Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee74a39b62 | |||
| 2f507e5721 | |||
| 9785782532 | |||
| 43a002c6aa | |||
| 8ce3e5792d | |||
| 1be4a2edbf | |||
| 561cd73760 | |||
| 3c910e89ab | |||
| b7e23a01f8 | |||
| 281dd29e62 | |||
| 1c17fecea3 | |||
| fcc3c84d76 | |||
| e86fc65643 | |||
| 014015014b | |||
| b392fa923c | |||
| 839404da0c | |||
| 79a27a946b | |||
| 26f3680197 | |||
| b0737380cd | |||
| 33212c71e4 | |||
| 2696e64ff5 | |||
| ff54632657 | |||
| 93cf506059 | |||
| c9fd1c535e | |||
| e6bd69f132 | |||
| c2f566512a | |||
| 39ce54fbcc | |||
| 55d04ce4ca | |||
| bd6dc6524a | |||
| 17bdd5fd9a | |||
| 2b32e124ab | |||
| 98eea3e2d6 | |||
| 0c654b173d | |||
| f57d4080dc | |||
| 91b0fb358c | |||
| 868391870c | |||
| 74476423ba | |||
| 599e7018f6 | |||
| 4ffff6b677 | |||
| aaf7b09bbe | |||
| 4f72aa31e0 | |||
| 9ca07a3f81 | |||
| 867f2504d6 | |||
| 08bdea8539 | |||
| 0de91fe35c | |||
| 2f5d09aef5 | |||
| 87279fc545 | |||
| 9c0e3b60ac | |||
| 31d680be54 | |||
| f758fe072f | |||
| 33c67ccc09 | |||
| 508a8cb6d0 | |||
| fec51bdbb6 | |||
| 455ef33b29 | |||
| 9f548a340d | |||
| 569066ef48 | |||
| a117fbb521 | |||
| d5213fc2da | |||
| 380e1f9782 | |||
| db57caf8a1 | |||
| 999c4b65ef | |||
| 0252e40527 | |||
| 97ba3ae9b5 | |||
| 6c2bdb3c0a | |||
| 0f6b2b464b | |||
| f114af8ce0 | |||
| a2dc29af9c | |||
| 8004b2a123 | |||
| 69fd0cb2f0 | |||
| 87ddb845f4 | |||
| 76207bbb62 | |||
| 2fe2a719ac | |||
| fe47eefdd9 | |||
| e8ce3cd228 | |||
| 07e003e5fc | |||
| c386b05960 | |||
| 48fd6f9675 | |||
| cecc6c0b41 | |||
| f1b03dffef | |||
| 759a42e589 | |||
| 2746242022 | |||
| f7bd466f31 | |||
| 539d063c22 | |||
| 27a0a0443b | |||
| 7d05125381 | |||
| 8f21dc5f9c | |||
| 49b741243e | |||
| 2d9022b19e | |||
| 0bccae1260 | |||
| d8661d5025 | |||
| d73a9e2860 | |||
| 597f6bc92b | |||
| 3a3104f4c6 | |||
| dd72f913a3 | |||
| 1b011ed483 | |||
| 2cf7db4bff | |||
| 6f356d3598 | |||
| 7703fa233a | |||
| 1dab24eef0 | |||
| 503689bf1a | |||
| ad2ece18fa | |||
| 57b2fc5205 | |||
| 34a65e099e | |||
| 848de4cd8a | |||
| 49ba06e0c2 | |||
| 6f901e3e2c | |||
| 7aba89655d | |||
| 0062510a4e | |||
| 714c2aed86 |
@@ -0,0 +1,15 @@
|
||||
# Keep the preview build context lean + reproducible.
|
||||
.git
|
||||
.gitea
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
backend/.venv/
|
||||
backend/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
e2e/
|
||||
mockups/
|
||||
docs/
|
||||
*.md
|
||||
!VERSION
|
||||
.pytest_cache/
|
||||
@@ -26,3 +26,4 @@ data/
|
||||
|
||||
# Claude Code (per-machine settings only; shared config under .claude/ is committed)
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
|
||||
+844
@@ -23,6 +23,850 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.46.1 — 2026-06-06
|
||||
|
||||
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
|
||||
crash deploying the three-tier series (v0.40.0+) onto a deployment that went
|
||||
through the v0.39.0 `default`→`<id>` project re-stamp: the re-stamp updated
|
||||
`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at
|
||||
the stale `project_id` that migration 029's per-project collection backfill could
|
||||
not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus
|
||||
stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale
|
||||
rows whose entry no longer exists. No operator action; **no schema change** — 029
|
||||
gains a repair prologue only.
|
||||
|
||||
Fixed:
|
||||
|
||||
- **Migration 029 repair prologue** — before rekeying, each entry-satellite table
|
||||
(`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has
|
||||
its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose
|
||||
entry no longer exists are dropped (stale cache, rebuildable from gitea), and
|
||||
stale rows that duplicate an already-correctly-stamped row are dropped (keeping
|
||||
the fresh copy). A no-op on a clean/fresh deployment (empty or already-
|
||||
consistent satellites), so fresh installs are unaffected — the existing 029
|
||||
test suite passes unchanged, plus a new regression test for the stale/dup/
|
||||
orphan shape.
|
||||
|
||||
No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair
|
||||
only mutates the rebuildable `cached_*` caches.
|
||||
|
||||
## 0.46.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder):
|
||||
*request-to-join + the cross-collection inbox (§22.8).* S4 shipped the invite
|
||||
half of joining a gated scope (an Owner grants a role directly); this ships the
|
||||
other half — a user who knows a scope exists asks to join it, naming a desired
|
||||
role, and the request is fanned out to that scope's Owners across the subtree
|
||||
(the cross-collection inbox, §22.11), who accept (writing the `memberships` row)
|
||||
or decline. Purely additive: one new table (no rebuild), one new endpoint group,
|
||||
and inbox/affordance UI; no change to existing read or write semantics.**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S6) and `SPEC.md` §22.8 / §22.11. This closes the **request-to-join**
|
||||
item flagged open at 0.45.0; the per-type *surfaces* (§22.4a items 1 & 3) remain
|
||||
the last S6 item, still pending a discovery/spec pass.
|
||||
|
||||
Added:
|
||||
|
||||
- **Request-to-join a scope (§22.8)** — `join_requests` (migration 032), a
|
||||
scope-grain analogue of `contribution_requests`: a `(scope_type ∈
|
||||
{project,collection}, scope_id, requester, requested_role, message, status)`
|
||||
row, one-open-per-`(scope, requester)`. New endpoints under
|
||||
`/api/scopes/{scope_type}/{scope_id}/`: `GET join-target`, `POST
|
||||
join-requests`, and the Owner's `POST .../{id}/accept` / `.../{id}/decline`.
|
||||
Accept writes the membership via `memberships.grant` (the §22.8 "accepting
|
||||
writes the membership row"); the request POST does **not** require the scope be
|
||||
readable — that is how one joins a *gated* scope they were told about.
|
||||
- **The cross-collection inbox (§22.11)** — a join request fans one actionable
|
||||
§15 notification to every Owner whose reach covers the scope (collection
|
||||
Owners + project Owners + global Owners + deployment owners/admins), so it
|
||||
surfaces in the one deployment-wide inbox of anyone who can grant it. New
|
||||
event kinds `join_request_on_scope` (owner-facing, Accept/Decline inline) and
|
||||
`join_request_accepted` / `join_request_declined` (requester-facing).
|
||||
- **Frontend** — a "Request to join" affordance in the project collection
|
||||
directory and the per-collection catalog footer (shown when the viewer is
|
||||
signed in, granted, and holds no role reaching the scope — the new
|
||||
`viewer.can_request_join` flag), a `JoinRequestModal`, and the actionable
|
||||
`JoinRequestRow` in the inbox.
|
||||
- **`auth.effective_role_at_scope(user, scope_type, scope_id)`** — the
|
||||
scope-grain twin of `effective_scope_role` (which keys on a collection),
|
||||
folding global → project for a project target; drives the "already a member?"
|
||||
gate and the `can_request_join` flag.
|
||||
|
||||
No upgrade steps: migration 032 is additive (a new table; no rebuild, no FK
|
||||
changes to existing tables), and no env var or config changes are required.
|
||||
|
||||
## 0.45.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge +
|
||||
per-collection model universe + the type-driven entry noun.* The three-tier
|
||||
model (deployment → project → RFC collection) is now written into the binding
|
||||
`SPEC.md` as a canonical §22, so the spec finally reflects the shipped S1–S5
|
||||
behavior; a collection may narrow its project's model universe; and the entry
|
||||
noun ("RFC" / "Spec" / "Feature") follows the collection's type. Purely additive
|
||||
over S5 — one additive column (no rebuild), docs, and two small features; no
|
||||
change to existing read or write semantics.**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S6; Parts A/B/D, merged into `SPEC.md` §22). With S6 the §22
|
||||
three-tier model is realized in the binding contract. **Two S6 items remain
|
||||
open and are carried to a follow-up slice** (they want a discovery/spec pass
|
||||
first, lacking BDD scenarios in Part C): the per-type *surfaces* — per-type
|
||||
frontmatter schemas, the `specification` release-planning data model, the `bdd`
|
||||
scenario/coverage views (§22.4a items 1 & 3, flagged "first proposals" in the
|
||||
design doc) — and **request-to-join + the cross-collection inbox** (§22.8; S4
|
||||
shipped the invite half).
|
||||
|
||||
Added:
|
||||
|
||||
- **§22 merged into `SPEC.md` (the keystone)** — a canonical §22.1–§22.14 writes
|
||||
the three-tier model into the binding spec: the tiers + collection-grain
|
||||
isolation, the registry (`projects.yaml`) + `.collection.yaml` manifests,
|
||||
one-content-repo-per-project, per-collection slug identity, collection
|
||||
`type`/`initial_state`/`unreviewed`, two-tier (narrow-only) visibility, the
|
||||
unified `{owner, contributor}` role vocabulary at `{global, project,
|
||||
collection}`, the four-layer most-permissive union, discovery/joining,
|
||||
runtime branding, `/p/<project>/c/<collection>/` routing, the one inbox, the
|
||||
per-collection model universe, and the default-project+collection migration.
|
||||
The **S3 keystone reinterpretation of §B.1/§B.3** lands here: a plain granted
|
||||
account is a granted *account*, not a global write role; "global RFC
|
||||
Contributor" is an explicit `scope_type='global'` grant; the implicit-public
|
||||
write baseline is grandfathered onto the migration-seeded default collection
|
||||
only. Forward-pointer amendment notes added at §1/§2/§5/§6. The registry +
|
||||
manifest formats are documented for operators in
|
||||
[`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
|
||||
- **Per-collection model universe (§22.12)** — a collection's `.collection.yaml`
|
||||
may carry an `enabled_models` list that narrows its project's universe, which
|
||||
in turn narrows the deployment `ENABLED_MODELS`. Resolution (extending
|
||||
§6.6/§6.7) is `funder ∩ per-entry models ∩ collection ∩ project`, with the
|
||||
operator providers as the ceiling — a collection can only narrow, never widen.
|
||||
An absent list inherits the parent; an empty list opts the collection out of
|
||||
AI. Surfaced as `enabled_models` on `GET /api/projects/:id/collections/:cid`.
|
||||
- **Type-driven entry noun (§22.4a)** — the displayed noun for an entry follows
|
||||
the collection type (`document` → "RFC", `specification` → "Spec", `bdd` →
|
||||
"Feature"), defined once in the framework and read from the API
|
||||
(`entry_noun` on the collection, directory, and project surfaces) rather than
|
||||
hardcoded. The catalog's propose control and the propose modal name entries
|
||||
accordingly.
|
||||
|
||||
Schema:
|
||||
|
||||
- **Migration `031_collection_enabled_models.sql`** — additive
|
||||
`collections.config_json` (paralleling `projects.config_json`), holding the
|
||||
per-collection `enabled_models`. No table rebuild; `NULL` means "inherit the
|
||||
project's universe."
|
||||
|
||||
Upgrade steps (from 0.44.0):
|
||||
|
||||
1. Rebuild and redeploy as usual; the framework **SHALL** apply migration
|
||||
`031_collection_enabled_models.sql` automatically on start (additive column,
|
||||
no data movement, no operator action).
|
||||
2. A deployment **MAY** now add an `enabled_models` list to any
|
||||
`.collection.yaml` to narrow that collection's model universe, and **MAY**
|
||||
create `specification`- or `bdd`-typed collections to get the corresponding
|
||||
entry noun. Both are optional; absent them every collection behaves exactly
|
||||
as before.
|
||||
|
||||
## 0.44.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app
|
||||
create-project + the global directory.* A global Owner can now stand up a new
|
||||
project end-to-end from the UI — the bot provisions a Gitea content repo and
|
||||
commits the project to `projects.yaml`, and the registry mirror picks it up — and
|
||||
the deployment directory shows a role-aware empty state. Purely additive over S4:
|
||||
one new endpoint and UI, no schema migration, and no change to existing read or
|
||||
write semantics. Completes acceptance scenarios `@S5` (C3.1–C3.2: the
|
||||
global-directory empty states).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S5, Part C.3 scenarios C3.1–C3.2). With S5 the role-and-empty-state
|
||||
focus of Parts B/C is complete; type modules, membership lifecycle, hardening,
|
||||
and the SPEC merge remain S6.
|
||||
|
||||
Added:
|
||||
|
||||
- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner
|
||||
only) provisions a Gitea content repo under the deployment org (seeding a
|
||||
`README.md` so `main` exists), commits a new project entry to the registry's
|
||||
`projects.yaml`, then re-runs the registry mirror so the `projects` + default
|
||||
`collections` rows flow from the registry (§22.2 keeps the registry the source
|
||||
of truth). The bot remains the only Git writer (§1); the action is logged
|
||||
(`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not
|
||||
`default`), `name`, `type` (the initial/default collection's), optional
|
||||
`visibility` (defaults to `public`), and an optional `content_repo` name
|
||||
(defaults to `<id>-content`).
|
||||
- **Create-project gate** — `auth.can_create_project`: "+ New project" is a
|
||||
global-Owner action — a deployment owner/admin, or a holder of an explicit
|
||||
`scope_type='global'` Owner grant. A project- or collection-scope grant, or a
|
||||
global RFC Contributor, cannot create projects (that role creates collections,
|
||||
not projects).
|
||||
- **Deployment-directory capability + redirect signals** — `GET /api/deployment`
|
||||
now carries a `viewer` block (`can_create_project`) and
|
||||
`default_project_readable` (whether the N=1 land-in-corpus redirect target is
|
||||
reachable by this viewer). The frontend bounces into the default project only
|
||||
when it is readable; a gated default (C3.2) or an absent default (C3.1, a
|
||||
deployment with no projects) falls through to the directory's empty state
|
||||
instead of a 404.
|
||||
- **Role-aware global directory (§22 C3.1–C3.2)** — a global Owner landing on an
|
||||
empty deployment directory sees a "Create your first project" CTA (opening the
|
||||
create-project modal: id, name, type, visibility, optional content-repo); a
|
||||
non-owner sees "Nothing has been shared with you yet" and no create action. The
|
||||
directory also gains an Owner-only "New project" control when it is non-empty.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. No operator action is required. S5 adds one endpoint and UI only — there is no
|
||||
migration and no change to existing authorization outcomes. A deployment that
|
||||
was declaring projects directly in `projects.yaml` keeps working unchanged;
|
||||
the new UI is an additional, equivalent way to commit the same registry entry.
|
||||
|
||||
## 0.43.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
|
||||
surfaces + role-aware empty states.* An Owner can now grant scope roles from the
|
||||
UI, and each tier shows a role-appropriate empty state. Purely additive over S3:
|
||||
new endpoints and UI, no schema migration, and no change to existing read or
|
||||
write semantics. Completes acceptance scenarios `@S4` (C2.1–C2.7: invitation
|
||||
reach, bounding, supersession, and the pending-account floor; C3.3–C3.5: the
|
||||
create-first-collection and propose-first empty states).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S4, Part C.2 and C.3). The in-app create-project flow and the
|
||||
global-directory empty states (C3.1–C3.2) remain S5.
|
||||
|
||||
Added:
|
||||
|
||||
- **Scope-role invitation surface (§22 C.2)** — `POST
|
||||
/api/projects/<id>/members` grants `{owner, contributor}` to an existing
|
||||
account (looked up by email) at the project, or at one collection (with
|
||||
`collection_id`). The grant writes a `memberships` row immediately and emits a
|
||||
§15 personal-direct notification (`scope_role_granted`) naming the project and
|
||||
role — a direct grant, not an accept round-trip. `GET` lists the project
|
||||
subtree's grants (project-Owner view); `DELETE
|
||||
/api/projects/<id>/members/<user_id>` (optionally `?collection_id=`) revokes.
|
||||
- **Invitation gates** — `auth.can_invite_at_project` /
|
||||
`can_invite_at_collection`: managing membership is an Owner capability bounded
|
||||
by the inviter's reach. A project Owner grants at the project or any collection
|
||||
within it; a collection Owner grants only at that collection (never the project
|
||||
or globally); an RFC Contributor manages no membership.
|
||||
- **Broader-scope-supersedes (§22 C.2.6)** — granting at a broader scope removes
|
||||
the grantee's narrower rows that the new grant subsumes (same-or-lower role
|
||||
rank within the subtree); a stronger child grant survives a weaker parent grant
|
||||
(no negative override).
|
||||
- **Pending-account floor (§22 C.2.7)** — a grant to a `pending` deployment
|
||||
account is recorded but confers no write until the account is granted at the
|
||||
deployment (the §6 admission floor in `effective_scope_role`).
|
||||
- **Viewer capability flags** — `GET /api/projects/<id>/collections` carries a
|
||||
`viewer` block (`can_create_collection`, `can_invite`, `role`); `GET
|
||||
/api/projects/<id>/collections/<cid>` carries `viewer.can_contribute /
|
||||
can_invite / role`. These drive the role-aware UI without a second round-trip.
|
||||
- **Role-aware empty states (§22 C.3.3–C.3.5)** — a project Owner landing on an
|
||||
empty project sees a "Create your first collection" CTA (opening the
|
||||
create-collection modal — surfacing the S2 endpoint, previously UI-less); a
|
||||
contributor without create rights sees the bare empty directory; a collection
|
||||
contributor landing on an empty collection sees "Propose the first entry"
|
||||
(anonymous readers keep the S2 sign-in prompt). The directory also gains an
|
||||
Owner-only **Members** control opening the invitation modal.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. No operator action is required. S4 adds endpoints and UI only — there is no
|
||||
migration and no change to existing authorization outcomes. A deployment that
|
||||
was managing `memberships` rows directly (the S3 administrative path) keeps
|
||||
working; the new UI is an additional way to write the same rows.
|
||||
|
||||
## 0.42.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
|
||||
*scope-role enforcement + collection-grain visibility.* Authorization is now
|
||||
resolved by the four-layer most-permissive union of §B.2 — global → project →
|
||||
collection → per-entry — over the unified `{owner, contributor}` roles, with the
|
||||
§22.5 visibility gate enforced at the **collection** grain. A collection can be
|
||||
"hidden from public existence" (`gated`): invisible to anonymous viewers and
|
||||
omitted from the project directory, yet readable and listed for any contributor
|
||||
holding a scope role that reaches it (collection / project / global). A
|
||||
collection's visibility may be set only as strict or stricter than its project's.
|
||||
Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the
|
||||
most-permissive union, and no-negative-override).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and
|
||||
role-keyed empty states remain S4; grants in S3 are applied administratively
|
||||
(via the `memberships` table / an Owner-authorized create surface).
|
||||
|
||||
Added:
|
||||
|
||||
- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)`
|
||||
folds a deployment owner/admin (global Owner), an explicit global-scope grant,
|
||||
a project-scope grant, and a collection-scope grant into the most-permissive
|
||||
unified role over a collection. Owner outranks RFC Contributor; there is no
|
||||
negative override (a child scope can never subtract a parent grant).
|
||||
- **Global-scope grants** — migration 030 extends `memberships.scope_type` to
|
||||
`{global, project, collection}`. A "global RFC Contributor" (writes in every
|
||||
collection of every project, distinct from a deployment owner) is a
|
||||
`scope_type='global'` row (sentinel `scope_id='*'`).
|
||||
- **Collection-grain visibility enforcement** — `can_read_collection` /
|
||||
`require_collection_readable` gate the collection-scoped read, entry, and
|
||||
propose routes; the project collection-directory (`GET
|
||||
/api/projects/<id>/collections`) is now viewer-aware (a hidden/gated collection
|
||||
is listed only for a scope-role holder; `unlisted` stays omitted from
|
||||
enumeration). The effective read gate is the stricter of the project's and the
|
||||
collection's visibility.
|
||||
- **Collection visibility strictness** — a collection may narrow but never widen
|
||||
its project's visibility (`public` < `unlisted` < `gated`). Enforced at
|
||||
create-collection (422 on a looser request) and clamped at the registry mirror.
|
||||
- **create-collection authority widened (§B.1)** — `POST
|
||||
/api/projects/<id>/collections` now admits a project-scope or global-scope
|
||||
grant holder (Owner **or** RFC Contributor — the project-level "create a
|
||||
collection" affordance), not only a deployment owner/admin. A collection-scope
|
||||
grant cannot create sibling collections.
|
||||
|
||||
Changed (breaking):
|
||||
|
||||
- **Write standing now requires an explicit scope grant outside the default
|
||||
collection.** The pre-three-tier implicit-on-public write baseline (a granted
|
||||
deployment `contributor` may propose on any public project with no membership
|
||||
row) is **narrowed to the migration-seeded `default` collection only** (the N=1
|
||||
case, §22.13). On every *explicitly-created* collection — and on every project
|
||||
beyond the default — proposing, discussing, branching, and contributing now
|
||||
require an explicit `{owner, contributor}` grant at the collection, its
|
||||
project, or global scope. Reads of public collections are unchanged.
|
||||
- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute,
|
||||
PR/discussion/contribution moderation) are re-pointed from the project grain to
|
||||
the entry's **collection** grain, so a collection Owner administers exactly
|
||||
their collection's subtree and no more.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and
|
||||
is backward-data-compatible — existing `memberships` rows are preserved. No
|
||||
operator action is required for the migration itself.
|
||||
2. **A single-collection (N=1) deployment needs no further action.** The `default`
|
||||
collection keeps the implicit-on-public write baseline, so existing granted
|
||||
contributors keep proposing exactly as before.
|
||||
3. **A deployment that has created additional collections (S2) MUST grant scope
|
||||
roles to its contributors.** Any contributor who should write in a non-default
|
||||
collection (or in a second project) now needs an explicit grant: a
|
||||
`memberships` row at `scope_type` `collection` (that collection), `project`
|
||||
(its project — covers every collection within), or `global` (`scope_id='*'` —
|
||||
every project). A deployment owner/admin is unaffected (global Owner by role).
|
||||
4. To make a collection **hidden from the public**, set `visibility: gated` in its
|
||||
`.collection.yaml` (or the project's `visibility` in `projects.yaml`); the
|
||||
value MUST be as strict or stricter than the parent project's. The mirror
|
||||
clamps a looser value and logs a warning.
|
||||
|
||||
## 0.41.0 — 2026-06-05
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
|
||||
second collection.* A deployment can now host more than one RFC collection per
|
||||
project: a second collection is created, navigated, and proposed into beside the
|
||||
default one. Purely additive — a single-collection deployment is unchanged (its
|
||||
`/p/<project>/` still redirects into the sole collection, C3.7/C3.8). Completes
|
||||
acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public
|
||||
collection sees an empty catalog with no propose action and a sign-in prompt).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S2) and the slice plan
|
||||
[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md).
|
||||
Scoped {owner, contributor} roles at the collection axis remain S3; the
|
||||
role-keyed create/propose-first empty states remain S4.
|
||||
|
||||
Added:
|
||||
|
||||
- **Named collections via `.collection.yaml`** — the registry mirror walks each
|
||||
project's content repo and upserts a collection per
|
||||
`<subfolder>/.collection.yaml` manifest (`type`, optional `visibility` /
|
||||
`initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the
|
||||
project's when omitted). The default collection still flows from
|
||||
`projects.yaml`.
|
||||
- **create-collection** — `POST /api/projects/<id>/collections` (deployment
|
||||
owner/admin). The bot commits a `.collection.yaml` to the content repo's
|
||||
`main`, then the registry re-mirrors so the `collections` row appears (the
|
||||
registry stays the source of truth, §22.2). New reads
|
||||
`GET /api/projects/<id>/collections` and `…/collections/<cid>`.
|
||||
- **Collection-scoped serve + propose** —
|
||||
`GET /api/projects/<id>/collections/<cid>/rfcs[/<slug>]` and
|
||||
`POST …/collections/<cid>/rfcs/propose`. A propose writes the entry under the
|
||||
target collection's `<subfolder>/rfcs/`.
|
||||
- **Collection directory at `/p/<project>/`** — lists the project's visible
|
||||
collections, or redirects into the sole one when there is exactly one
|
||||
(preserving the S1 single-collection UX). The catalog rail + entry views read
|
||||
the active `/c/<collection>/` segment and scope to it.
|
||||
|
||||
Changed:
|
||||
|
||||
- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo`
|
||||
iterates each project's collections and reads each collection's
|
||||
`<subfolder>/rfcs/`, keying `cached_rfcs` by `collection_id`. The default
|
||||
collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged.
|
||||
|
||||
> ### Upgrade steps (0.40.0 → 0.41.0)
|
||||
>
|
||||
> - No required operator action — the slice is additive and the default-collection
|
||||
> paths are unchanged. A deployment **MAY** deploy this version with no config
|
||||
> change and keep running exactly as on 0.40.0.
|
||||
> - To add a second collection, a deployment owner/admin **MAY** call
|
||||
> `POST /api/projects/<id>/collections` (or commit a `<subfolder>/.collection.yaml`
|
||||
> to the content repo directly); the registry mirror picks it up on the next
|
||||
> refresh.
|
||||
> - Deployments that pin the framework version **MUST** bump their version pin to
|
||||
> `0.41.0`.
|
||||
|
||||
## 0.40.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
|
||||
grain. A deployment now hosts N projects, each owning one content repo and
|
||||
holding N RFC *collections*, each collection a typed corpus. S1 inserts the
|
||||
collection grain beneath today's project as the invisible default: the
|
||||
deployment runs exactly as before, now with a real collection layer and one
|
||||
extra `/c/<collection>/` URL segment. Per-corpus configuration (`type`,
|
||||
`initial_state`), entry keys, and membership move to the collection grain;
|
||||
no operator action is required beyond deploying.**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part A model, Part E / §A.6 migration strategy). The structural model (Parts
|
||||
A–D) is unaffected; the SPEC.md merge itself rides slice S6.
|
||||
|
||||
Added:
|
||||
|
||||
- **Migration `029_collections.sql`** — (1) a `collections` table
|
||||
`(id, project_id, type, subfolder, initial_state, visibility, name,
|
||||
registry_sha)` beneath `projects`; (2) the per-corpus fields (`type`,
|
||||
`initial_state`) move **down** off `projects` onto the collection; (3) one
|
||||
**default collection** per project (`id='default'` for the standard
|
||||
single-project deployment, `subfolder` = repo root), inheriting the project's
|
||||
type / initial_state / visibility; (4) the 13 entry-corpus tables re-key
|
||||
`(project_id, slug)` → `(collection_id, slug)` via the migration-028 rebuild
|
||||
pattern, each row mapped to its project's default collection; (5)
|
||||
`project_members` generalises into **`memberships(scope_type ∈ {project,
|
||||
collection}, scope_id, user_id, role, …)`** with the role enum collapsed to
|
||||
`{owner, contributor}` (M2's `project_admin`→`owner`,
|
||||
`project_contributor`→`contributor`; `project_viewer` folded into
|
||||
`contributor` this pass — the read-only tier is deferred).
|
||||
- **`app/collections.py`** — collection resolution helpers
|
||||
(`default_collection_id`, `collection_type`, `collection_initial_state`,
|
||||
`project_of_collection`).
|
||||
- **`/c/<collection>/` URL segment** — the canonical entry route is now
|
||||
`/p/<project>/c/<collection>/e/<slug>`. The project landing
|
||||
`/p/<project>/` redirects into the project's single (default) collection
|
||||
(C3.7); the deployment root `/` continues to redirect into the sole project
|
||||
(C3.8).
|
||||
|
||||
Changed:
|
||||
|
||||
- **The registry mirror** writes a project's grouping-tier fields (name,
|
||||
content_repo, visibility, config) to `projects` and the per-corpus fields
|
||||
(`type`, `initial_state`) to its default collection; §22.4a type-immutability
|
||||
is now enforced on the collection.
|
||||
- **Backend threading** — `auth.project_of_rfc` recovers a project by joining
|
||||
`collections`; `auth.project_member_role` reads `memberships`;
|
||||
`cache`/`api_*`/`funder` writers + readers key the 13 entry-corpus tables by
|
||||
`collection_id` (the denormalised `project_id` tags on
|
||||
`cached_prs`/`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches`
|
||||
are unchanged). Serving stays project-scoped (collection = default); the
|
||||
registry `.collection.yaml` reader and collection-aware serving land in S2.
|
||||
|
||||
Breaking:
|
||||
|
||||
- **`/p/<project>/e/<slug>` URLs gain a `/c/<collection>/` segment.** The
|
||||
shipped v0.35.0 `/p/<project>/e/<slug>` form is preserved by a client-side
|
||||
redirect into the default collection; the pre-multi-project `/rfc/<slug>` and
|
||||
`/proposals/<n>` server 308s now target `/p/<default>/c/<default>/…`.
|
||||
|
||||
> ### Upgrade steps (0.39.0 → 0.40.0)
|
||||
>
|
||||
> - A deployment **MUST** deploy this version with its migrations applied (the
|
||||
> standard startup path runs `029_collections.sql` automatically); the
|
||||
> migration seeds the default collection and re-keys existing entries with no
|
||||
> data loss. No configuration change is required.
|
||||
> - Operators **SHOULD** be aware that the canonical entry URL is now
|
||||
> `/p/<project>/c/default/e/<slug>`. Existing `/p/<project>/e/<slug>`,
|
||||
> `/rfc/<slug>`, and `/proposals/<n>` links keep working (client redirect /
|
||||
> server 308). External systems that hardcoded the old form **SHOULD** be
|
||||
> updated to the collection-scoped form at their convenience.
|
||||
> - Deployments that pin the framework version **MUST** bump their version pin
|
||||
> to `0.40.0`.
|
||||
|
||||
Deferred (later slices): creating + navigating a second collection and the
|
||||
registry `.collection.yaml` reader (S2); the four-layer scope-role resolver and
|
||||
the `viewer` read tier (S3); invitation surfaces (S4); in-app create-project
|
||||
(S5); per-type surfaces, membership lifecycle, and the SPEC.md merge (S6).
|
||||
|
||||
## 0.39.0 — 2026-06-04
|
||||
|
||||
**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can
|
||||
move its original corpus off the bootstrap `default` id onto a meaningful slug
|
||||
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
|
||||
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`projects.restamp_default_project(config)`** — at startup, after the
|
||||
registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
|
||||
bootstrap-stamped rows still exist, it renames `project_id` from `default` to
|
||||
the configured id across **every** project-scoped table (discovered by
|
||||
column, so it stays correct as the schema grows) and drops the stale
|
||||
`default` `projects` row (its data has moved to the configured row the
|
||||
registry mirror created). The rename runs with FK enforcement off — parent
|
||||
and child rows move together, so the composite FKs stay consistent — with a
|
||||
`foreign_key_check` backstop before commit. Idempotent.
|
||||
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
|
||||
move to the new id, the stale row is dropped, FK integrity holds, the second
|
||||
call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
|
||||
450 backend green.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
|
||||
matching project (same `id`) to `projects.yaml`. On the next deploy the
|
||||
re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
|
||||
become public. Leave it unset to keep the `default` id (no change).
|
||||
|
||||
## 0.38.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
|
||||
proposed *into a specific project*, landing in that project's content repo and
|
||||
surfacing under that project's proposals. A non-default project is no longer
|
||||
read-only. No upgrade steps; single-project deployments are unaffected.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project
|
||||
(read-gated, then project-level contribute-gated, §22.6/§22.7). The propose
|
||||
body is now a project-parameterized helper; the unscoped `/api/rfcs/propose`
|
||||
stays as the default-project compat path. Slug uniqueness, the idea-PR
|
||||
reservation, the landing state (§22.4b), and the `proposed_use_cases` row are
|
||||
all scoped to the target project.
|
||||
- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one
|
||||
project.
|
||||
- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates
|
||||
every project's `content_repo`, stamping `cached_prs.project_id` (was: the
|
||||
default project only). `projects.content_repo(pid)` helper added.
|
||||
- **Tests:** `test_project_scoped_propose.py` — propose into a second project
|
||||
lands in its content repo + shows only under its proposals (not the
|
||||
default's); gated-project propose 404s a non-member. 447 backend green.
|
||||
|
||||
Changed:
|
||||
|
||||
- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`;
|
||||
`ProposeModal` takes a `projectId`; `App` resolves the current project from
|
||||
the `/p/<id>/` URL so the propose modal targets it; `Catalog` lists that
|
||||
project's proposals.
|
||||
|
||||
Known limitation (next slice): the **edit** write flows — branch / PR /
|
||||
graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not
|
||||
yet project-scoped (they still target the default project's content repo). Per
|
||||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3.
|
||||
|
||||
## 0.37.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
|
||||
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
|
||||
namespace. No upgrade steps; single-project deployments are unaffected.**
|
||||
|
||||
This is the slice that makes "multiple projects" *observable*: M3-frontend
|
||||
(v0.35.0) shipped the shell with a "not served" guard for any non-default
|
||||
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
|
||||
#2's corpus.
|
||||
|
||||
Added:
|
||||
|
||||
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
|
||||
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
|
||||
(entry by `(project_id, slug)`), both behind the §22.5 read gate
|
||||
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
|
||||
the default-project compat path.
|
||||
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
|
||||
iterates every `projects` row and mirrors each project's `content_repo` into
|
||||
`cached_rfcs` stamped with that `project_id` (was: the default project only).
|
||||
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
|
||||
isolation (same slug under two projects resolves distinctly; a slug present
|
||||
only in one 404s under the other), gated-project 404. 445 backend green.
|
||||
|
||||
Changed:
|
||||
|
||||
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
|
||||
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
|
||||
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
|
||||
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
|
||||
|
||||
Known limitation (next slice): the **write** path (propose / branch / PR /
|
||||
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
|
||||
project-scoped — write affordances still target the default project, so a
|
||||
non-default project is effectively read-only until Plan B's write slice. (No
|
||||
live impact: no deployment runs a second project yet.) Per
|
||||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
|
||||
|
||||
## 0.36.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
|
||||
activates project #2. No behavior change (deployments are still single-project
|
||||
`default`); migration `028` runs automatically on deploy.**
|
||||
|
||||
This lands the table rebuilds migration 026 deliberately deferred "until a
|
||||
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
|
||||
UNIQUE constraints so two projects can hold the same slug without colliding.
|
||||
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
|
||||
hygiene: the schema change deploys and is verified on its own, smaller blast
|
||||
radius.
|
||||
|
||||
Added:
|
||||
|
||||
- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
|
||||
slug keys: `cached_rfcs` PK `(slug)` → `(project_id, slug)`; the `UNIQUE`/PK
|
||||
on `cached_branches`, `branch_visibility`, `branch_contribute_grants`,
|
||||
`stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`,
|
||||
`rfc_collaborators`, `contribution_requests`, `proposed_use_cases` all gain
|
||||
`project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
|
||||
`rfc_collaborators`, `contribution_requests` become composite
|
||||
`(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
|
||||
unchanged — `(repo, pr_number)` is already globally unique.)
|
||||
- **Migration-runner capability** (`db.run_migrations`): a migration whose
|
||||
first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
|
||||
toggled OFF around it (required by SQLite's table-rebuild procedure, and a
|
||||
no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
|
||||
the migration loudly on any dangling reference.
|
||||
|
||||
Changed:
|
||||
|
||||
- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
|
||||
(`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
|
||||
`api.py`, `funder.py`) so they continue to match the new composite indexes.
|
||||
The inserted `project_id` still defaults to `default`, so behavior is
|
||||
identical for a single-project deployment.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
|
||||
the listed tables in one transaction with FK enforcement off and verifies
|
||||
`foreign_key_check` after. No data is dropped (rows already carry
|
||||
`project_id` from migration 026, M1). No config change.
|
||||
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
|
||||
default-id re-stamp + the frontend guard removal) is the next slice (Plan B
|
||||
2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.
|
||||
|
||||
## 0.35.0 — 2026-06-04
|
||||
|
||||
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
|
||||
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
|
||||
switcher, and server-side 308 redirects off the old corpus-root URLs.
|
||||
Completes the runtime-config cut 0.33.0 began.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
|
||||
`GET /api/deployment` once and provides `{ name, tagline,
|
||||
defaultProjectId, projects[], loading }` to the tree. The neutral
|
||||
`brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
|
||||
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
|
||||
(§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
|
||||
`GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
|
||||
custom-property overrides (reset on switch/unmount so accents never bleed),
|
||||
provides `ProjectContext`, and sets the tab title to the project name.
|
||||
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
|
||||
corpus-served (default) project; any other id renders a "content not yet
|
||||
served" placeholder (`NotServedPlaceholder`), so per-project serving (the
|
||||
next backend slice, Plan B) can land without a wrong-content footgun.
|
||||
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
|
||||
caller-visible projects as cards when **2+** are visible; the **N=1** case
|
||||
redirects straight into the single project (`/p/<id>/`), preserving OHM's
|
||||
"land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
|
||||
rides deployment chrome when 2+ projects are visible.
|
||||
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
|
||||
(§22.4a); the route segment stays the generic `/e/`.
|
||||
- **`GET /api/deployment`** now returns **`default_project_id`** — the
|
||||
corpus-served project the frontend guard keys on.
|
||||
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
|
||||
`/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
|
||||
`/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
|
||||
external "RFC-0001"-style links and bookmarks keep working.
|
||||
|
||||
Breaking:
|
||||
|
||||
- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
|
||||
fails without it; the deployment name comes from the registry
|
||||
(`deployment.name` in `projects.yaml`) served at runtime via
|
||||
`GET /api/deployment`. The same build now serves any deployment. The
|
||||
build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
|
||||
plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
|
||||
sets the real title after config loads.
|
||||
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
|
||||
`/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
|
||||
and served as backend 308s instead — so nginx must route `/rfc/` and
|
||||
`/proposals/` to the backend rather than the SPA `index.html`.
|
||||
|
||||
Scope boundary (informational, not breaking): RFC *data* is still served
|
||||
unscoped for the default project this slice — per-project corpus serving is
|
||||
the next backend slice (Plan B). Non-default projects show the placeholder.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
|
||||
blocks that `proxy_pass` to the backend, **before** the SPA `location /`
|
||||
fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
|
||||
`testing/web.nginx.conf`) already carry them; a deployment running a custom
|
||||
vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
|
||||
redirecting.
|
||||
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
|
||||
is now the header brand and tab title (already required since 0.33.0).
|
||||
`tagline` shows on the directory.
|
||||
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
|
||||
read (no error if left — simply ignored).
|
||||
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
|
||||
deployment name from `/api/deployment`; `/` lands in your project (N=1) or
|
||||
shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
|
||||
`/p/<default>/e/<slug>`; `/api/health` is green.
|
||||
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
|
||||
Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
|
||||
is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
|
||||
there post-0.33.0). This slice is covered by Vitest unit tests
|
||||
(`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
|
||||
backend redirect tests (`backend/tests/test_api_deployment.py`).
|
||||
|
||||
## 0.34.0 — 2026-06-04
|
||||
|
||||
**Minor — containerize rfc-app for per-PR preview environments (flotilla
|
||||
SPEC §15). Additive: production is unaffected — it still deploys via the
|
||||
pin-based on-VM gesture. No upgrade steps for existing deployments.**
|
||||
|
||||
Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build
|
||||
a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a
|
||||
seeded **synthetic** database and **zero real secrets** (test-secret env only):
|
||||
|
||||
- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA
|
||||
via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process
|
||||
uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus
|
||||
TLS, minus prod secrets). Single process, single SQLite file (§4.2).
|
||||
- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`,
|
||||
boots uvicorn (which runs migrations), and applies the synthetic seed on a
|
||||
fresh DB.
|
||||
- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII).
|
||||
- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare
|
||||
always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the
|
||||
operator loads into flotilla's preview overlay layer.
|
||||
|
||||
The framework still knows nothing about flotilla — the image is a generic
|
||||
container of rfc-app; flotilla is one possible orchestrator of it.
|
||||
|
||||
## 0.33.0 — 2026-06-04
|
||||
|
||||
**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
|
||||
runtime config. The framework now learns its projects from
|
||||
`REGISTRY_REPO/projects.yaml`; `META_REPO` is retired. Migration `027`
|
||||
runs automatically on deploy.**
|
||||
|
||||
Added:
|
||||
|
||||
- **Project registry mirror** (`app/registry.py`): the framework reads
|
||||
`projects.yaml` from `REGISTRY_REPO` and mirrors its `deployment:`
|
||||
block and `projects:` entries into the `projects` table + a new
|
||||
`deployment` singleton. The mirror runs at startup (reconciler) and
|
||||
on every §4 webhook push to the registry repo.
|
||||
- **`GET /api/deployment`** — returns `name`, `tagline`, and the list
|
||||
of visible projects (each item carries `id`, `name`, `type`,
|
||||
`visibility`). Replaces the build-time `VITE_APP_NAME` as the
|
||||
authoritative runtime config source (the frontend cut lands in
|
||||
M3-frontend).
|
||||
- **`GET /api/projects/:id`** — returns `id`, `name`, `tagline`, `type`,
|
||||
`visibility`, `initial_state`, and `theme` for a single project.
|
||||
- **§22.4b `initial_state`** honored at propose time: new RFCs enter
|
||||
the state named by the project's `initial_state` field (default:
|
||||
`super-draft`; `bdd` projects default to `active`).
|
||||
- **§22.4c `unreviewed` flag**: newly proposed RFCs are flagged
|
||||
`unreviewed = true`. Owners clear it via
|
||||
`POST /api/projects/:id/rfcs/:slug/mark-reviewed`. The catalog
|
||||
accepts `?unreviewed=true` to filter to the review queue.
|
||||
- **Migration `027`** (additive): adds `projects.type` /
|
||||
`projects.initial_state`, the `deployment` table, and
|
||||
`cached_rfcs.unreviewed` / `reviewed_at` / `reviewed_by`.
|
||||
|
||||
Breaking:
|
||||
|
||||
- `META_REPO` is retired. The app **refuses to start** if `REGISTRY_REPO`
|
||||
is unset. The corpus mirror now reads each project's `content_repo`
|
||||
from `projects.yaml` rather than from the `META_REPO` env var.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** create a registry repo under your deployment's Gitea org.
|
||||
2. **MUST** author `projects.yaml` at its root with a `deployment:` block
|
||||
(`name`, `tagline`) and one `projects:` entry for your existing corpus:
|
||||
```yaml
|
||||
deployment:
|
||||
name: <your deployment name>
|
||||
tagline: <your tagline>
|
||||
projects:
|
||||
- id: default
|
||||
name: <your project name>
|
||||
type: document
|
||||
content_repo: <your old META_REPO value>
|
||||
visibility: public
|
||||
```
|
||||
Keep `id: default` for this release; the pretty-slug re-stamp lands in
|
||||
the next backend slice, before any `/p/` URL is public.
|
||||
3. **MUST** set `REGISTRY_REPO=<your registry repo name>` and **MUST**
|
||||
remove `META_REPO` (or leave it unset — it is ignored but its presence
|
||||
may cause confusion).
|
||||
4. **MUST** add a Gitea webhook on the registry repo pointing at
|
||||
`/api/webhooks/gitea` (same secret as the corpus webhook).
|
||||
5. **MUST** deploy. Migration `027` runs automatically at startup; the
|
||||
registry mirror reconciles immediately after. Verify
|
||||
`GET /api/deployment` returns your project and `/api/health` is green.
|
||||
6. **SHOULD** rebuild the frontend (no new env var is required until
|
||||
M3-frontend lands, but the frontend currently still reads
|
||||
`VITE_APP_NAME` for the display name).
|
||||
|
||||
## 0.32.0 — 2026-06-01
|
||||
|
||||
**Minor — graduation's integer RFC number is now optional, and RFC
|
||||
owners + site owners can retire (soft-delete) RFCs. Schema migration
|
||||
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
|
||||
applies the UI.**
|
||||
|
||||
Two changes to the §13 lifecycle:
|
||||
|
||||
1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
|
||||
hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
|
||||
field is now optional: it is still pre-filled with the next free
|
||||
number as a *suggestion*, but the graduating owner may clear it and
|
||||
graduate with **no number**. When blank, the entry flips to `active`
|
||||
with `id: null` and the **slug remains the canonical identifier**
|
||||
(§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
|
||||
`POST …/graduate` accepts a blank/absent `rfc_id` and only validates
|
||||
the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
|
||||
The catalog and RFC view render number-less active entries by their
|
||||
slug/title (no "RFC-undefined"). RFC-0001's existing number is
|
||||
grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
|
||||
migration was needed for this part.
|
||||
|
||||
2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
|
||||
state, `retired`, is added. An RFC's own owners (frontmatter) and site
|
||||
`owner`-role holders — **not** app admins — may retire an entry
|
||||
(`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
|
||||
auto-merged meta-repo PR, leaving the body and every other field
|
||||
(including any integer id) intact. A retired entry is removed from
|
||||
**every** browsing surface: the catalog, the RFC/discussion/branch
|
||||
views (404), and link pickers/search. It is *not* hard-deleted — the
|
||||
entry stays in `rfcs/` (git is truth). Un-retire
|
||||
(`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
|
||||
**site-owner-only**, so a soft-delete is always recoverable by the
|
||||
operator but an RFC owner cannot reverse their own retirement. Site
|
||||
owners find retired entries via a new owner-gated admin surface
|
||||
(`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
|
||||
there or by navigating directly to the entry.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
- **MUST** run the migration. `025_retired_state.sql` rebuilds
|
||||
`cached_rfcs` to widen its `state` CHECK constraint to include
|
||||
`retired` (SQLite cannot alter a CHECK in place). It runs automatically
|
||||
at startup via the forward-only migration runner; existing rows are
|
||||
preserved, and the table is reconstructible from Gitea by the
|
||||
reconciler regardless (§4). No operator action beyond a normal deploy.
|
||||
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
|
||||
Retire affordance, and the owner-only "Retired" admin tab are present.
|
||||
- No config or secret changes.
|
||||
|
||||
## 0.31.4 — 2026-06-01
|
||||
|
||||
**Patch — bug fix + UI polish: secondary buttons that were invisible on
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@~/.claude/wiggleverse.md
|
||||
|
||||
# Working in rfc-app
|
||||
|
||||
This is the framework — the software that hosts an RFC standardization
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# rfc-app container image — the Cloud Run keystone for per-PR preview
|
||||
# environments (flotilla SPEC §15). NOT used by production: prod still deploys
|
||||
# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla
|
||||
# `preview up --pr=N` can build a PR's tree and run it as an ephemeral,
|
||||
# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real
|
||||
# secrets (test-secret env only — flotilla §15 / §3 invariant 1).
|
||||
#
|
||||
# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run
|
||||
# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on
|
||||
# 127.0.0.1:8000 — mirroring the prod nginx + systemd split
|
||||
# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a
|
||||
# preview behaves like prod minus the secrets. Single uvicorn process + single
|
||||
# SQLite file, per §4.2 (never scale workers).
|
||||
#
|
||||
# Build context is the repo root: docker build -t <image> .
|
||||
|
||||
# ---- stage 1: build the Vite SPA -------------------------------------------
|
||||
FROM node:20-slim AS web
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
# VITE_* values are baked into the bundle at build time (intentionally public —
|
||||
# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework
|
||||
# ships no default — each deployment names itself); previews self-name. Turnstile
|
||||
# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes.
|
||||
ARG VITE_APP_NAME="RFC App (preview)"
|
||||
ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
ARG VITE_AMPLITUDE_API_KEY=
|
||||
RUN VITE_APP_NAME="$VITE_APP_NAME" \
|
||||
VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \
|
||||
VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \
|
||||
npm run build
|
||||
|
||||
# ---- stage 2: runtime (backend + nginx) ------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/rfc-app
|
||||
|
||||
# Backend deps first for layer caching.
|
||||
COPY backend/requirements.txt backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
COPY backend/ backend/
|
||||
COPY deploy/preview/ deploy/preview/
|
||||
# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION).
|
||||
COPY VERSION ./
|
||||
COPY --from=web /app/frontend/dist/ frontend/dist/
|
||||
|
||||
RUN chmod +x deploy/preview/entrypoint.sh
|
||||
|
||||
# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against
|
||||
# it. The synthetic preview DB lives on the container's ephemeral filesystem and
|
||||
# dies with the instance (zero create/seed/drop lifecycle — §15).
|
||||
ENV PORT=8080 \
|
||||
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["deploy/preview/entrypoint.sh"]
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
|
||||
|
||||
tier1-up:
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
docker compose -f testing/docker-compose.yml logs -f
|
||||
|
||||
fe-unit:
|
||||
cd frontend && npm run test:run
|
||||
|
||||
e2e-install:
|
||||
cd e2e && npm ci && npx playwright install chromium
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
@@ -65,6 +65,18 @@ in `rfcs/`" is the whole mental model a new deployer needs.
|
||||
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
|
||||
> decision record is OHM ROADMAP #36.
|
||||
|
||||
> **Three-tier change (v0.45.0 — supersedes the single-corpus topology;
|
||||
> see §22).** A deployment is no longer one corpus in one repo. It has a
|
||||
> **registry** (§22.2) naming N **projects**, each owning **one content
|
||||
> repo** (§22.3) that holds N typed **RFC collections** as subfolders.
|
||||
> "This single repository is its content repository" now reads "each
|
||||
> *project* names one content repository; the deployment's registry lists
|
||||
> them." The bot and app-owned-authorization paragraphs below are
|
||||
> unchanged and now read **org-wide** across every content repo and the
|
||||
> registry repo. The single-corpus deployment is the N=1 case and keeps
|
||||
> running unchanged via a generated default project + default collection
|
||||
> (§22.13). §22 is the binding model.
|
||||
|
||||
All Git operations on the meta repository are performed by a single **bot
|
||||
service account** in Gitea. Real human users do not have meaningful Gitea
|
||||
permissions on the repo itself; their accounts exist for OAuth identity
|
||||
@@ -107,6 +119,18 @@ That's the entirety of the meta repo. App-level permission state, user
|
||||
accounts, chat history, audit logs, and branch visibility grants do **not**
|
||||
live in the meta repo — they live in the app database (see §5).
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
|
||||
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
|
||||
> frontmatter schema is **type-dependent** on the collection's `type`
|
||||
> (§22.4a) — `document` keeps the fields below; `specification` and `bdd`
|
||||
> add their type metadata. The §2.3 `RFC-NNNN` `max+1` allocation is
|
||||
> **removed** (the slug is the identity); pre-change `id` values survive as
|
||||
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
|
||||
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
|
||||
> Collection configuration (`type`, `visibility`, `initial_state`) lives in
|
||||
> a `.collection.yaml` manifest in the content repo, mirrored like entry
|
||||
> frontmatter (§22.2).
|
||||
|
||||
### 2.1 Entry file format
|
||||
|
||||
```markdown
|
||||
@@ -160,19 +184,31 @@ an idea costs nothing in identifier space.
|
||||
|
||||
## 3. RFC states
|
||||
|
||||
There are three canonical states stored in entry frontmatter:
|
||||
There are four canonical states stored in entry frontmatter:
|
||||
|
||||
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
|
||||
directory. Anyone signed in can chat on it; anyone can claim ownership;
|
||||
an owner is required before graduation.
|
||||
- **`active`** — the entry has been graduated: it carries an integer
|
||||
`id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the
|
||||
same `rfcs/<slug>.md` entry it always did — graduation is an in-place
|
||||
state flip (§13), not a move. Branches, PRs, and conversation happen on
|
||||
the meta repo against that entry, exactly as they did while it was a
|
||||
- **`active`** — the entry has been graduated: it carries
|
||||
`graduated_at`/`graduated_by` and, **optionally**, an integer `id`
|
||||
(`RFC-NNNN`). The integer id is not required — graduation may proceed
|
||||
with no number, in which case `id` stays null and the **slug remains
|
||||
the canonical identifier** (§2.3, §13.2). It lives in the same
|
||||
`rfcs/<slug>.md` entry it always did — graduation is an in-place state
|
||||
flip (§13), not a move. Branches, PRs, and conversation happen on the
|
||||
meta repo against that entry, exactly as they did while it was a
|
||||
super-draft. `repo:` stays null (§1).
|
||||
- **`withdrawn`** — pulled before becoming canonical. Stays in the
|
||||
directory as historical record, hidden from default views, filterable in.
|
||||
- **`retired`** — soft-deleted. The entry stays in the `rfcs/` directory
|
||||
as historical record (git is truth — nothing is hard-deleted), but it
|
||||
is **removed from every UI surface**: it does not appear in the
|
||||
catalog, is not addressable through the ordinary RFC view, and is
|
||||
excluded from link pickers and search. Unlike `withdrawn` (which is
|
||||
merely hidden-by-default and filterable back in), `retired` is not
|
||||
surfaced anywhere a contributor browses. Retiring is for entries an
|
||||
owner wants gone from the working set; it is reversible only by a site
|
||||
owner (§13.7).
|
||||
|
||||
A fourth concept — **`idea`** — is not stored in frontmatter. It is the
|
||||
*derived view* of "there is an open PR against the meta repo proposing
|
||||
@@ -192,12 +228,28 @@ super-draft ──[graduate, owner or admin]─────▶ active
|
||||
super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn
|
||||
active ──[withdraw, owner/admin]─────────────▶ withdrawn
|
||||
withdrawn ──[reopen, owner/admin]────────────▶ super-draft
|
||||
super-draft ──[retire, RFC owner or site owner]──▶ retired
|
||||
active ──[retire, RFC owner or site owner]───▶ retired
|
||||
retired ──[un-retire, site owner]────────────▶ prior state
|
||||
```
|
||||
|
||||
Every transition is a commit to the meta repo. State history is auditable
|
||||
through `git log rfcs/<slug>.md`. The app maintains a separate audit log
|
||||
for "who clicked the button" (see §6.5).
|
||||
|
||||
**Who may retire.** Retire (§13.7) is deliberately *narrower* than
|
||||
withdraw: it is available to the RFC's own `owners` (frontmatter) and to
|
||||
holders of the site **`owner`** role only — **not** to app admins. It is
|
||||
the one destructive-feeling action in the lifecycle (the entry leaves the
|
||||
working set entirely), so the authority to perform it is held closer than
|
||||
withdraw's owner/admin set. Un-retire — bringing a retired entry back to
|
||||
the state it held before — is held tighter still: **site owners only**.
|
||||
An RFC owner can retire their own entry but cannot bring it back; that
|
||||
asymmetry is intentional, so a soft-delete is always recoverable by the
|
||||
site's operator. The entry file is never removed from `rfcs/`, so a
|
||||
retired entry remains recoverable by editing frontmatter directly even if
|
||||
the app surfaces were lost.
|
||||
|
||||
### 3.2 State change side-effects
|
||||
|
||||
Changing state in the meta repo entry is the *only* required operation
|
||||
@@ -270,6 +322,18 @@ the natural path is SQLite FTS5 indexed off the reconciler.
|
||||
These are the tables that are app-owned (not cached from Gitea). Names
|
||||
and exact columns are illustrative; the implementing session can adjust.
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22).** Every entry-scoped table
|
||||
> below carries the corpus grain, which is now the **collection**: the
|
||||
> column is **`collection_id`** (not `project_id`), and `cached_rfcs` is
|
||||
> keyed `(collection_id, slug)`. A denormalized `project_id` rides the
|
||||
> high-churn cache/notification rows for filtering. New app-owned tables:
|
||||
> **`projects`** (one content repo, project settings), **`collections`**
|
||||
> (the immutable `type`, `subfolder`, `initial_state`, `visibility`,
|
||||
> mirrored from `.collection.yaml`, §22.2), and **`memberships`**
|
||||
> (`scope_type ∈ {global, project, collection}`, the unified
|
||||
> `{owner, contributor}` roles — §22.6, replacing M2's `project_members`).
|
||||
> `users.role` is the **deployment** admission tier only (§22.7).
|
||||
|
||||
- `users` — `id`, `email`, `display_name`, `gitea_login`, `role` (one
|
||||
of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2
|
||||
app-wide write-mute, distinct from the per-RFC and per-user
|
||||
@@ -407,6 +471,20 @@ merge with no data movement.
|
||||
|
||||
Authorization is owned by the app. Gitea sees only the bot account.
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22.6–§22.7).** The deployment
|
||||
> roles described in this section are the **global** tier of a four-layer
|
||||
> most-permissive union (global → project → collection → per-entry).
|
||||
> Scope roles use one vocabulary — **Owner** and **RFC Contributor** —
|
||||
> attached at `{global, project, collection}` via the `memberships` table
|
||||
> and inheriting downward with no negative override. A plain granted
|
||||
> `contributor` account is **not** an implicit global write role: it can
|
||||
> sign in and read, but writing an explicitly-created collection requires
|
||||
> an explicit `memberships` grant. The one carve-out is the N=1
|
||||
> default-collection baseline, where the pre-multi-project implicit-public
|
||||
> write capability is grandfathered (§22.6 keystone note). The §6.3
|
||||
> per-RFC delegated-authority idea is now **Owner** at project/collection
|
||||
> scope, sitting above per-entry authority in the union.
|
||||
|
||||
Authentication has three paths, in the order a visitor encounters
|
||||
them:
|
||||
|
||||
@@ -2034,9 +2112,14 @@ broadening rather than a precondition for the proposer's own RFC.)
|
||||
|
||||
Clicking "Graduate" opens a small dialog with two editable fields:
|
||||
|
||||
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
|
||||
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
|
||||
default is just the next number.
|
||||
- **Integer ID** — **optional.** Pre-filled as `max(existing integer
|
||||
IDs) + 1`, formatted as `RFC-NNNN`, purely as a *suggestion* the
|
||||
graduating owner may keep, change (to reserve gaps), or **clear
|
||||
entirely**. Leaving it blank graduates the entry to `active` with no
|
||||
number — `id` stays null and the slug remains the canonical identifier
|
||||
(§2.3). The field's helper text states this: blank means "graduate
|
||||
without a number." A blank id is the default-accepted case, not an
|
||||
error.
|
||||
- **Initial owners** — pre-filled from the entry's `owners:`, with an
|
||||
"add owner" picker. Must have at least one.
|
||||
|
||||
@@ -2044,17 +2127,20 @@ Clicking "Graduate" opens a small dialog with two editable fields:
|
||||
the meta-only topology.)
|
||||
|
||||
Each field validates inline as the admin types, with a short
|
||||
debounce, against the catalog cache and a regex — integer-ID
|
||||
collision against existing IDs, the at-least-one-owner constraint on
|
||||
the picker. Errors render as a short line of text beneath the
|
||||
offending field. The integer-ID collision check is re-issued
|
||||
atomically server-side on confirm, since a concurrent graduation
|
||||
could land between dialog-open and submit. While any field is
|
||||
invalid, the confirm button is disabled and its tooltip names the
|
||||
first blocker specifically — "Integer ID 42 is already taken," "Add
|
||||
at least one initial owner" — the same grammar the precondition
|
||||
popover below uses, so the dialog and the gate read as one surface
|
||||
rather than two competing styles.
|
||||
debounce, against the catalog cache and a regex. The integer-ID field
|
||||
is valid when it is **blank** (graduate without a number) *or* matches
|
||||
`^RFC-\d{4,}$` and is not already taken by another entry; the owners
|
||||
picker enforces the at-least-one constraint. Errors render as a short
|
||||
line of text beneath the offending field. The integer-ID collision
|
||||
check is re-issued atomically server-side on confirm, since a
|
||||
concurrent graduation could land between dialog-open and submit (only
|
||||
relevant when a number was supplied — a blank id can never collide).
|
||||
While any field is invalid, the confirm button is disabled and its
|
||||
tooltip names the first blocker specifically — "Integer ID 42 is
|
||||
already taken," "Add at least one initial owner" — the same grammar the
|
||||
precondition popover below uses, so the dialog and the gate read as one
|
||||
surface rather than two competing styles. A missing number is **never**
|
||||
a blocker.
|
||||
|
||||
The dialog's confirm button is also disabled when the entry has no
|
||||
owners. The disabled button opens a small popover on hover or click
|
||||
@@ -2070,14 +2156,16 @@ kept, so they coexist with graduation.
|
||||
|
||||
Confirming the dialog runs a single operation as the bot: open a PR
|
||||
against the meta repo that re-serializes `rfcs/<slug>.md` with
|
||||
`state: active`, `id: RFC-NNNN`, `graduated_at: <timestamp>`,
|
||||
`graduated_by: <admin username>`, and the `owners:` from the dialog —
|
||||
**leaving the body unchanged** — then auto-merge it (the admin who
|
||||
clicked is the merge actor). The webhook flow updates the SQLite cache
|
||||
and the catalog row transitions per §7.2.
|
||||
`state: active`, `id: RFC-NNNN` **or `id: null` when no number was
|
||||
supplied**, `graduated_at: <timestamp>`, `graduated_by: <admin
|
||||
username>`, and the `owners:` from the dialog — **leaving the body
|
||||
unchanged** — then auto-merge it (the admin who clicked is the merge
|
||||
actor). The webhook flow updates the SQLite cache and the catalog row
|
||||
transitions per §7.2. When `id` is null, the catalog and RFC view
|
||||
identify the entry by its slug (no "RFC-NNNN" chip is rendered).
|
||||
|
||||
```
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned OR null
|
||||
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
|
||||
```
|
||||
|
||||
@@ -2144,6 +2232,41 @@ the canonical home in the app. RFC-0001 is therefore an ordinary
|
||||
meta-only active RFC like any other; no grandfathered per-repo path
|
||||
remains in the code.
|
||||
|
||||
### 13.7 Retire (soft delete)
|
||||
|
||||
Retire takes an entry out of the working set without destroying it. It
|
||||
is the same shape as the other lifecycle transitions — an in-place
|
||||
frontmatter flip on the meta entry, committed via an auto-merged bot PR
|
||||
(§13.3's machinery, reused) — but with two distinguishing rules:
|
||||
|
||||
- **Authority (§3.1).** Only the RFC's own `owners` (frontmatter) and
|
||||
holders of the site `owner` role may retire. App admins may *not*
|
||||
(this is the one lifecycle action where admin authority does not
|
||||
apply). Un-retire is **site owners only**: an RFC owner can retire but
|
||||
cannot reverse it, so every soft-delete remains recoverable by the
|
||||
operator.
|
||||
- **Visibility.** A `retired` entry is removed from **every** browsing
|
||||
surface — the catalog (`GET /api/rfcs`), the ordinary RFC view (`GET
|
||||
/api/rfcs/<slug>` returns 404 for everyone except a site owner, so the
|
||||
un-retire affordance has somewhere to live), link pickers, and search.
|
||||
This is stronger than `withdrawn`, which is merely hidden-by-default
|
||||
and filterable back in.
|
||||
|
||||
The flip sets `state: retired` and leaves every other field — including
|
||||
the integer `id`, if one was assigned — untouched, so an un-retire
|
||||
restores the entry exactly as it was. Retire is allowed from
|
||||
`super-draft` or `active`; the prior state is recorded in the audit log
|
||||
(`retire` action) so un-retire (`unretire` action) can restore it.
|
||||
Because the entry file is never removed from `rfcs/`, the meta repo
|
||||
remains the source of truth and the reconciler reproduces the
|
||||
`retired` state on every sweep.
|
||||
|
||||
A site owner finds retired entries through an admin surface (a
|
||||
"Retired" list, owner-gated) and un-retires from there; the entry then
|
||||
reappears in the catalog under its restored state. The integer `id`,
|
||||
once assigned, is never reclaimed or reissued by retire/un-retire
|
||||
(gap-tolerant allocation per §2.3).
|
||||
|
||||
---
|
||||
|
||||
## 14. Outside the RFC view: landing and the philosophy surface
|
||||
@@ -4839,3 +4962,478 @@ existing consenters. The cleanest moment to do this is the next
|
||||
material privacy-policy revision; the conventions in §21.4 hold
|
||||
in the interim.
|
||||
|
||||
---
|
||||
|
||||
## 22. Three tiers: deployment → project → RFC collection
|
||||
|
||||
A **deployment** hosts one or more **projects**; each project owns one
|
||||
content repository and holds one or more **RFC collections**; each
|
||||
collection is a typed corpus of **entries**. This is the three-tier model
|
||||
that supersedes the original single-corpus framing of §§1–21 and the
|
||||
two-tier (deployment → project) draft that preceded it.
|
||||
|
||||
```
|
||||
deployment (= "global" in the UI) one Gitea org, one bot, one account
|
||||
│ system, one inbox, one running process;
|
||||
│ the surface a visitor first lands on.
|
||||
└─ project a named grouping + project settings;
|
||||
│ owns exactly ONE content repo. No type.
|
||||
└─ RFC collection a typed corpus: type, slug namespace,
|
||||
│ catalog, philosophy, initial_state,
|
||||
│ unreviewed flag, members.
|
||||
└─ entry an RFC / spec / feature, identified by
|
||||
its slug within the collection.
|
||||
```
|
||||
|
||||
Everything §§1–21 describe about *a corpus* is now *an RFC collection*.
|
||||
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. A grouping layer, the **project**, sits
|
||||
between: it owns the content repo and project-wide settings, and groups the
|
||||
collections beneath it. The numbered sections that assume a single corpus are
|
||||
amended in §22.14; **§22 is the binding model they defer to.**
|
||||
|
||||
> **Three-tier change (v0.40.0 → v0.45.0 — supersedes the single-corpus and
|
||||
> the two-tier models).** §1 originally said "this single repository is its
|
||||
> content repository," and an earlier draft of this section said "a deployment
|
||||
> hosts N projects, each a corpus." The current model is three tiers: a
|
||||
> deployment has a **registry** (§22.2) naming N **projects**, each project
|
||||
> owns **one content repo** (§22.3) holding N **RFC collections** as typed
|
||||
> subfolders (§22.2). The single-corpus deployment is the **N=1 case** and
|
||||
> continues to run after migration via a generated default project carrying a
|
||||
> generated default collection (§22.13); no deployment is forced to adopt more
|
||||
> than one of either. Where earlier sections say "the meta repo," "the corpus,"
|
||||
> or "the project," read "the collection's content repo" and "the collection's
|
||||
> corpus." The slices that delivered this are S1–S6 (the design record is
|
||||
> `docs/design/2026-06-05-three-tier-projects-collections.md`).
|
||||
|
||||
### 22.1 The tiers and isolation
|
||||
|
||||
One deployment, N projects (N ≥ 1); one project, N collections (N ≥ 1). A
|
||||
collection belongs to exactly one project; a project to exactly one
|
||||
deployment; neither moves. **Isolation (§22.5) holds at the collection
|
||||
grain:** an RFC, branch, thread, star, or watch belongs to exactly one
|
||||
collection, and no app surface joins across collections except the
|
||||
per-account ones the deployment owns (the §15 inbox, the §6 account roster,
|
||||
sign-in).
|
||||
|
||||
- **Deployment / "global."** The unchanged top tier. Owns accounts, the §6
|
||||
admission gate, the §15 inbox, the §1 bot, and the landing directory. Its
|
||||
management surface is **projects + global settings**.
|
||||
- **Project.** Belongs to one deployment; never moves. Owns one content repo
|
||||
(§22.3) and carries project settings (name, tagline, theme, visibility,
|
||||
model universe). Has **no `type`** of its own. Its management surface is
|
||||
**RFC collections + project settings**.
|
||||
- **RFC collection.** A typed subfolder of its project's content repo (§22.2).
|
||||
Carries everything the original draft pinned on a "project": the immutable
|
||||
`type` (§22.4a), the per-collection slug namespace (§22.4), `initial_state`
|
||||
(§22.4b), the `unreviewed` flag (§22.4c), catalog, philosophy.
|
||||
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
|
||||
|
||||
### 22.2 The registry and the collection manifests — git is still truth
|
||||
|
||||
Project and collection configuration is declared in git and mirrored into
|
||||
cache tables (`projects`, `collections`) exactly the way content is mirrored
|
||||
into `cached_rfcs` (§4). There are **two git sources**, both read by the bot:
|
||||
|
||||
1. **The registry repo** declares **projects**. A `projects.yaml` at the root
|
||||
of a dedicated **registry repo** under the deployment's Gitea org lists each
|
||||
project's `id`, `name`, `content_repo`, `visibility`, `theme`, and
|
||||
`enabled_models`. The framework learns the registry repo's location from a
|
||||
required env var (`REGISTRY_REPO`, the successor to `META_REPO`); the repo's
|
||||
*name* is the deployment's choice per the separation-of-concerns rule, and
|
||||
the framework fails loudly at startup if the var is unset. `content_repo`
|
||||
lives on the **project** (one repo per project), not the collection.
|
||||
|
||||
2. **Each project's content repo** declares its **collections** as typed
|
||||
subfolders, each carrying a **`.collection.yaml` manifest** (the
|
||||
collection's `type`, `visibility`, `initial_state`, `name`, and optional
|
||||
`enabled_models`). The registry mirror walks the content repo and reads
|
||||
these manifests, so collection configuration is git-truth and survives a
|
||||
cache rebuild — exactly as entry frontmatter does.
|
||||
|
||||
```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 # ONE repo under the org; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/model/.collection.yaml (one per collection subfolder)
|
||||
type: document # document | specification | bdd — immutable (§22.4a)
|
||||
visibility: gated # defaults to the project's, may only narrow (§22.5)
|
||||
initial_state: super-draft # super-draft | active — defaults from type (§22.4b)
|
||||
name: The Model
|
||||
# enabled_models: [claude] # optional; narrows the project's universe (§22.12)
|
||||
```
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
rfcs/intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
rfcs/runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
rfcs/login.md
|
||||
```
|
||||
|
||||
**Creation is in-app, wrapping a bot commit, at both tiers.** *+ New project*
|
||||
(a global-Owner action, §22.6) has the bot create a Gitea content repo under
|
||||
the org and commit a project entry to `projects.yaml`. *+ New collection* (a
|
||||
project Owner / RFC-Contributor-with-create action) has the bot commit a new
|
||||
subfolder + `.collection.yaml` to the project's content repo. The in-app
|
||||
button is a thin convenience over a git write; nothing becomes app state that
|
||||
git cannot rebuild. `projects` and `collections` cache rows are never written
|
||||
from user actions directly — they flow from the mirror only. **Membership**
|
||||
(§22.6) remains app state, as `rfc_collaborators` always has been — it churns
|
||||
at user speed and is not document state.
|
||||
|
||||
### 22.3 Content repositories — one per project
|
||||
|
||||
Each project names one content repo under the deployment's single Gitea org
|
||||
(convention `<project-id>-content`). Collections are **subfolders** within it
|
||||
(§22.2). The §1 bot service account operates org-wide across every content
|
||||
repo and the registry repo; nothing about the bot, the §6 app-owned
|
||||
authorization, or the "app is the only contribution surface" stance changes.
|
||||
There are no per-project Gitea orgs and no per-project bot accounts.
|
||||
|
||||
### 22.4 The slug namespace is per-collection; the slug is the identity
|
||||
|
||||
An entry's slug (§2) is unique **within its collection**: `model/intro` and
|
||||
`specs/intro` coexist. The fully-qualified identity is `(project, collection,
|
||||
slug)` — there is no type prefix and **no numeric ID**. The §22.4 retirement
|
||||
of `RFC-NNNN` allocation stands: the slug is the identity, and graduation
|
||||
(§13) flips state without allocating a number. The displayed *noun* around a
|
||||
slug ("RFC", "Spec", "Feature") is a presentation concern driven by the
|
||||
collection'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 so external "RFC-0001"-style citations still resolve, but
|
||||
never used for routing or lookup. New entries are never assigned one.
|
||||
|
||||
### 22.4a Collection type
|
||||
|
||||
Every collection declares a `type` in its `.collection.yaml` manifest
|
||||
(§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 collection 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 `collection.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. The type names and their behavior are framework concepts (like role
|
||||
names), not deployment content: a deployment picks which type each collection
|
||||
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. No type-specific surfaces. The
|
||||
§22.13 generated default collection is a `document` collection, so the N=1
|
||||
case is unchanged.
|
||||
- **`specification`** — a versioned technical specification (this framework's
|
||||
own `SPEC.md` is the archetype). Frontmatter adds spec metadata (`version`,
|
||||
lifecycle `status` of draft/active/superseded, `supersedes`). Type-specific
|
||||
surface — **release planning:** group entries/changes into versioned
|
||||
releases with a changelog + §20-style upgrade-steps per 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 a coverage
|
||||
view mapping features to the spec sections they exercise.
|
||||
|
||||
### 22.4b Initial state of a new entry
|
||||
|
||||
A collection sets the **landing state** a new entry takes when its creating
|
||||
idea-PR merges (§2.4) — the `initial_state` manifest field, one of the §2.4
|
||||
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.
|
||||
- **`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.
|
||||
|
||||
The default comes from the collection's **type** (§22.4a), but `initial_state`
|
||||
is an independent knob. It changes only the landing state and whether
|
||||
graduation is required; the underlying engine is unchanged. The §22.13 default
|
||||
collection keeps `super-draft`, preserving the N=1 flow.
|
||||
|
||||
### 22.4c The `unreviewed` flag
|
||||
|
||||
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
|
||||
`state`, recording whether a human gate has vetted it. An entry that reaches
|
||||
`active` by the normal **graduate** path (§13) is never flagged — the graduate
|
||||
action *is* the review. An entry that skips straight to `active` via
|
||||
`initial_state: active` lands `unreviewed = true`. A collection **Owner**
|
||||
(§22.6) clears it with a **mark-reviewed** action (§17), stamping
|
||||
`reviewed_at`/`reviewed_by` for provenance. The flag is git-truth
|
||||
(frontmatter, §2 amendment) and survives a cache rebuild. The §7 catalog gains
|
||||
an **unreviewed filter** — the owner's worklist for the action.
|
||||
|
||||
### 22.5 Visibility applies at both project and collection
|
||||
|
||||
Visibility is `gated` | `public` | `unlisted`, and is carried at **both** the
|
||||
project and the collection tier:
|
||||
|
||||
- **`gated`** — invisible to non-members: not shown in the directory (§22.10),
|
||||
returns 404 to non-members, and reading or writing requires a scope role
|
||||
(§22.6).
|
||||
- **`public`** — any visitor may read under the §6.1 anonymous-read contract;
|
||||
appears in the directory; contributing still requires a grant (subject to
|
||||
the N=1 baseline, §22.6).
|
||||
- **`unlisted`** — readable by anyone with a direct link, but not shown in the
|
||||
directory and not enumerated by the deployment/project listing.
|
||||
|
||||
**A collection defaults to its project's visibility and may only narrow it**
|
||||
(`public` < `unlisted` < `gated`; a collection may be as strict or stricter
|
||||
than its project, never looser). Reading or writing a collection requires
|
||||
passing **both** gates — the stricter of project and collection wins. This is
|
||||
validated at create-collection (422 on a looser setting) and clamped at the
|
||||
registry mirror. Project/collection visibility does not relax the §11
|
||||
per-branch `read_public` controls *within* a collection.
|
||||
|
||||
### 22.6 Roles: one vocabulary, attached at a scope
|
||||
|
||||
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
|
||||
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
|
||||
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
|
||||
levels" is literal — the same two words at every tier.
|
||||
|
||||
| Role | Capabilities within its scope's subtree |
|
||||
|---|---|
|
||||
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
|
||||
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project. (A *collection*-scope grant cannot create sibling collections — creating one is a project-level action.) |
|
||||
|
||||
**Schema.** `users.role` continues to carry the **deployment admission** tier
|
||||
(`owner` / `admin` / `contributor`, the §6 gate). Scope grants live in a single
|
||||
polymorphic **`memberships(scope_type ∈ {global, project, collection},
|
||||
scope_id, user_id, role, granted_by, granted_at)`** table; the role enum is
|
||||
`{owner, contributor}`. The prior `project_members` three-role set
|
||||
(`viewer`/`contributor`/`admin`) collapsed: `admin → owner`, `contributor →
|
||||
contributor`, and `viewer` is deferred (a read grant folded into visibility,
|
||||
not a membership role this pass). When the richer set returns it **re-splits
|
||||
out of** Owner / re-adds a tier; the unified roles are not aliases.
|
||||
|
||||
> **Keystone reinterpretation (v0.42.0, S3 — reconciles the role mapping).**
|
||||
> An earlier draft equated "deployment `contributor`" with "global RFC
|
||||
> Contributor." That contradicted the open-by-default baseline. The binding
|
||||
> reading: a **plain granted account** (`users.role='contributor'`, no
|
||||
> membership row) is a granted *account* — it can sign in and read — **not** a
|
||||
> write-everywhere global role. **"Global RFC Contributor"** is an **explicit
|
||||
> `memberships(scope_type='global')` grant** (the cleo case, §22.6a). The one
|
||||
> carve-out preserving N=1: the pre-multi-project **implicit-public write
|
||||
> baseline is grandfathered onto the migration-seeded `default` collection
|
||||
> only** — a granted `contributor` keeps its historical write capability there
|
||||
> with no membership row. Every **explicitly created** collection (and any
|
||||
> second project) requires an explicit scope grant to write. Deployment
|
||||
> `owner`/`admin` remain superusers everywhere.
|
||||
|
||||
Membership is still gated by the deployment-level
|
||||
`users.permission_state='granted'` (§6): a pending account has no write
|
||||
capability at any scope regardless of its `memberships` rows.
|
||||
|
||||
### 22.6a Role & invitation scenarios
|
||||
|
||||
The behavioral spec for role usage, invitation, and empty-state experiences is
|
||||
the BDD scenario set in
|
||||
`docs/design/2026-06-05-three-tier-projects-collections.md` Part C (C.1 role
|
||||
usage / inheritance / most-permissive union; C.2 invitation — who may invite
|
||||
whom, at which scope; C.3 empty states at each tier). They are written so they
|
||||
can also seed a `bdd`-type collection (the framework dogfooding its own model).
|
||||
Each scenario carries the slice tag (`@S1`–`@S6`) that makes it pass.
|
||||
|
||||
### 22.7 How the four tiers compose
|
||||
|
||||
Effective authority on an entry is the **most permissive** union of four
|
||||
layers, inheriting **downward**, **additive**, with **no negative override**:
|
||||
|
||||
```
|
||||
effective authority on an entry =
|
||||
global role (memberships scope_type='global'; + users.role owner/admin)
|
||||
∪ project role (membership at the entry's project)
|
||||
∪ collection role (membership at the entry's collection)
|
||||
∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
|
||||
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
|
||||
```
|
||||
|
||||
- A grant at **global** covers every project and collection in the deployment.
|
||||
- A grant at **project** covers every collection in that project — including
|
||||
collections added later, with no new grant.
|
||||
- A grant at **collection** covers just that collection.
|
||||
- You **cannot** grant at a parent scope and revoke at a child; resolution
|
||||
never subtracts a parent grant.
|
||||
|
||||
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
|
||||
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12);
|
||||
the three named scopes apply to a *subtree*. `arbiter` is narrower than Owner
|
||||
(one entry, not a subtree) and stays distinct. `users.role` now means
|
||||
deployment level only; no schema change demotes an existing owner/admin —
|
||||
their powers read as "superuser in every project and collection."
|
||||
|
||||
### 22.8 Discovery and joining
|
||||
|
||||
Because a gated project or collection is invisible to non-members, joining is
|
||||
by one of:
|
||||
|
||||
- **Invite** — an Owner (at the target scope or any scope above it) grants a
|
||||
user a role directly, writing a `memberships` row and fanning a §15
|
||||
notification. The grant may name any scope at or beneath the inviter's reach;
|
||||
the **broader-scope-supersedes** rule prunes membership rows the new grant
|
||||
subsumes (a project grant removes subsumed collection rows of same-or-lower
|
||||
rank; a global grant removes subsumed project + collection rows; a *stronger*
|
||||
child grant survives).
|
||||
- **Request to join** — a user who knows a scope exists requests membership
|
||||
naming a desired role; the request is recorded and surfaced to the scope's
|
||||
Owners across the subtree (the cross-collection inbox), who accept or decline.
|
||||
Accepting writes the `memberships` row.
|
||||
|
||||
A `public` project/collection needs neither for read; the existing §6 / §12
|
||||
contribute-grant paths cover write.
|
||||
|
||||
### 22.9 Branding is resolved at runtime
|
||||
|
||||
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
|
||||
cannot serve N projects. Deployment, project, and collection identity are
|
||||
served at runtime — `GET /api/deployment` (deployment `name`, `tagline`, the
|
||||
visible projects), `GET /api/projects/:id` (the project's settings + visible
|
||||
collections), `GET /api/projects/:id/collections/:cid` (the collection's
|
||||
settings incl. `type`). The frontend reads these instead of
|
||||
`import.meta.env.VITE_APP_NAME`. Three chrome layers result: **deployment
|
||||
chrome** (directory, switcher, shared inbox), **project chrome** (the
|
||||
collection directory, project settings), and **collection chrome** (the §7
|
||||
catalog, the §8 entry view, the §14 philosophy).
|
||||
|
||||
### 22.10 Routing and the landing surfaces
|
||||
|
||||
The canonical route gains a collection segment:
|
||||
|
||||
```
|
||||
/p/<project>/c/<collection>/e/<slug>
|
||||
```
|
||||
|
||||
The `c/` segment keeps collection ids from colliding with reserved
|
||||
project-level segments. Reserved **collection-level** siblings (`proposals`,
|
||||
`philosophy`) sit under `/p/<project>/c/<collection>/…`. The displayed entry
|
||||
noun is the collection type's label (§22.4a), not part of the path.
|
||||
|
||||
- `/` is the **deployment landing**: a directory of the projects the visitor
|
||||
can see (§22.5). Redirects to the sole visible project when there is exactly
|
||||
one (the N=1 case).
|
||||
- `/p/<project>/` is the **project landing**: a directory of the collections
|
||||
the visitor can see. Redirects to its sole visible collection when there is
|
||||
exactly one.
|
||||
|
||||
**Backcompat.** The shipped `/p/<project>/e/<slug>` URLs (v0.35.0)
|
||||
**308-redirect** to `/p/<project>/c/<default>/e/<slug>`, and the
|
||||
pre-multi-project `/rfc/<slug>` / `/proposals/<n>` redirect to their
|
||||
`/p/<default-project>/c/<default-collection>/…` equivalents. Both are handled
|
||||
in the migration (§22.13).
|
||||
|
||||
### 22.11 Notifications span the deployment, one inbox
|
||||
|
||||
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
|
||||
caller's collections. Entry-scoped notification rows carry the entry's
|
||||
`collection_id` (and a denormalized `project_id`) so the inbox filters by
|
||||
collection or project and a user can mute an entire collection. Quiet hours,
|
||||
digest cadence, and email preferences stay per-account at the deployment level
|
||||
(§5, §15). The **cross-collection inbox** (§22.8) surfaces join requests to
|
||||
the Owners of the scope they target, aggregated across the subtree.
|
||||
|
||||
### 22.12 Per-collection model universe
|
||||
|
||||
A collection's `enabled_models` (its `.collection.yaml` manifest, §22.2)
|
||||
narrows its **project's** `enabled_models` (registry, §22.2), which in turn
|
||||
overrides the deployment `ENABLED_MODELS` (§18). Resolution order is **funder
|
||||
universe ∩ §6.6 per-entry list ∩ collection universe ∩ project universe**,
|
||||
with the collection universe substituting for the deployment universe at the
|
||||
outermost step. A collection's universe may only narrow, never widen, its
|
||||
project's; the project's may only narrow the deployment's.
|
||||
|
||||
### 22.13 Migration — the default project and default collection (N=1)
|
||||
|
||||
A deployment on the shipped two-tier schema (v0.39.0) is migrated so it keeps
|
||||
running unchanged:
|
||||
|
||||
1. The existing `projects` row **stays as the project** (it already owns
|
||||
`content_repo` and its config-derived `id` from the §22.13 re-stamp).
|
||||
2. A **default collection** (`id='default'`, `subfolder` = repo root) is
|
||||
created per project, inheriting that project's `type` / `initial_state` /
|
||||
visibility; those per-corpus fields are then dropped from `projects`.
|
||||
3. Every entry-scoped row is re-keyed `(project_id, slug)` →
|
||||
`(collection_id, slug)` via the migration-028 rebuild pattern.
|
||||
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
|
||||
the default collection, role-collapsed (§22.6).
|
||||
5. **308 redirects:** the shipped `/p/<project>/e/<slug>` →
|
||||
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
|
||||
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
|
||||
|
||||
Until a second collection is added, the deployment is functionally identical
|
||||
to before, with one extra path segment. This is the §20.4 upgrade-steps
|
||||
content for the release.
|
||||
|
||||
### 22.14 Amendments to §§1–21 (applied in place)
|
||||
|
||||
The single-corpus sections defer to §22; the load-bearing reinterpretations:
|
||||
|
||||
- **§1 Repository topology.** Each *project* names one content repo; the
|
||||
deployment's registry (§22.2) lists them; collections are subfolders within
|
||||
a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
|
||||
unchanged and now read org-wide.
|
||||
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
|
||||
frontmatter schema is **type-dependent** (§22.4a). The `RFC-NNNN` `max+1`
|
||||
allocation is **removed** — the slug is the identity (§22.4). New
|
||||
`active`-entry fields: `unreviewed` (bool) and the `reviewed_at`/
|
||||
`reviewed_by` provenance pair (§22.4c).
|
||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
|
||||
targets the collection's `initial_state` (§22.4b); a new `active
|
||||
─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
|
||||
flag (§22.4c).
|
||||
- **§5 Data model.** `project_id` becomes **`collection_id`** on every
|
||||
entry-scoped row (the corpus grain is now the collection); a separate
|
||||
`project_id` exists only on the `collections` table and project-scoped rows.
|
||||
`cached_rfcs` PK → `(collection_id, slug)` and mirrors the `unreviewed`
|
||||
frontmatter flag. New tables: `projects`, `collections` (carrying the
|
||||
immutable `type`, §22.4a), and `memberships` (§22.6, replacing
|
||||
`project_members`). `users.role` is annotated deployment-scope (§22.7).
|
||||
- **§6 Permission model.** Deployment roles are the global tier of the §22.7
|
||||
four-layer union; a plain `contributor` has no implicit write at any
|
||||
explicitly-created scope until a `memberships` grant gives it one (the N=1
|
||||
default-collection baseline is the sole carve-out, §22.6 keystone note).
|
||||
`project_admin`'s delegation idea is now **Owner** at project/collection
|
||||
scope (§22.6), sitting above per-RFC authority (§6.3).
|
||||
- **§7 / §8.1 / §13.3.** The catalog is per-collection under
|
||||
`/p/<project>/c/<collection>/`; the project collection-directory and the
|
||||
deployment directory (§22.10) sit above it; the §7 catalog gains the
|
||||
unreviewed filter (§22.4c). The §8.1 breadcrumb gains leading project +
|
||||
collection segments. §13.3 graduation operates on the collection's content
|
||||
subfolder, allocates no number, and is a no-op (replaced by mark-reviewed)
|
||||
for collections whose `initial_state` is `active`.
|
||||
- **§14.1 / §17 / §18 / §20.** The landing splits into deployment directory,
|
||||
project collection-directory, and per-collection philosophy/deck. §17 routes
|
||||
gain the `/p/<project>/c/<collection>/` scoping plus `GET /api/deployment`,
|
||||
`GET /api/projects/:id`, `GET /api/projects/:id/collections[/:cid]`, the
|
||||
`memberships` management + request-to-join endpoints, and the mark-reviewed
|
||||
endpoint. `ENABLED_MODELS` is the deployment fallback under §22.12.
|
||||
`VITE_APP_NAME` is deprecated and `REGISTRY_REPO` is a required env var
|
||||
(§20.3); `META_REPO` is legacy, consulted only by the §22.13 migration.
|
||||
|
||||
|
||||
+11
-3
@@ -9,10 +9,18 @@ GITEA_URL=http://localhost:3000
|
||||
GITEA_BOT_USER=rfc-bot
|
||||
GITEA_BOT_TOKEN=
|
||||
|
||||
# The Gitea org or user that owns the meta repo and every RFC repo
|
||||
# the bot will create on graduation.
|
||||
# The Gitea org or user that owns every RFC repo the bot will create on
|
||||
# graduation.
|
||||
GITEA_ORG=wiggleverse
|
||||
META_REPO=meta
|
||||
|
||||
# §22.2 — the project registry repo (REQUIRED). The framework reads
|
||||
# `projects.yaml` at its root to learn which projects exist. The repo name is
|
||||
# the deployment's choice; the app fails to start if this is unset.
|
||||
REGISTRY_REPO=registry
|
||||
# §22.13 — optional id for the bootstrap/default project. Reserved for the
|
||||
# Plan B re-stamp; leave unset in Plan A (the default project id stays
|
||||
# `default`). When set, it must match an `id` in projects.yaml.
|
||||
# DEFAULT_PROJECT_ID=ohm
|
||||
|
||||
# --- OAuth (Gitea) ---
|
||||
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
|
||||
|
||||
+326
-31
@@ -21,13 +21,19 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_collections,
|
||||
api_contributions,
|
||||
api_deployment,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_join_requests,
|
||||
api_memberships,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
@@ -146,6 +152,17 @@ def make_router(
|
||||
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
||||
# on accept; lands the request + owner notifications via §15 notify.
|
||||
router.include_router(api_contributions.make_router())
|
||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config, gitea, bot))
|
||||
router.include_router(api_collections.make_router(config, gitea, bot))
|
||||
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
||||
# {owner, contributor} at project/collection scope to existing accounts.
|
||||
router.include_router(api_memberships.make_router())
|
||||
# §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
|
||||
# scope (naming a role); the scope's Owners across the subtree accept (writing
|
||||
# the membership row) or decline.
|
||||
router.include_router(api_join_requests.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -607,25 +624,40 @@ def make_router(
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs")
|
||||
async def list_rfcs(request: Request) -> dict[str, Any]:
|
||||
async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
|
||||
"""§7's left pane data.
|
||||
|
||||
The chip-filter / sort / search combinatorics live on the
|
||||
client — the server returns the full set and lets the chips
|
||||
narrow it. The set is small (hundreds, not thousands) for the
|
||||
foreseeable future, so paginating here would buy nothing.
|
||||
|
||||
§22.4c: pass `?unreviewed=true` to narrow to active entries
|
||||
still awaiting owner review.
|
||||
"""
|
||||
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)
|
||||
params = list(visible)
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
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')
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
"""
|
||||
f"""
|
||||
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
||||
r.owners_json, r.arbiters_json, r.tags_json,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
||||
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
||||
WHERE r.state IN ('super-draft', 'active')
|
||||
AND c.project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
starred = set()
|
||||
@@ -657,12 +689,21 @@ 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")
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members. Recover the project via the entry's collection.
|
||||
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
|
||||
# §13.7: a retired entry is removed from every browsing surface. The
|
||||
# sole exception is a site owner, so the un-retire affordance has
|
||||
# somewhere to live; everyone else gets a plain 404.
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
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
|
||||
@@ -679,6 +720,166 @@ def make_router(
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
|
||||
# scoped to one project, identified by its own slug namespace
|
||||
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
|
||||
# default-project compat path; the frontend reads these scoped routes so a
|
||||
# second project's corpus renders under /p/<id>/.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
||||
# §22 S2: a collection-scoped route 404s when the collection does not
|
||||
# belong to the project in the path (shape matches an unknown id).
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
def _list_rfcs_for_collection(
|
||||
collection_id: str, viewer, unreviewed: str | None
|
||||
) -> dict[str, Any]:
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
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 collection_id = ?{unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
starred = set()
|
||||
if viewer_id is not None:
|
||||
starred = {
|
||||
r["rfc_slug"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
|
||||
(viewer_id, collection_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"state": r["state"],
|
||||
"id": r["rfc_id"],
|
||||
"repo": r["repo"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"arbiters": json.loads(r["arbiters_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||
"starred_by_me": r["slug"] in starred,
|
||||
"has_open_prs": False,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||
(collection_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
payload = _serialize_rfc(row)
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: the project-scoped route serves the default collection.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# §22 S2: collection-scoped serve + propose. The catalog/entry views read
|
||||
# these under /p/<project>/c/<collection>/; the project-scoped routes above
|
||||
# stay as the default-collection compat surface.
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
|
||||
async def list_collection_rfcs(
|
||||
project_id: str, collection_id: str, request: Request,
|
||||
unreviewed: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||
async def get_collection_rfc(
|
||||
project_id: str, collection_id: str, slug: str, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
|
||||
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
|
||||
§B.2 collection Owner (a collection/project/global Owner or deployment
|
||||
owner/admin reaching the entry's collection)."""
|
||||
viewer = auth.require_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
if not auth.is_collection_superuser(viewer, collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
|
||||
row = db.conn().execute(
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] != "active" or not row["unreviewed"]:
|
||||
raise HTTPException(409, "Entry is not an unreviewed active entry")
|
||||
try:
|
||||
await bot.mark_entry_reviewed(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
reviewed_by=viewer.gitea_login,
|
||||
reviewed_at=entry_mod.today(),
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
@@ -694,14 +895,50 @@ 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": [
|
||||
{
|
||||
"slug": r["rfc_slug"],
|
||||
"pr_number": r["pr_number"],
|
||||
"title": r["title"],
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}/proposals")
|
||||
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
|
||||
# §22.4/§22.5: the pending idea-PRs scoped to one project.
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
||||
FROM cached_prs
|
||||
WHERE pr_kind = 'idea' AND state = 'open'
|
||||
WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
|
||||
ORDER BY opened_at DESC
|
||||
"""
|
||||
""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
@@ -736,10 +973,12 @@ 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"]
|
||||
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
|
||||
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
|
||||
entry_payload: dict[str, Any] | None = None
|
||||
if result:
|
||||
text, _sha = result
|
||||
@@ -768,9 +1007,21 @@ def make_router(
|
||||
# §9.1: propose a new RFC
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
||||
# Default-collection wrapper (§22 S1/S2): resolve the project's default
|
||||
# collection and delegate. Keeps the project-scoped propose routes intact.
|
||||
return await _propose_into_collection(
|
||||
project_id, collections_mod.default_collection_id(project_id), payload, user
|
||||
)
|
||||
|
||||
async def _propose_into_collection(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, user
|
||||
) -> dict[str, Any]:
|
||||
# §B.2 (S3): proposing a new entry requires contribute standing in the
|
||||
# *target collection* — the four-layer scope-role union, with the
|
||||
# grandfathered implicit-public baseline on the default collection.
|
||||
if not auth.can_contribute_in_collection(user, collection_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this collection")
|
||||
slug = payload.slug.strip().lower()
|
||||
if not entry_mod.is_valid_slug(slug):
|
||||
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
||||
@@ -780,21 +1031,29 @@ def make_router(
|
||||
# on every keystroke, since a concurrent submission could land
|
||||
# between dialog-open and submit.
|
||||
clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
||||
).fetchone()
|
||||
if clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
||||
idea_clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
|
||||
(slug,),
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
|
||||
"AND rfc_slug = ? AND project_id = ?",
|
||||
(slug, project_id),
|
||||
).fetchone()
|
||||
if idea_clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
||||
|
||||
# §22.4b: the target collection's landing state (the per-corpus field
|
||||
# moved down to the collection in migration 029).
|
||||
landing_state = (
|
||||
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
||||
else "super-draft"
|
||||
)
|
||||
|
||||
entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=payload.title.strip(),
|
||||
state="super-draft",
|
||||
state=landing_state,
|
||||
id=None,
|
||||
repo=None,
|
||||
proposed_by=user.email or user.gitea_login,
|
||||
@@ -809,6 +1068,7 @@ def make_router(
|
||||
arbiters=[],
|
||||
tags=[t.strip() for t in payload.tags if t.strip()],
|
||||
body=payload.pitch.strip() + "\n",
|
||||
unreviewed=(landing_state == "active"),
|
||||
)
|
||||
contents = entry_mod.serialize(entry)
|
||||
pr_title = f"Propose: {entry.title}"
|
||||
@@ -819,15 +1079,19 @@ def make_router(
|
||||
f"**Topic:** {entry.title}\n\n"
|
||||
f"{payload.pitch.strip()}"
|
||||
)
|
||||
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
|
||||
subfolder = collections_mod.subfolder_of(collection_id)
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
pr = await bot.open_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.content_repo(project_id) or ""),
|
||||
slug=slug,
|
||||
file_contents=contents,
|
||||
pr_title=pr_title,
|
||||
pr_description=pr_description,
|
||||
rfcs_dir=rfcs_dir,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
@@ -847,19 +1111,48 @@ def make_router(
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
|
||||
VALUES ('rfc', ?, ?, ?, ?)
|
||||
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
(slug, pr["number"], use_case, collection_id),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
||||
(use_case, pr["number"], project_id),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
# Default-project compat path (pre-multi-project clients).
|
||||
user = auth.require_contributor(request)
|
||||
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/propose")
|
||||
async def propose_project_rfc(
|
||||
project_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22.4: propose a new entry into a specific project (read-gated first
|
||||
# so a gated project 404s a non-member before the contribute check).
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
return await _propose_into_project(project_id, payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
|
||||
async def propose_collection_rfc(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22 S2: propose a new entry into a specific collection of a project.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
|
||||
# before the contribute check (existence is not revealed).
|
||||
auth.require_collection_readable(user, collection_id)
|
||||
return await _propose_into_collection(project_id, collection_id, payload, user)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
@@ -899,7 +1192,7 @@ def make_router(
|
||||
await bot.merge_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -919,7 +1212,7 @@ def make_router(
|
||||
await bot.decline_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
comment=body.comment,
|
||||
@@ -943,7 +1236,7 @@ def make_router(
|
||||
await bot.withdraw_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -999,6 +1292,8 @@ def make_router(
|
||||
).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, auth.project_of_rfc(slug))
|
||||
# §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.
|
||||
|
||||
@@ -829,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
|
||||
items_blocked.append(payload)
|
||||
return {"ready": items_ready, "blocked": items_blocked}
|
||||
|
||||
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
|
||||
|
||||
@router.get("/api/admin/retired-rfcs")
|
||||
async def retired_rfcs(request: Request) -> dict[str, Any]:
|
||||
# Retired entries are hidden from every browsing surface (§13.7);
|
||||
# this owner-gated list is how a site owner discovers them to
|
||||
# un-retire. Admins do not get this surface — un-retire authority is
|
||||
# site-owner-only, so neither is the list that feeds it.
|
||||
viewer = auth.require_admin(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Site owner role required")
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, tags_json,
|
||||
proposed_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state = 'retired'
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
# The state it would return to on un-retire, mirroring
|
||||
# api_graduation._prior_state_before_retire's audit lookup.
|
||||
prior = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(r["slug"],),
|
||||
).fetchone()
|
||||
restored_state = None
|
||||
if prior and prior["details"]:
|
||||
try:
|
||||
restored_state = json.loads(prior["details"]).get("prior_state")
|
||||
except (ValueError, TypeError):
|
||||
restored_state = None
|
||||
if restored_state not in ("super-draft", "active"):
|
||||
restored_state = "active" if r["rfc_id"] else "super-draft"
|
||||
items.append({
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"id": r["rfc_id"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"retired_at": r["updated_at"],
|
||||
"restores_to": restored_state,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
# ----- User search (typeahead for §15.8 mute add) -----
|
||||
|
||||
@router.get("/api/users/search")
|
||||
|
||||
+65
-43
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -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)
|
||||
@@ -740,7 +742,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
read_public = excluded.read_public,
|
||||
contribute_mode = excluded.contribute_mode
|
||||
""",
|
||||
@@ -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
|
||||
@@ -894,7 +896,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
""",
|
||||
@@ -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):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id 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
|
||||
@@ -1148,7 +1153,7 @@ def make_router(
|
||||
|
||||
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
|
||||
if _is_meta_target(rfc, branch):
|
||||
return config.gitea_org, config.meta_repo
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
@@ -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, §B.2): in a hidden/gated
|
||||
# collection nothing — not even main or a read_public branch — is
|
||||
# readable by a non-scope-role viewer.
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
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_collection_superuser(viewer, cid):
|
||||
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"):
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
# §22.5 visibility gate (subtractive): no contribute in an unreadable
|
||||
# collection.
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
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 collection-level write standing
|
||||
# (§B.2) — the grandfathered baseline on the public default
|
||||
# collection, or an explicit scope grant reaching the collection.
|
||||
return auth.can_contribute_in_collection(viewer, cid)
|
||||
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_collection_superuser(viewer, rfc["collection_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_collection_superuser(viewer, rfc["collection_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_collection_superuser(viewer, rfc["collection_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_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""§22 S2 — collection directory + create-collection.
|
||||
|
||||
GET /api/projects/:id/collections — list the project's visible collections.
|
||||
GET /api/projects/:id/collections/:cid — one collection's settings.
|
||||
POST /api/projects/:id/collections — create a collection. Authorized by a
|
||||
deployment owner/admin (S2; scoped
|
||||
{owner, contributor} roles at the
|
||||
collection axis land in S3). The bot
|
||||
commits a `.collection.yaml` to the
|
||||
content repo, then the registry mirror
|
||||
upserts the collections row — §22.2
|
||||
keeps the registry the source of truth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class CreateCollectionBody(BaseModel):
|
||||
collection_id: str
|
||||
type: str
|
||||
name: str | None = None
|
||||
visibility: str | None = None
|
||||
initial_state: str | None = None
|
||||
|
||||
|
||||
def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
|
||||
"""§22 S4: the viewer's project-grain capabilities for role-aware UI — may
|
||||
they create a collection, may they manage membership (invite), and their
|
||||
project role. `role` maps the §22.6 legacy strings back to the unified
|
||||
`{owner, contributor}` vocabulary the frontend speaks."""
|
||||
legacy = auth.project_member_role(viewer, project_id)
|
||||
role = None
|
||||
if viewer is not None and viewer.role in ("owner", "admin"):
|
||||
role = "owner"
|
||||
elif legacy == "project_admin":
|
||||
role = "owner"
|
||||
elif legacy == "project_contributor":
|
||||
role = "contributor"
|
||||
return {
|
||||
"can_create_collection": auth.can_create_collection(viewer, project_id),
|
||||
"can_invite": auth.can_invite_at_project(viewer, project_id),
|
||||
# §22.8: a signed-in, granted account with no role at the project may ask
|
||||
# to join it (the request-to-join affordance). Owners/members and
|
||||
# not-yet-granted accounts don't see it.
|
||||
"can_request_join": (
|
||||
viewer is not None
|
||||
and viewer.permission_state == "granted"
|
||||
and auth.effective_role_at_scope(viewer, "project", project_id) is None
|
||||
),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections")
|
||||
async def list_cols(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22.5 (S3): the directory is viewer-aware — a hidden/gated collection
|
||||
# is listed only for a scope-role holder who can read it; `unlisted` is
|
||||
# omitted from enumeration for everyone (link-only).
|
||||
items = [
|
||||
c
|
||||
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
||||
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
||||
]
|
||||
# §22 S4: surface the viewer's project-level capabilities so the
|
||||
# directory can render role-aware affordances (the create-first-
|
||||
# collection CTA, the invite control) without a second round-trip.
|
||||
return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)}
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
||||
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
# §22 S4: the viewer's collection-level capabilities drive the
|
||||
# propose-first empty state and the collection invite control.
|
||||
col = dict(col)
|
||||
col["viewer"] = {
|
||||
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
|
||||
"can_invite": auth.can_invite_at_collection(viewer, collection_id),
|
||||
# §22.8: a signed-in, granted account with no role reaching this
|
||||
# collection may ask to join it.
|
||||
"can_request_join": (
|
||||
viewer is not None
|
||||
and viewer.permission_state == "granted"
|
||||
and auth.effective_scope_role(viewer, collection_id) is None
|
||||
),
|
||||
"role": auth.effective_scope_role(viewer, collection_id),
|
||||
}
|
||||
return col
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections")
|
||||
async def create_col(
|
||||
project_id: str, body: CreateCollectionBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
|
||||
# grant holder (Owner or RFC Contributor) may create a collection. The
|
||||
# read gate runs first so a gated project 404s a non-member.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
if not auth.can_create_collection(user, project_id):
|
||||
raise HTTPException(403, "You may not create collections in this project")
|
||||
cid = body.collection_id.strip().lower()
|
||||
if not _SLUG_RE.match(cid) or cid == "default":
|
||||
raise HTTPException(422, "collection id must be a slug and not 'default'")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
if body.visibility is not None:
|
||||
if body.visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {body.visibility!r}")
|
||||
# §22.5 (S3) strictness: a collection may be set only as strict or
|
||||
# stricter than its project — never more public.
|
||||
pvis = auth.project_visibility(project_id)
|
||||
if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis):
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"collection visibility {body.visibility!r} is looser than "
|
||||
f"the project's {pvis!r}; a collection may only narrow it",
|
||||
)
|
||||
if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE:
|
||||
raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
|
||||
if collections_mod.get_collection(cid) is not None:
|
||||
raise HTTPException(409, f"collection `{cid}` already exists")
|
||||
content_repo = projects_mod.content_repo(project_id)
|
||||
if not content_repo:
|
||||
raise HTTPException(409, "project has no content repo")
|
||||
|
||||
manifest: dict[str, Any] = {"type": body.type}
|
||||
if body.name:
|
||||
manifest["name"] = body.name
|
||||
if body.visibility:
|
||||
manifest["visibility"] = body.visibility
|
||||
if body.initial_state:
|
||||
manifest["initial_state"] = body.initial_state
|
||||
manifest_yaml = yaml.safe_dump(manifest, sort_keys=False)
|
||||
|
||||
try:
|
||||
await bot.create_collection(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
content_repo=content_repo,
|
||||
collection_id=cid,
|
||||
manifest_yaml=manifest_yaml,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new manifest becomes a row.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None:
|
||||
raise HTTPException(500, "collection committed but not mirrored")
|
||||
return col
|
||||
|
||||
return router
|
||||
@@ -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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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_collection_superuser(viewer, auth.collection_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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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")
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects + §22 S5 in-app create-project.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on, the
|
||||
`viewer` capability block (S5: `can_create_project`), and
|
||||
`default_project_readable` (whether the N=1 redirect target is reachable by this
|
||||
viewer — drives the deployment-directory empty state vs the land-in-corpus
|
||||
redirect).
|
||||
POST /api/projects — §22 S5 create-project (global-Owner only). The bot
|
||||
provisions a Gitea content repo and commits a project entry to `projects.yaml`;
|
||||
the registry mirror then upserts the `projects` + default `collections` rows
|
||||
(§22.2 keeps the registry the source of truth).
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
gated behind the §22.5 read gate (404 for a non-member of a gated project).
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
|
||||
to the backend instead of serving index.html).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
# A project id is a slug (the §22.2 registry key + the `/p/<id>/` path segment).
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
# A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`.
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
class CreateProjectBody(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
type: str
|
||||
visibility: str | None = None
|
||||
content_repo: str | None = None
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/deployment")
|
||||
async def get_deployment(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
dep = db.conn().execute(
|
||||
"SELECT name, tagline FROM deployment WHERE id = 1"
|
||||
).fetchone()
|
||||
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
|
||||
visible = set(auth.visible_project_ids(viewer))
|
||||
# §22 three-tier: `type` is a per-corpus field on the (default) collection
|
||||
# now; surface the default collection's type for each project.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, name, visibility FROM projects "
|
||||
"WHERE visibility != 'unlisted' ORDER BY name"
|
||||
).fetchall()
|
||||
projects = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"type": collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
),
|
||||
"entry_noun": collections_mod.entry_noun(
|
||||
collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
)
|
||||
),
|
||||
"visibility": r["visibility"],
|
||||
}
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
# §22 S5: the N=1 land-in-corpus redirect targets the default project, but
|
||||
# only when this viewer can actually read it. A `gated` default (C3.2) or
|
||||
# an absent default (C3.1, a deployment with no projects) is *not* a valid
|
||||
# redirect target — the frontend then falls through to the deployment
|
||||
# directory's role-aware empty state instead of bouncing into a 404.
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
default_exists = db.conn().execute(
|
||||
"SELECT 1 FROM projects WHERE id = ?", (default_id,)
|
||||
).fetchone() is not None
|
||||
default_readable = default_exists and auth.can_read_project(viewer, default_id)
|
||||
return {
|
||||
"name": (dep["name"] if dep else "") or "",
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
# §22.10 / M3-frontend guard contract: which project the backend
|
||||
# serves the corpus for (the default, until Plan B serves per
|
||||
# project). The frontend renders corpus routes only for this id and
|
||||
# shows a "content not yet served" placeholder for any other.
|
||||
"default_project_id": default_id,
|
||||
"default_project_readable": default_readable,
|
||||
"projects": projects,
|
||||
# §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances.
|
||||
"viewer": {"can_create_project": auth.can_create_project(viewer)},
|
||||
}
|
||||
|
||||
@router.post("/api/projects")
|
||||
async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]:
|
||||
# §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action.
|
||||
user = auth.require_contributor(request)
|
||||
if not auth.can_create_project(user):
|
||||
raise HTTPException(403, "Only a global Owner may create projects")
|
||||
pid = body.project_id.strip().lower()
|
||||
if not _SLUG_RE.match(pid) or pid == "default":
|
||||
raise HTTPException(422, "project id must be a slug and not 'default'")
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(422, "project name is required")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
# A project is created visible by default — the point of standing one up
|
||||
# is for it to be seen; an Owner narrows it afterwards (or picks gated).
|
||||
visibility = (body.visibility or "public").strip()
|
||||
if visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {visibility!r}")
|
||||
if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone():
|
||||
raise HTTPException(409, f"project `{pid}` already exists")
|
||||
content_repo = (body.content_repo or f"{pid}-content").strip()
|
||||
if not _REPO_RE.match(content_repo):
|
||||
raise HTTPException(422, f"invalid content repo name {content_repo!r}")
|
||||
if await gitea.get_repo(config.gitea_org, content_repo) is not None:
|
||||
raise HTTPException(409, f"repo `{content_repo}` already exists")
|
||||
|
||||
# Read the current registry, append the project, recompose. Reads live in
|
||||
# gitea.py and may be called anywhere; the bot owns the write back.
|
||||
read = await gitea.read_file(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if read is None:
|
||||
raise HTTPException(409, "registry projects.yaml not found")
|
||||
text, sha = read
|
||||
try:
|
||||
doc = yaml.safe_load(text) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise HTTPException(500, "registry projects.yaml is malformed")
|
||||
projects = doc.get("projects")
|
||||
if not isinstance(projects, list):
|
||||
projects = []
|
||||
if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects):
|
||||
raise HTTPException(409, f"project `{pid}` already in the registry")
|
||||
projects.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": name,
|
||||
"type": body.type,
|
||||
"content_repo": content_repo,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
doc["projects"] = projects
|
||||
new_text = yaml.safe_dump(doc, sort_keys=False)
|
||||
readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n"
|
||||
|
||||
try:
|
||||
await bot.create_project(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
registry_repo=config.registry_repo,
|
||||
content_repo=content_repo,
|
||||
project_id=pid,
|
||||
projects_yaml_new=new_text,
|
||||
projects_yaml_sha=sha,
|
||||
readme_text=readme_text,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new entry becomes projects +
|
||||
# default-collection rows.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, visibility FROM projects WHERE id = ?", (pid,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(500, "project committed but not mirrored")
|
||||
cid = collections_mod.default_collection_id(pid)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"visibility": row["visibility"],
|
||||
"type": collections_mod.collection_type(cid),
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
async def get_project(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s to a non-member (shape matches
|
||||
# an unknown id). unlisted is readable by direct id.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
except (ValueError, TypeError):
|
||||
cfg = {}
|
||||
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
|
||||
# §22 three-tier: type + initial_state moved down to the (default)
|
||||
# collection in migration 029.
|
||||
cid = collections_mod.default_collection_id(row["id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
"theme": cfg.get("theme") or {},
|
||||
}
|
||||
|
||||
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
|
||||
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
|
||||
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
|
||||
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
|
||||
# before the SPA's index.html fallback.
|
||||
# §22 three-tier (S1): the canonical entry route now carries the collection
|
||||
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
|
||||
# the default project's default collection.
|
||||
@router.get("/rfc/{slug}")
|
||||
async def redirect_old_rfc(slug: str) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
|
||||
|
||||
@router.get("/rfc/{slug}/pr/{pr_number}")
|
||||
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(
|
||||
url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
)
|
||||
|
||||
@router.get("/proposals/{pr_number}")
|
||||
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
|
||||
|
||||
return router
|
||||
@@ -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,17 +245,24 @@ 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,)
|
||||
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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")
|
||||
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
|
||||
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
|
||||
if row["state"] == "retired":
|
||||
raise HTTPException(404, "RFC not found")
|
||||
return row
|
||||
|
||||
|
||||
@@ -305,7 +312,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_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+252
-29
@@ -19,7 +19,10 @@ Routes (§17):
|
||||
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
|
||||
graduation precondition)
|
||||
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim) and the
|
||||
§13.7 retire / un-retire endpoints (POST /api/rfcs/<slug>/retire,
|
||||
POST /api/rfcs/<slug>/unretire) — soft-delete and its site-owner-only
|
||||
reversal, each a single in-place frontmatter flip via an auto-merged PR.
|
||||
|
||||
The orchestrator runs in-process — each in-flight graduation lives in a
|
||||
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
|
||||
@@ -39,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, db, entry as entry_mod
|
||||
from . import auth, cache, db, entry as entry_mod, projects as projects_mod
|
||||
from .bot import Actor, Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -74,7 +77,7 @@ class StepState:
|
||||
@dataclass
|
||||
class GraduationState:
|
||||
slug: str
|
||||
rfc_id: str
|
||||
rfc_id: str | None # null when graduating without a number (§13.2)
|
||||
owners: list[str]
|
||||
arbiters: list[str]
|
||||
steps: list[StepState]
|
||||
@@ -113,7 +116,7 @@ def _get_active(slug: str) -> GraduationState | None:
|
||||
return _active.get(slug)
|
||||
|
||||
|
||||
def _new_active(slug: str, *, rfc_id: str,
|
||||
def _new_active(slug: str, *, rfc_id: str | None,
|
||||
owners: list[str], arbiters: list[str]) -> GraduationState:
|
||||
state = GraduationState(
|
||||
slug=slug, rfc_id=rfc_id,
|
||||
@@ -164,7 +167,12 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
|
||||
|
||||
class GraduateBody(BaseModel):
|
||||
rfc_id: str = Field(min_length=5, max_length=40)
|
||||
# §13.2: the integer id is OPTIONAL. Blank/absent → graduate with no
|
||||
# number (id stays null, the slug is the canonical identifier per
|
||||
# §2.3). When supplied it must match ^RFC-\d{4,}$ and be free — both
|
||||
# are checked in the handler, not by the field bound (an empty string
|
||||
# is a legitimate value here).
|
||||
rfc_id: str | None = Field(default=None, max_length=40)
|
||||
owners: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
@@ -191,7 +199,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 +218,7 @@ def make_router(
|
||||
can_merge = (
|
||||
viewer is not None
|
||||
and (
|
||||
viewer.role in ("owner", "admin")
|
||||
auth.is_collection_superuser(viewer, rfc["collection_id"])
|
||||
or viewer.gitea_login in owners
|
||||
or viewer.gitea_login in arbiters
|
||||
)
|
||||
@@ -250,21 +258,26 @@ 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.
|
||||
|
||||
candidate_id = (request.query_params.get("id") or "").strip()
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
|
||||
# ID field
|
||||
# ID field — §13.2: the integer id is OPTIONAL. A blank id is
|
||||
# valid and means "graduate without a number" (id stays null, the
|
||||
# slug is canonical per §2.3). Only a *non-blank* id is held to the
|
||||
# RFC-NNNN regex and the collision check.
|
||||
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
|
||||
if not candidate_id:
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "Integer ID is required"
|
||||
pass # blank is the default-accepted case, not an error
|
||||
elif not _is_valid_rfc_id(candidate_id):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
|
||||
id_payload["error"] = (
|
||||
"ID must look like RFC-NNNN (at least four digits), "
|
||||
"or leave blank to graduate without a number"
|
||||
)
|
||||
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
|
||||
@@ -300,7 +313,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.
|
||||
@@ -315,20 +328,24 @@ def make_router(
|
||||
# §13.2 atomic re-validation. The dialog's debounced check runs
|
||||
# client-side as the admin types; this is the authoritative check
|
||||
# that closes the dialog-open-to-confirm race on the integer ID.
|
||||
rfc_id = body.rfc_id.strip()
|
||||
# The id is OPTIONAL: a blank/absent id graduates with no number
|
||||
# (id stays null, slug is canonical per §2.3). Only a supplied id
|
||||
# is held to the regex and the collision re-check.
|
||||
rfc_id = (body.rfc_id or "").strip() or None
|
||||
owners = [o.strip() for o in body.owners if o.strip()]
|
||||
if not owners:
|
||||
raise HTTPException(422, "Add at least one initial owner")
|
||||
if not _is_valid_rfc_id(rfc_id):
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
if rfc_id is not None:
|
||||
if not _is_valid_rfc_id(rfc_id):
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
@@ -441,7 +458,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}
|
||||
@@ -456,7 +479,7 @@ def make_router(
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
@@ -472,7 +495,7 @@ def make_router(
|
||||
try:
|
||||
pr = await bot.open_claim_pr(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
new_file_contents=new_contents, prior_sha=meta_sha,
|
||||
)
|
||||
@@ -482,18 +505,123 @@ def make_router(
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.7: POST /api/rfcs/<slug>/retire
|
||||
# Soft-delete an entry — flip its frontmatter `state` to `retired` via
|
||||
# an auto-merged meta-repo PR (§13.3 machinery reused). RFC owners and
|
||||
# site owners only — NOT app admins (§3.1). Allowed from super-draft or
|
||||
# active; the body and every other field (including the integer id) are
|
||||
# kept, so the entry stays recoverable in git and un-retire restores it
|
||||
# exactly.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/retire")
|
||||
async def retire_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_retirable(slug)
|
||||
if not _can_retire(rfc, viewer):
|
||||
raise HTTPException(
|
||||
403, "Only this RFC's owners or a site owner may retire it"
|
||||
)
|
||||
prior_state = rfc["state"]
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = "retired"
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
verb="retire", target_state="retired",
|
||||
)
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "retire",
|
||||
rfc_slug=slug,
|
||||
details={"prior_state": prior_state, "rfc_id": entry.id},
|
||||
)
|
||||
await _refresh_catalog()
|
||||
return {"ok": True, "slug": slug, "state": "retired"}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.7: POST /api/rfcs/<slug>/unretire
|
||||
# Bring a retired entry back to the state it held before retirement.
|
||||
# Site owners only (§3.1) — tighter than retire itself, so a soft-delete
|
||||
# is always recoverable by the operator but an RFC owner cannot reverse
|
||||
# their own retirement.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/unretire")
|
||||
async def unretire_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Only a site owner may un-retire an RFC")
|
||||
_require_retired(slug)
|
||||
restored = _prior_state_before_retire(slug)
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = restored
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
verb="unretire", target_state=restored,
|
||||
)
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "unretire",
|
||||
rfc_slug=slug,
|
||||
details={"restored_state": restored},
|
||||
)
|
||||
await _refresh_catalog()
|
||||
return {"ok": True, "slug": slug, "state": restored}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id 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
|
||||
|
||||
def _require_retirable(slug: str):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] not in ("super-draft", "active"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}, cannot be retired")
|
||||
return row
|
||||
|
||||
def _require_retired(slug: str):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "retired":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
||||
return row
|
||||
|
||||
async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]:
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
text, file_sha = fetched
|
||||
try:
|
||||
return entry_mod.parse(text), file_sha
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
|
||||
async def _refresh_catalog() -> None:
|
||||
# Inline refresh so the catalog reflects the flip immediately; the
|
||||
# webhook/reconciler path is the steady-state per §4.1. A refresh
|
||||
# failure does not unwind the merge — the reconciler catches up.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
except Exception as e:
|
||||
log.warning("retire/unretire cache refresh failed for slug: %s", e)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -528,7 +656,7 @@ async def _orchestrate(
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
@@ -548,7 +676,7 @@ async def _orchestrate(
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, rfc_id=state.rfc_id,
|
||||
@@ -610,7 +738,7 @@ async def _cleanup_unmerged(
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation merge failed",
|
||||
@@ -623,7 +751,7 @@ async def _cleanup_unmerged(
|
||||
await bot.delete_branch(
|
||||
actor,
|
||||
owner=config.gitea_org,
|
||||
repo=config.meta_repo,
|
||||
repo=(projects_mod.default_content_repo(config) or ""),
|
||||
branch=branch_name,
|
||||
slug=state.slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
@@ -666,13 +794,108 @@ 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_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
return viewer.gitea_login in owners or viewer.gitea_login in arbiters
|
||||
|
||||
|
||||
def _can_retire(rfc, viewer) -> bool:
|
||||
"""§3.1: retire is narrower than graduate/withdraw — RFC owners
|
||||
(frontmatter) and site `owner`-role holders only, NOT app admins."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role == "owner": # site owner
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
return viewer.gitea_login in owners
|
||||
|
||||
|
||||
def _prior_state_before_retire(slug: str) -> str:
|
||||
"""§13.7: the state to restore on un-retire. Read it from the most
|
||||
recent `retire` audit row's `prior_state`; if that's missing (e.g. the
|
||||
entry was retired out of band), fall back to inferring from the cached
|
||||
row — an assigned integer id implies it was `active`, else `super-draft`.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row and row["details"]:
|
||||
try:
|
||||
prior = json.loads(row["details"]).get("prior_state")
|
||||
if prior in ("super-draft", "active"):
|
||||
return prior
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
cached = db.conn().execute(
|
||||
"SELECT rfc_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
return "active" if (cached and cached["rfc_id"]) else "super-draft"
|
||||
|
||||
|
||||
async def _run_state_flip(
|
||||
*,
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
slug: str,
|
||||
new_contents: str,
|
||||
prior_sha: str,
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> None:
|
||||
"""§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs
|
||||
inline (no SSE — the flip is a single quick state change, unlike the
|
||||
multi-step graduation that streams progress). On an open failure
|
||||
nothing was created; on a merge failure the half-open PR/branch is
|
||||
cleaned up. Either failure raises 502 so the caller does not record the
|
||||
transition as having happened."""
|
||||
try:
|
||||
pr = await bot.open_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug, new_file_contents=new_contents, prior_sha=prior_sha,
|
||||
verb=verb, target_state=target_state,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
pr_number = pr["number"]
|
||||
head_branch = pr["head"]["ref"]
|
||||
try:
|
||||
await bot.merge_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, verb=verb,
|
||||
)
|
||||
except GiteaError as e:
|
||||
# Clean up the unmerged flip PR/branch so failed attempts don't
|
||||
# accumulate (mirrors graduation's `_cleanup_unmerged`).
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, reason=f"{verb} merge failed",
|
||||
)
|
||||
await bot.delete_branch(
|
||||
actor, owner=config.gitea_org, repo=(projects_mod.default_content_repo(config) or ""),
|
||||
branch=head_branch, slug=slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
reason=f"{verb} merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("%s cleanup failed for %s", verb, slug)
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
|
||||
def _audit(
|
||||
actor_user_id: int | None,
|
||||
on_behalf_of: str | None,
|
||||
|
||||
@@ -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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A gated project or collection is invisible to non-members (§22.5), so joining is
|
||||
by invite (an Owner grants directly — `api_memberships.py`) *or* by request: a
|
||||
user who knows a scope exists asks to join it, naming a desired role. This module
|
||||
is the request side:
|
||||
|
||||
* ``GET /api/scopes/{scope_type}/{scope_id}/join-target`` — what the join
|
||||
form needs (the scope's name, the viewer's eligibility + whether they already
|
||||
have a pending ask + their current role).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` — submit the ask
|
||||
(desired role + optional message); lands a row + one §15 notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept`` —
|
||||
Owner: accept, which writes the `memberships` row via ``memberships.grant``
|
||||
(the §22.8 "accepting writes the membership row"), then notifies the requester.
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline`` —
|
||||
Owner: decline; the request closes and the requester is notified.
|
||||
|
||||
Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
|
||||
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
|
||||
``memberships`` scope vocabulary (minus ``global`` — a deployment isn't a thing
|
||||
one discovers and joins), and accept grants a scope role rather than minting an
|
||||
RFC invitation.
|
||||
|
||||
The request POST deliberately does **not** require the scope be *readable*: the
|
||||
whole point of request-to-join is to ask into a *gated* scope you were told about
|
||||
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
|
||||
already a member". Accept/decline are gated on Owner reach over the scope
|
||||
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
_MESSAGE_MAX = 4000
|
||||
|
||||
|
||||
class JoinRequestBody(BaseModel):
|
||||
role: str
|
||||
message: str | None = Field(default=None, max_length=_MESSAGE_MAX)
|
||||
|
||||
|
||||
class DecideBody(BaseModel):
|
||||
# On accept, the Owner may grant a role narrower than the one requested; a
|
||||
# missing value grants exactly the requested role.
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
|
||||
"""Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
|
||||
it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
|
||||
`scope_type` itself must be one of the join-able scopes."""
|
||||
if scope_type == "project":
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name FROM projects WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
name = row["name"] or scope_id
|
||||
return {"project_id": scope_id, "scope_name": name, "project_name": name}
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
if col is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
pid = col["project_id"]
|
||||
return {
|
||||
"project_id": pid,
|
||||
"scope_name": col.get("name") or scope_id,
|
||||
"project_name": _project_name(pid),
|
||||
}
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
|
||||
def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
|
||||
"""The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
|
||||
scope's Owners across the subtree'). Reuses the S4 invite gates."""
|
||||
ok = (
|
||||
auth.can_invite_at_collection(viewer, scope_id)
|
||||
if scope_type == "collection"
|
||||
else auth.can_invite_at_project(viewer, scope_id)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(403, "Only an Owner of this scope can act on join requests")
|
||||
|
||||
|
||||
def _require_request(scope_type: str, scope_id: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, scope_type, scope_id, requester_user_id, requested_role,
|
||||
message, status
|
||||
FROM join_requests
|
||||
WHERE id = ? AND scope_type = ? AND scope_id = ?
|
||||
""",
|
||||
(request_id, scope_type, scope_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Join request not found")
|
||||
return row
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the join form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
|
||||
async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)
|
||||
|
||||
if viewer is None:
|
||||
eligible, reason = False, "Sign in to request to join."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
elif current_role is not None:
|
||||
eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM join_requests
|
||||
WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
|
||||
AND status = 'pending' LIMIT 1
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"name": facts["scope_name"],
|
||||
"project_id": facts["project_id"],
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
"current_role": current_role,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a request to join.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
|
||||
async def create_join_request(
|
||||
scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
# Already a member of the scope (at this or a broader grain)? Then there
|
||||
# is nothing to request — a clear 409 rather than a useless self-request.
|
||||
if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
|
||||
raise HTTPException(409, "You already hold a role in this scope.")
|
||||
|
||||
message = (body.message or "").strip() or None
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO join_requests
|
||||
(scope_type, scope_id, requester_user_id, requested_role, message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id, role, message),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (scope, user).
|
||||
raise HTTPException(409, "You already have a pending request to join this scope.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per Owner across the subtree; stamp the
|
||||
# first onto the row as the inbox-action handle (any Owner may act).
|
||||
notif_ids = notify.fan_out_join_request(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
project_id=facts["project_id"],
|
||||
project_name=facts["project_name"],
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
requested_role=role,
|
||||
message=message,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE join_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner accepts → write the membership row.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
|
||||
async def accept_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
# The Owner may narrow the requested role on accept; default to what was
|
||||
# asked for. (Both are within the Owner's grant reach at this scope.)
|
||||
granted_role = (body.role or req["requested_role"] or "").strip().lower()
|
||||
if granted_role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=req["requester_user_id"],
|
||||
role=granted_role,
|
||||
granted_by=viewer.user_id,
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, granted_role = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, granted_role, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=granted_role,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "granted_role": granted_role}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
|
||||
async def decline_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=None,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,157 @@
|
||||
"""§22 S4 (C.2) — the scope-role invitation surface.
|
||||
|
||||
An Owner grants `{owner, contributor}` at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account, looked up
|
||||
by email. The grant writes a `memberships` row immediately and §15-notifies
|
||||
the grantee (there is no accept round-trip; the C.2 scenarios name an existing
|
||||
user and write the row directly). Endpoints:
|
||||
|
||||
GET /api/projects/:pid/members — list the project subtree's grants
|
||||
POST /api/projects/:pid/members — grant at project scope, or
|
||||
(with collection_id) at one collection
|
||||
DELETE /api/projects/:pid/members/:user_id — revoke (optionally ?collection_id=)
|
||||
|
||||
The single POST keys on the optional `collection_id` so the invite UI's one
|
||||
control (role picker + scope picker) maps to one endpoint:
|
||||
|
||||
* no `collection_id` → project-scope grant; gate `can_invite_at_project`.
|
||||
* with `collection_id` → collection-scope grant; gate `can_invite_at_collection`.
|
||||
|
||||
There is deliberately no "grant at parent, exclude a child" parameter (C.2.5):
|
||||
the only knobs are role ∈ {owner, contributor} and scope ∈ {project, one
|
||||
collection}. Reach is bounded by the inviter's own Owner reach (C.2.3): a
|
||||
collection Owner who is nothing more is refused the project-scope POST.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
|
||||
class GrantBody(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
collection_id: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/members")
|
||||
async def list_members(project_id: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
# The full subtree listing is a project-Owner view; a collection-only
|
||||
# Owner manages membership through the collection-scoped POST/DELETE.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
return {"items": memberships_mod.list_for_project(project_id)}
|
||||
|
||||
@router.post("/api/projects/{project_id}/members")
|
||||
async def grant_member(
|
||||
project_id: str, body: GrantBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
cid = (body.collection_id or "").strip() or None
|
||||
if cid is not None:
|
||||
# Collection-scope grant — bounded by Owner reach over that collection.
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
scope_type, scope_id = "collection", cid
|
||||
else:
|
||||
# Project-scope grant — bounded by Owner reach over the project.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
scope_type, scope_id = "project", project_id
|
||||
|
||||
grantee = memberships_mod.user_by_email(body.email)
|
||||
if grantee is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
"No account with that email — the invitee must sign in to the "
|
||||
"deployment before they can be granted a role",
|
||||
)
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=grantee["id"],
|
||||
role=role,
|
||||
granted_by=user.user_id,
|
||||
)
|
||||
|
||||
# §15 (C.2): name the project and role to the grantee.
|
||||
col_name = None
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
col_name = (col.get("name") if col else None) or scope_id
|
||||
notify.notify_scope_role_granted(
|
||||
recipient_user_id=grantee["id"],
|
||||
granter_user_id=user.user_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
role=role,
|
||||
project_id=project_id,
|
||||
project_name=_project_name(project_id),
|
||||
collection_name=col_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"user_id": grantee["id"],
|
||||
"role": role,
|
||||
"pending": grantee["permission_state"] != "granted",
|
||||
}
|
||||
|
||||
@router.delete("/api/projects/{project_id}/members/{user_id}")
|
||||
async def revoke_member(
|
||||
project_id: str, user_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
cid = (request.query_params.get("collection_id") or "").strip() or None
|
||||
if cid is not None:
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="collection", scope_id=cid, user_id=user_id
|
||||
)
|
||||
else:
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="project", scope_id=project_id, user_id=user_id
|
||||
)
|
||||
return {"removed": removed}
|
||||
|
||||
return router
|
||||
@@ -213,14 +213,16 @@ 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 (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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)
|
||||
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
set_by = 'explicit',
|
||||
set_at = excluded.set_at
|
||||
|
||||
+23
-20
@@ -23,7 +23,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -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)
|
||||
@@ -153,7 +153,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, 1, 'just-me')
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
""",
|
||||
(slug, branch),
|
||||
)
|
||||
@@ -189,7 +189,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
@@ -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
|
||||
@@ -398,7 +398,7 @@ def make_router(
|
||||
INSERT INTO pr_seen
|
||||
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
last_seen_commit_sha = excluded.last_seen_commit_sha,
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
@@ -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):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id 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
|
||||
@@ -688,7 +691,7 @@ def make_router(
|
||||
|
||||
def _owner_repo(rfc) -> tuple[str, str]:
|
||||
if _is_meta_resident(rfc):
|
||||
return config.gitea_org, config.meta_repo
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
@@ -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_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+508
-15
@@ -16,9 +16,11 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
from .bot import Actor
|
||||
from .config import Config
|
||||
from .projects import DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -290,6 +292,471 @@ 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 _is_default_project(project_id: str) -> bool:
|
||||
"""True iff `project_id` owns the migration-seeded `default` collection — the
|
||||
deployment's primary project (§22.13), whatever its configured id. Only there
|
||||
do M2's role rows (which migrated to collection scope `default`) stand in for
|
||||
project-level authority."""
|
||||
return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id
|
||||
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *project-grain* §22.6 role at this project, or None — the
|
||||
most-permissive of a **global** grant (inherits down to every project) and a
|
||||
**project**-scope grant. Mapped back to the legacy
|
||||
`project_admin`/`project_contributor` strings the project-grain authz speaks.
|
||||
|
||||
Back-compat: on the deployment's *default* project only, M2's rows live at
|
||||
collection scope `default` (§B.3 migration), so a `default` collection-scope
|
||||
grant there is read as project-level too. A collection grant on any other
|
||||
project is NOT project authority — that is the four-layer collection resolver
|
||||
(`effective_scope_role`). Does not fold in the deployment tier
|
||||
(`is_project_superuser` adds it) or the implicit-on-public baseline."""
|
||||
if user is None:
|
||||
return None
|
||||
clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
|
||||
params: list = [user.user_id, project_id]
|
||||
if _is_default_project(project_id):
|
||||
clauses.append("(scope_type = 'collection' AND scope_id = ?)")
|
||||
params.append(collections_mod.DEFAULT_COLLECTION_ID)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
params,
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return "project_admin" if row["role"] == "owner" else "project_contributor"
|
||||
|
||||
|
||||
def project_of_rfc(rfc_slug: str) -> str:
|
||||
"""The project an RFC belongs to, via its collection
|
||||
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
|
||||
Falls back to the default project when the slug isn't cached — the same N=1
|
||||
default migration 026 backfills."""
|
||||
row = db.conn().execute(
|
||||
"SELECT c.project_id AS project_id "
|
||||
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
|
||||
"WHERE r.slug = ?",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_PROJECT_ID
|
||||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def collection_of_rfc(rfc_slug: str) -> str:
|
||||
"""The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back
|
||||
to the default collection when the slug isn't cached. Mirrors
|
||||
`project_of_rfc`'s first-match semantics; a slug shared across collections is
|
||||
a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved
|
||||
by the collection-qualified routes in later slices."""
|
||||
row = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
).fetchone()
|
||||
if row is None or not row["collection_id"]:
|
||||
return collections_mod.DEFAULT_COLLECTION_ID
|
||||
return row["collection_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 at the project grain. `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
|
||||
holder of any scope grant reaching the project — a global grant, a project
|
||||
grant, or membership at *any* collection within it (seeing a collection
|
||||
implies seeing its project). 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 — scope-role holders + superusers only, subject to the §6 floor.
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships m WHERE m.user_id = ? AND ("
|
||||
" m.scope_type = 'global'"
|
||||
" OR (m.scope_type = 'project' AND m.scope_id = ?)"
|
||||
" OR (m.scope_type = 'collection' AND m.scope_id IN "
|
||||
" (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1",
|
||||
(user.user_id, project_id, project_id),
|
||||
).fetchone()
|
||||
return row 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"])]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the
|
||||
# collection-grain visibility gate.
|
||||
#
|
||||
# A grant attaches the unified role {owner, contributor} at a scope: global,
|
||||
# project, or collection (§B.1). Grants inherit downward, are additive, and
|
||||
# admit no negative override (§B.2). Effective authority over a *collection* is
|
||||
# the most-permissive union of the layers reaching it:
|
||||
#
|
||||
# global (users.role owner/admin ∪ memberships scope_type='global')
|
||||
# ∪ project (memberships scope_type='project' at the collection's project)
|
||||
# ∪ collection (memberships scope_type='collection' at the collection)
|
||||
#
|
||||
# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today).
|
||||
# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct,
|
||||
# finer layer the RFC-grain helpers union in beneath collection.
|
||||
#
|
||||
# OPERATOR DECISIONS (S3, session 0076):
|
||||
# * scope-role-primary — a plain granted account (users.role 'contributor', no
|
||||
# membership) is a granted *account*, not a write-everywhere global role.
|
||||
# Write standing comes from an explicit scope grant; the lone exception is the
|
||||
# grandfathered implicit-public baseline below.
|
||||
# * grandfathered baseline — the migration-seeded `default` collection keeps the
|
||||
# pre-three-tier implicit-on-public write baseline (a granted deployment
|
||||
# contributor may propose while it is public), so the N=1 deployment (§22.13)
|
||||
# loses no capability. Every *explicitly-created* collection requires an
|
||||
# explicit scope grant to write.
|
||||
# * hidden-from-public — a `gated` collection is invisible to the public (404,
|
||||
# omitted from the directory) yet visible to any scope-role holder reaching it
|
||||
# (collection/project/global). A collection's visibility may be set only as
|
||||
# strict or stricter than its project's (the rank ordering below).
|
||||
# ===========================================================================
|
||||
|
||||
# The global scope is a single tier per deployment; its grant rows use this
|
||||
# sentinel scope_id (migration 030).
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
# §22.5 visibility strictness on the public-exposure axis: `public` is least
|
||||
# strict, `gated` most strict. A collection may narrow its project's visibility
|
||||
# but never widen it (`rank(collection) >= rank(project)`).
|
||||
_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
|
||||
|
||||
def visibility_rank(visibility: str | None) -> int:
|
||||
"""The strictness rank of a §22.5 visibility (higher = stricter). An unknown
|
||||
value reads as the strictest (`gated`) — the safe default."""
|
||||
return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"])
|
||||
|
||||
|
||||
def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None:
|
||||
"""The most-permissive unified role ({'owner','contributor'}) the user holds
|
||||
over `collection_id`, folding §B.2's global → project → collection layers.
|
||||
Returns None when no scope grant reaches the collection. 'owner' outranks
|
||||
'contributor'; there is no negative override (a parent grant is never
|
||||
subtracted by a child). Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
# Global tier — a deployment owner/admin is a global Owner (§B.1).
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)"
|
||||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, pid, collection_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The most-permissive role the user holds *over a project* — folding the
|
||||
global tier (deployment owner/admin, or a `scope_type='global'` grant) and a
|
||||
`scope_type='project'` grant on this project. Unlike `effective_scope_role`
|
||||
(which keys on a collection), this answers the project grain directly, for the
|
||||
§22.8 request-to-join membership check. Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def effective_role_at_scope(
|
||||
user: SessionUser | None, scope_type: str, scope_id: str
|
||||
) -> str | None:
|
||||
"""The most-permissive scope role the user holds over a `(scope_type,
|
||||
scope_id)` target — the scope-grain twin of `effective_scope_role`. A
|
||||
`collection` target folds global → project → collection (the existing
|
||||
resolver); a `project` target folds global → project. Returns None when no
|
||||
grant reaches the scope. Drives the §22.8 "already a member?" gate."""
|
||||
if scope_type == "collection":
|
||||
return effective_scope_role(user, scope_id)
|
||||
if scope_type == "project":
|
||||
return _effective_project_role(user, scope_id)
|
||||
return None
|
||||
|
||||
|
||||
def collection_visibility(collection_id: str) -> str:
|
||||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||||
an unknown collection is invisible rather than open."""
|
||||
row = db.conn().execute(
|
||||
"SELECT visibility FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["visibility"]:
|
||||
return "gated"
|
||||
return row["visibility"]
|
||||
|
||||
|
||||
def effective_collection_visibility(collection_id: str) -> str:
|
||||
"""The stricter of the collection's own visibility and its project's (§22.5
|
||||
'both gates'). A collection is constrained to be ≥ its project in strictness,
|
||||
but we max() defensively so a misconfigured looser collection can never widen
|
||||
its project's gate."""
|
||||
cvis = collection_visibility(collection_id)
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
pvis = project_visibility(pid) if pid else "gated"
|
||||
return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis
|
||||
|
||||
|
||||
def can_read_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22.5 read/existence gate at the collection grain. `public`/`unlisted`
|
||||
read by anyone (anonymous included — `unlisted` is link-only but the link
|
||||
reads); `gated` ("hidden from public existence") reads only for a scope-role
|
||||
holder over the collection (collection/project/global) or a deployment
|
||||
owner/admin. The subtractive read gate — a gated collection 404s a
|
||||
non-holder, indistinguishable from absent."""
|
||||
vis = effective_collection_visibility(collection_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
return effective_scope_role(user, collection_id) is not None
|
||||
|
||||
|
||||
def require_collection_readable(user: SessionUser | None, collection_id: str) -> None:
|
||||
"""Raise 404 when the collection is not readable by this viewer (§22.5: a
|
||||
hidden/gated collection is invisible to non-holders — the shape matches an
|
||||
unknown collection)."""
|
||||
if not can_read_collection(user, collection_id):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""Maximal authority over a collection: an effective scope role of 'owner'
|
||||
reaching it (a collection Owner, a project Owner of its project, a global
|
||||
Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters
|
||||
tier within the collection."""
|
||||
return effective_scope_role(user, collection_id) == "owner"
|
||||
|
||||
|
||||
def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""The grandfathered implicit-on-public write baseline, narrowed to the
|
||||
migration-seeded `default` collection (§22.13 N=1 case). A granted deployment
|
||||
`contributor` keeps its pre-three-tier write standing on the default
|
||||
collection while its effective visibility is public; every explicitly-created
|
||||
collection requires an explicit scope grant (S3 operator decision)."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if collection_id != collections_mod.DEFAULT_COLLECTION_ID:
|
||||
return False
|
||||
return user.role == "contributor" and effective_collection_visibility(collection_id) == "public"
|
||||
|
||||
|
||||
def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user contribute *new* content to the collection (propose an entry)
|
||||
— the collection-level contribute standing. The union of the scope-role grant
|
||||
(owner/contributor reaching the collection) and the grandfathered default
|
||||
baseline, subject to the visibility read gate."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_collection(user, collection_id):
|
||||
return False
|
||||
if effective_scope_role(user, collection_id) is not None:
|
||||
return True
|
||||
return _has_collection_write_baseline(user, collection_id)
|
||||
|
||||
|
||||
def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user participate in discussion in the collection — the
|
||||
collection-level discuss standing. A superset of contribute for this pass
|
||||
(the read-only viewer tier is deferred, §B.3), so it mirrors
|
||||
`can_contribute_in_collection`."""
|
||||
return can_contribute_in_collection(user, collection_id)
|
||||
|
||||
|
||||
def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user create a new collection in this project (§B.1)? Creating a
|
||||
collection is a *project-level* action: a deployment owner/admin, or any
|
||||
holder of a project-scope or global-scope grant (Owner OR RFC Contributor —
|
||||
'anyone at the project level with permission to create a collection'). A
|
||||
*collection*-scope grant cannot create sibling collections."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_create_project(user: SessionUser | None) -> bool:
|
||||
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
|
||||
is a **global-Owner** action — a deployment owner/admin (a global Owner per
|
||||
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
|
||||
a project is deployment-level, so it is not reachable by a project- or
|
||||
collection-scope grant nor by a global RFC Contributor (that role creates
|
||||
collections, not projects). 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
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships "
|
||||
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
|
||||
collection within it)? Managing membership is an *Owner* capability whose
|
||||
reach covers the project — a deployment owner/admin, a global Owner, or this
|
||||
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
|
||||
collection Owner's reach is its own collection only (C.2.3), so it is not
|
||||
offered project-scope invites. Identical to `is_project_superuser` — the
|
||||
invite gate IS "is an Owner over this project"."""
|
||||
return is_project_superuser(user, project_id)
|
||||
|
||||
|
||||
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
|
||||
whose reach covers it — the collection's Owner, its project's Owner, a global
|
||||
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
|
||||
narrowest invite reach; a collection Owner who is nothing more may invite
|
||||
here but not at the project or globally (C.2.3)."""
|
||||
return is_collection_superuser(user, collection_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 +853,28 @@ 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"):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a
|
||||
# collection the viewer cannot even read.
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §B.2 union, scope-role grants first — these bypass per-RFC curation (a
|
||||
# collection/project/global Owner or RFC Contributor ⊇ discussant).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
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
|
||||
# grandfathered implicit-public baseline (curation preserved): on the default
|
||||
# collection a granted deployment contributor 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_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
@@ -419,16 +896,26 @@ 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"):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §B.2 union, scope-role grants first (a collection/project/global RFC
|
||||
# Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
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
|
||||
# grandfathered implicit-public baseline (curation preserved): until an owner
|
||||
# exists, a granted deployment contributor on the public default collection
|
||||
# may contribute.
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
@@ -439,7 +926,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"):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# An Owner reaching the collection (collection/project/global Owner, or a
|
||||
# deployment owner/admin) may invite; otherwise only the RFC's frontmatter
|
||||
# owner. Per-RFC collaborators and the baseline do not get the invite power.
|
||||
if is_collection_superuser(user, cid):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
+280
-15
@@ -27,7 +27,7 @@ import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db, notify
|
||||
from . import db, entry as entry_mod, notify
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -163,6 +163,117 @@ class Bot:
|
||||
def __init__(self, gitea: Gitea):
|
||||
self._gitea = gitea
|
||||
|
||||
# ----- Content repo: collection structure (§22 S2) -----
|
||||
|
||||
async def create_collection(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
content_repo: str,
|
||||
collection_id: str,
|
||||
manifest_yaml: str,
|
||||
) -> dict:
|
||||
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
|
||||
repo's main. A structural admin action — committed straight to main (no
|
||||
PR), like the registry config it feeds; the registry mirror then upserts
|
||||
the collections row (§22.2 keeps the registry the source of truth). Logs
|
||||
an audit row for the §6.5 trail."""
|
||||
path = f"{collection_id}/.collection.yaml"
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
path,
|
||||
content=manifest_yaml,
|
||||
message=_stamp_single(f"chore: create collection {collection_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_collection",
|
||||
bot_commit_sha=created.get("commit", {}).get("sha"),
|
||||
details={"collection_id": collection_id, "repo": content_repo},
|
||||
)
|
||||
return created
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
registry_repo: str,
|
||||
content_repo: str,
|
||||
project_id: str,
|
||||
projects_yaml_new: str,
|
||||
projects_yaml_sha: str,
|
||||
readme_text: str,
|
||||
) -> dict:
|
||||
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
|
||||
a bot write at two git sources:
|
||||
|
||||
1. **provision the content repo** — create `org/content_repo` if it
|
||||
doesn't exist, then seed a `README.md` on `main` so the branch
|
||||
exists (the contents API initialises the repo with that commit; the
|
||||
corpus mirror and the propose path both need a `main` to write to).
|
||||
2. **register the project** — commit the caller-composed
|
||||
`projects.yaml` (the existing doc with the new project appended) to
|
||||
the registry repo's `main`.
|
||||
|
||||
Like `create_collection`, this is a structural admin action committed
|
||||
straight to main (no PR), like the registry config it feeds; the caller
|
||||
then re-runs the registry mirror so the new `projects` + default
|
||||
`collections` rows flow from the registry (§22.2 keeps the registry the
|
||||
source of truth). Logs a `create_project` audit row for the §6.5 trail.
|
||||
Returns the registry update_file result (carries the new commit sha)."""
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
existing = await self._gitea.get_repo(org, content_repo)
|
||||
if existing is None:
|
||||
await self._gitea.create_org_repo(
|
||||
org, content_repo, description=f"Content repo for project {project_id}"
|
||||
)
|
||||
# Seed a README if absent, which also establishes `main` on a freshly
|
||||
# created (auto_init=False) repo — the contents API initialises the repo
|
||||
# with that commit. Keyed on the README rather than the branch so it is
|
||||
# idempotent and behaves identically whether the repo has a bare `main`
|
||||
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
|
||||
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
|
||||
if readme is None:
|
||||
await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
"README.md",
|
||||
content=readme_text,
|
||||
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
result = await self._gitea.update_file(
|
||||
org,
|
||||
registry_repo,
|
||||
"projects.yaml",
|
||||
content=projects_yaml_new,
|
||||
sha=projects_yaml_sha,
|
||||
message=_stamp_single(f"chore: create project {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_project",
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"project_id": project_id, "content_repo": content_repo},
|
||||
)
|
||||
return result
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
@@ -175,12 +286,16 @@ class Bot:
|
||||
file_contents: str,
|
||||
pr_title: str,
|
||||
pr_description: str,
|
||||
rfcs_dir: str = "rfcs",
|
||||
) -> dict:
|
||||
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
|
||||
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
|
||||
|
||||
One file per PR keeps idea submissions atomic and conflict-free.
|
||||
The PR title and the file-add commit subject share §9.2's fixed
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2:
|
||||
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
|
||||
propose into a named collection writes under its subfolder; it
|
||||
defaults to `rfcs` (the default collection / shipped behaviour).
|
||||
"""
|
||||
branch = f"propose/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
@@ -189,7 +304,7 @@ class Bot:
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
meta_repo,
|
||||
f"rfcs/{slug}.md",
|
||||
f"{rfcs_dir}/{slug}.md",
|
||||
content=file_contents,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
@@ -706,22 +821,24 @@ class Bot:
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
rfc_id: str | None,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the integer `id` and
|
||||
graduation stamps — **keeping the body unchanged** (§1 meta-only
|
||||
topology; no repo is created and no body is stripped). Branch name
|
||||
uses the `graduate-<slug>-<6hex>` shape — dash-separated like the
|
||||
other meta-repo branches per the §19.2 path-routing candidate.
|
||||
entry's frontmatter to `state: active` with the graduation stamps
|
||||
and — **optionally** — the integer `id`, **keeping the body
|
||||
unchanged** (§1 meta-only topology; no repo is created and no body
|
||||
is stripped). When `rfc_id` is None the entry graduates without a
|
||||
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
@@ -736,11 +853,12 @@ class Bot:
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"Graduate {slug} → {rfc_id}"
|
||||
pr_title = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
owners_str = ", ".join(owners) if owners else "(none)"
|
||||
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"{id_line}"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
f"This is an in-place state flip per the meta-only topology\n"
|
||||
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
|
||||
@@ -772,7 +890,7 @@ class Bot:
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
rfc_id: str,
|
||||
rfc_id: str | None,
|
||||
) -> None:
|
||||
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
||||
merge actor. Distinct action_kind so the audit log carries the
|
||||
@@ -788,7 +906,7 @@ class Bot:
|
||||
reports the transient state (belt-and-suspenders against the same
|
||||
race appearing in a different shape).
|
||||
"""
|
||||
subject = f"Graduate {slug} → {rfc_id}"
|
||||
subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
body = _trailer(actor)
|
||||
|
||||
try:
|
||||
@@ -809,6 +927,105 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
|
||||
|
||||
async def open_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> dict:
|
||||
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
||||
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
||||
or un-retire (`verb='unretire'`, target the restored prior state).
|
||||
Only the frontmatter `state` changes; the body and every other
|
||||
field (including the integer `id`) are kept, so an un-retire
|
||||
restores the entry exactly. Branch shape mirrors graduation's
|
||||
`<verb>-<slug>-<6hex>`.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
commit_subject = f"{verb_title} {slug}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"{verb_title} {slug}"
|
||||
pr_body_text = (
|
||||
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
|
||||
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
|
||||
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
|
||||
f"only `state` changes."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, meta_repo,
|
||||
title=pr_title, body=pr_body, head=branch, base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
f"{verb}_pr_open",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "target_state": target_state},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
verb: str,
|
||||
) -> None:
|
||||
"""§13.7: auto-merge the retire / un-retire flip PR (same
|
||||
mergeable-wait + retry shape as `merge_graduation_pr`)."""
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
subject = f"{verb_title} {slug}"
|
||||
body = _trailer(actor)
|
||||
try:
|
||||
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
||||
except TimeoutError as e:
|
||||
raise GiteaError(409, str(e)) from e
|
||||
await _merge_with_retry(
|
||||
self._gitea, org, meta_repo, pr_number,
|
||||
merge_message_title=subject, merge_message_body=body,
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
f"{verb}_pr_merge",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={},
|
||||
)
|
||||
|
||||
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
||||
|
||||
async def close_graduation_pr(
|
||||
@@ -967,6 +1184,54 @@ class Bot:
|
||||
)
|
||||
return pr
|
||||
|
||||
# ----- §22.4c: mark-reviewed (direct main write) -----
|
||||
|
||||
async def mark_entry_reviewed(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
reviewed_by: str,
|
||||
reviewed_at: str,
|
||||
) -> None:
|
||||
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
||||
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
||||
trailer and writes an actions-log row, mirroring the graduation
|
||||
stamp's bot-write shape."""
|
||||
path = f"rfcs/{slug}.md"
|
||||
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
||||
if result is None:
|
||||
raise GiteaError(404, f"{path} not found")
|
||||
text, sha = result
|
||||
e = entry_mod.parse(text)
|
||||
e.unreviewed = False
|
||||
e.reviewed_at = reviewed_at
|
||||
e.reviewed_by = reviewed_by
|
||||
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, path,
|
||||
content=entry_mod.serialize(e),
|
||||
sha=sha,
|
||||
message=commit_message,
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"mark_reviewed",
|
||||
rfc_slug=slug,
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
|
||||
)
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
|
||||
+97
-30
@@ -27,7 +27,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from . import db, entry as entry_mod
|
||||
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
@@ -35,16 +35,46 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
|
||||
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
|
||||
|
||||
Idempotent. Safe to call on every meta-repo webhook and on every
|
||||
reconciler sweep.
|
||||
§22 (Plan B): a deployment has N projects (§22.1), each with its own
|
||||
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
|
||||
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
|
||||
safe on every content-repo webhook and reconciler sweep.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
|
||||
|
||||
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
# §22 S2: the corpus grain is the collection. Mirror every collection of the
|
||||
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
|
||||
# collection id. The default collection (subfolder '') reads `rfcs/` — the
|
||||
# shipped path, unchanged. include_unlisted: the mirror serves every
|
||||
# collection's content regardless of enumeration visibility.
|
||||
from . import collections as collections_mod
|
||||
for col in collections_mod.list_collections(project_id, include_unlisted=True):
|
||||
await _refresh_collection_corpus(
|
||||
org, project_id, repo, col["id"], col["subfolder"] or "", gitea
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_collection_corpus(
|
||||
org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea
|
||||
) -> None:
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
|
||||
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
|
||||
project_id, collection_id, rfcs_dir, e)
|
||||
return
|
||||
|
||||
seen_slugs: set[str] = set()
|
||||
@@ -58,24 +88,31 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||
project_id, collection_id, f["path"], parse_err)
|
||||
continue
|
||||
if not entry.slug:
|
||||
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||
project_id, collection_id, f["path"])
|
||||
continue
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
|
||||
# Mark entries removed from the meta repo as withdrawn-without-trace.
|
||||
# In practice the spec keeps withdrawn entries in rfcs/ as historical
|
||||
# record (§3), so this branch fires only for entries deleted out of
|
||||
# band. We leave the row but flag it for reconciler attention.
|
||||
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
|
||||
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
# leave the row, scoped to this collection, for reconciler attention.
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
|
||||
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
|
||||
project_id, collection_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||
@@ -87,9 +124,11 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
INSERT INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
models_json, funder_login, body, body_sha, last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
models_json, funder_login, body, body_sha,
|
||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
rfc_id = excluded.rfc_id,
|
||||
@@ -105,6 +144,9 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login = excluded.funder_login,
|
||||
body = excluded.body,
|
||||
body_sha = excluded.body_sha,
|
||||
unreviewed = excluded.unreviewed,
|
||||
reviewed_at = excluded.reviewed_at,
|
||||
reviewed_by = excluded.reviewed_by,
|
||||
last_entry_commit_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
@@ -125,6 +167,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login,
|
||||
entry.body,
|
||||
body_sha,
|
||||
1 if entry.unreviewed else 0,
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
collection_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -184,7 +230,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -320,7 +366,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
|
||||
of slashes per the §19.2 path-routing candidate.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
|
||||
return
|
||||
try:
|
||||
branches = await gitea.list_branches(org, repo)
|
||||
except GiteaError as e:
|
||||
@@ -355,7 +405,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -377,7 +427,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
""",
|
||||
@@ -437,17 +487,29 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
|
||||
login as last resort.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
bot_login = config.gitea_bot_user
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login)
|
||||
|
||||
|
||||
async def _refresh_project_pulls(
|
||||
org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str
|
||||
) -> None:
|
||||
repo_full = f"{org}/{repo}"
|
||||
try:
|
||||
open_pulls = await gitea.list_pulls(org, repo, state="open")
|
||||
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_pulls: %s", e)
|
||||
log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
|
||||
return
|
||||
|
||||
bot_login = config.gitea_bot_user
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
@@ -502,8 +564,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, merged_at, closed_at,
|
||||
head_branch, base_branch, head_sha, merge_commit_sha)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
head_branch, base_branch, head_sha, merge_commit_sha, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
@@ -530,6 +592,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
(pull.get("base") or {}).get("ref") or "main",
|
||||
(pull.get("head") or {}).get("sha"),
|
||||
merge_commit_sha,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -663,6 +726,10 @@ class Reconciler:
|
||||
async def sweep(self) -> None:
|
||||
log.info("reconciler: starting sweep")
|
||||
try:
|
||||
try:
|
||||
await registry_mod.refresh_registry(self._config, self._gitea)
|
||||
except Exception:
|
||||
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
||||
await refresh_meta_repo(self._config, self._gitea)
|
||||
await refresh_meta_branches(self._config, self._gitea)
|
||||
await refresh_meta_pulls(self._config, self._gitea)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""§22 collection grain — resolution helpers beneath the project tier.
|
||||
|
||||
In S1 each project has exactly one collection (the default). These helpers
|
||||
recover the collection for a project and read the per-corpus fields (`type`,
|
||||
`initial_state`) that moved down from `projects` in migration 029. Project-grain
|
||||
authz (auth.py) recovers a row's project by joining `collections` on
|
||||
`collection_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
# §22.4a item (2): the displayed noun for an entry is a type-driven label, a
|
||||
# framework concept (like role names), not deployment content. The chrome reads
|
||||
# this from the API rather than hardcoding "RFC", so a `specification` collection
|
||||
# says "Spec" and a `bdd` collection says "Feature" with no per-deployment config.
|
||||
ENTRY_NOUN = {
|
||||
"document": "RFC",
|
||||
"specification": "Spec",
|
||||
"bdd": "Feature",
|
||||
}
|
||||
_DEFAULT_ENTRY_NOUN = "RFC"
|
||||
|
||||
|
||||
def entry_noun(collection_type: str) -> str:
|
||||
"""The §22.4a entry noun for a collection type. Unknown types fall back to
|
||||
the generic 'RFC' so a future type is never label-less."""
|
||||
return ENTRY_NOUN.get(collection_type, _DEFAULT_ENTRY_NOUN)
|
||||
|
||||
|
||||
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
||||
when unset (the collection inherits its project's universe)."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
literal 'default' when the project has no collection row yet."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else DEFAULT_COLLECTION_ID
|
||||
|
||||
|
||||
def project_of_collection(collection_id: str) -> str | None:
|
||||
"""The project a collection belongs to, or None if unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["project_id"] if row else None
|
||||
|
||||
|
||||
def collection_initial_state(collection_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a collection. 'super-draft'
|
||||
default for an unknown/unset row (today's safe flow)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
|
||||
|
||||
def collection_type(collection_id: str) -> str:
|
||||
"""The collection's immutable §22.4a type. 'document' default for unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["type"] if row and row["type"] else "document"
|
||||
|
||||
|
||||
def subfolder_of(collection_id: str) -> str:
|
||||
"""The content-repo subfolder a collection lives under (§22.3). Empty string
|
||||
for the default collection (entries at the repo root `rfcs/`)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["subfolder"] if row else "") or ""
|
||||
|
||||
|
||||
def get_collection(collection_id: str) -> dict | None:
|
||||
"""The full collection row as a dict, or None if unknown. `enabled_models`
|
||||
(§22.12) is unpacked from `config_json` as a list, or None when unset."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
|
||||
"config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
|
||||
out["entry_noun"] = entry_noun(out["type"])
|
||||
return out
|
||||
|
||||
|
||||
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
||||
"""Collections in a project, the default first then by name (§22.5). `unlisted`
|
||||
is omitted from enumeration unless include_unlisted (a direct-id read or the
|
||||
corpus mirror, which serves every collection)."""
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
||||
"FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
if not include_unlisted and r["visibility"] == "unlisted":
|
||||
continue
|
||||
item = dict(r)
|
||||
item["entry_noun"] = entry_noun(item["type"])
|
||||
out.append(item)
|
||||
return out
|
||||
@@ -32,7 +32,7 @@ class Config:
|
||||
gitea_bot_user: str
|
||||
gitea_bot_token: str
|
||||
gitea_org: str
|
||||
meta_repo: str
|
||||
registry_repo: str
|
||||
oauth_client_id: str
|
||||
oauth_client_secret: str
|
||||
app_url: str
|
||||
@@ -44,14 +44,15 @@ class Config:
|
||||
anthropic_api_key: str = ""
|
||||
google_api_key: str = ""
|
||||
openai_api_key: str = ""
|
||||
default_project_id: str = ""
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"{self.app_url}/auth/callback"
|
||||
|
||||
@property
|
||||
def meta_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.meta_repo}"
|
||||
def registry_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.registry_repo}"
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
@@ -79,7 +80,7 @@ def load_config() -> Config:
|
||||
gitea_bot_user=_required("GITEA_BOT_USER"),
|
||||
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
|
||||
gitea_org=_required("GITEA_ORG"),
|
||||
meta_repo=_optional("META_REPO", "meta"),
|
||||
registry_repo=_required("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("/"),
|
||||
@@ -91,4 +92,5 @@ def load_config() -> Config:
|
||||
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
|
||||
google_api_key=_optional("GOOGLE_API_KEY"),
|
||||
openai_api_key=_optional("OPENAI_API_KEY"),
|
||||
default_project_id=_optional("DEFAULT_PROJECT_ID"),
|
||||
)
|
||||
|
||||
+23
-1
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
|
||||
if version in applied:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
|
||||
# folding project_id into a composite key) follow the official
|
||||
# 12-step ALTER procedure, which requires FK enforcement OFF —
|
||||
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
|
||||
# it must be toggled here, around the script. The connection is
|
||||
# in autocommit mode (isolation_level=None), so the PRAGMA takes
|
||||
# effect immediately. We re-enable and run foreign_key_check
|
||||
# after, failing the migration loudly if the rebuild left any
|
||||
# dangling reference.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
f"migration {version} left foreign-key violations: "
|
||||
f"{[tuple(v) for v in violations]}"
|
||||
)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+20
-1
@@ -30,7 +30,7 @@ _ABSENT = object()
|
||||
class Entry:
|
||||
slug: str
|
||||
title: str
|
||||
state: str = "super-draft" # super-draft | active | withdrawn
|
||||
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
|
||||
id: str | None = None # 'RFC-NNNN' or None
|
||||
repo: str | None = None
|
||||
proposed_by: str = ""
|
||||
@@ -50,6 +50,13 @@ class Entry:
|
||||
# operator credentials per §18 are used. The binding is inert until
|
||||
# the named user has a funder_consents row (the hybrid two-key rule).
|
||||
funder: str | None = None
|
||||
# §22.4c: an `active` entry that landed without a human review gate
|
||||
# carries unreviewed=True until an owner clears it. Orthogonal to
|
||||
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
|
||||
# are the provenance of the clear, paralleling graduated_at/by.
|
||||
unreviewed: bool = False
|
||||
reviewed_at: str | None = None
|
||||
reviewed_by: str | None = None
|
||||
body: str = ""
|
||||
|
||||
|
||||
@@ -66,6 +73,7 @@ def parse(text: str) -> Entry:
|
||||
models = [str(m) for m in raw_models]
|
||||
raw_funder = fm.get("funder")
|
||||
funder = str(raw_funder).strip() if raw_funder else None
|
||||
unreviewed = bool(fm.get("unreviewed") or False)
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
@@ -81,6 +89,9 @@ def parse(text: str) -> Entry:
|
||||
tags=list(fm.get("tags") or []),
|
||||
models=models,
|
||||
funder=funder,
|
||||
unreviewed=unreviewed,
|
||||
reviewed_at=fm.get("reviewed_at") or None,
|
||||
reviewed_by=fm.get("reviewed_by") or None,
|
||||
body=body,
|
||||
)
|
||||
|
||||
@@ -110,6 +121,14 @@ def serialize(entry: Entry) -> str:
|
||||
# second meaning here as with `models:`; one set of semantics.
|
||||
if entry.funder:
|
||||
fm["funder"] = entry.funder
|
||||
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
|
||||
# active entry leaves the key absent → frontmatter stays minimal).
|
||||
if entry.unreviewed:
|
||||
fm["unreviewed"] = True
|
||||
if entry.reviewed_at:
|
||||
fm["reviewed_at"] = entry.reviewed_at
|
||||
if entry.reviewed_by:
|
||||
fm["reviewed_by"] = entry.reviewed_by
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
|
||||
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, rfc_slug) DO NOTHING
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import db
|
||||
from . import db, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
|
||||
@@ -299,7 +299,14 @@ async def _delete_branch_via_bot(
|
||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||
return False
|
||||
if not rfc["repo"]:
|
||||
owner, repo = config.gitea_org, config.meta_repo
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning(
|
||||
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
|
||||
slug, branch,
|
||||
)
|
||||
return False
|
||||
owner = config.gitea_org
|
||||
elif "/" in rfc["repo"]:
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
else:
|
||||
|
||||
+24
-1
@@ -28,8 +28,10 @@ from . import (
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
projects,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
registry as registry_mod,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
@@ -100,6 +102,27 @@ async def lifespan(app: FastAPI):
|
||||
db.run_migrations(config)
|
||||
db.init(config)
|
||||
gitea = Gitea(config)
|
||||
# §22.2: mirror the registry before anything reads projects/content_repo.
|
||||
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||
# fresh thereafter and tolerates a later bad PR.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||
) from e
|
||||
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
|
||||
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
|
||||
# exists, so the original corpus lands at a meaningful /p/<id>/ and
|
||||
# 'default' is never a public URL. Idempotent no-op once done.
|
||||
projects.restamp_default_project(config)
|
||||
if projects.default_content_repo(config) is None:
|
||||
raise RuntimeError(
|
||||
f"registry does not describe the default project "
|
||||
f"{projects.resolved_default_id(config)!r} (no content_repo). "
|
||||
f"Add it to {config.registry_repo_full}/projects.yaml."
|
||||
)
|
||||
bot = Bot(gitea)
|
||||
reconciler = cache.Reconciler(config, gitea)
|
||||
digest_sched = digest.DigestScheduler()
|
||||
@@ -128,7 +151,7 @@ async def lifespan(app: FastAPI):
|
||||
reconciler.start()
|
||||
digest_sched.start()
|
||||
hygiene_sched.start()
|
||||
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
||||
log.info("RFC app started — registry %s", config.registry_repo_full)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""§22 S4 (C.2) — scope-role grant operations over the `memberships` table.
|
||||
|
||||
The membership *gates* (who may invite, who holds which role) live in
|
||||
`auth.py`; this module holds the *mutations* the invitation surface drives —
|
||||
granting, the "broader scope supersedes narrower" cleanup, listing, and
|
||||
revocation — mirroring how `invites.py` owns the create/claim/list of per-user
|
||||
invite tokens while the gate (`auth.can_invite_to_rfc`) lives in `auth.py`.
|
||||
|
||||
The model (Part B / S3): a `memberships` row is `(scope_type ∈ {global,
|
||||
project, collection}, scope_id, user_id, role ∈ {owner, contributor})`, unique
|
||||
per `(scope_type, scope_id, user_id)`. A grant is a direct write of that row
|
||||
(the C.2 scenarios write the row immediately and §15-notify an existing
|
||||
account — there is no accept round-trip; inviting a not-yet-account email is
|
||||
out of S4 scope and handled by the admin-create-invite path).
|
||||
|
||||
The "broader scope supersedes narrower" rule (C.2.6): granting a role at a
|
||||
broader scope removes this user's narrower rows that the new grant *subsumes*
|
||||
— a narrower row whose role is no more permissive than the new one. A narrower
|
||||
row that is *more* permissive is kept (no negative override: a child Owner
|
||||
grant survives a parent Contributor grant, and the §B.2 resolver still unions
|
||||
most-permissively).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
|
||||
# Higher rank = more permissive. Used by the supersede rule: a narrower grant
|
||||
# is pruned only when its rank ≤ the new broader grant's rank.
|
||||
_ROLE_RANK = {"contributor": 1, "owner": 2}
|
||||
|
||||
VALID_SCOPE_TYPES = ("global", "project", "collection")
|
||||
VALID_ROLES = ("owner", "contributor")
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
|
||||
def user_by_email(email: str) -> dict[str, Any] | None:
|
||||
"""The `users` row (id, display_name, email, permission_state) for an
|
||||
email, case-insensitively, or None. The grantee must already be an account
|
||||
— S4 grants a scope role to an existing user, it does not provision one."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, display_name, email, permission_state, role "
|
||||
"FROM users WHERE lower(email) = lower(?) "
|
||||
"ORDER BY id LIMIT 1",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def grant(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
user_id: int,
|
||||
role: str,
|
||||
granted_by: int | None,
|
||||
) -> None:
|
||||
"""Write (or update) the membership row, then apply the C.2.6
|
||||
broader-scope-supersedes cleanup. Idempotent on `(scope_type, scope_id,
|
||||
user_id)` — re-granting at the same scope updates the role and the grantor.
|
||||
|
||||
The grant is recorded regardless of the grantee's deployment
|
||||
`permission_state`: a `pending` account's row is written (C.2.7), but the
|
||||
§6 admission floor in `auth.effective_scope_role` keeps it conferring no
|
||||
write until the account is granted at the deployment."""
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (scope_type, scope_id, user_id) "
|
||||
"DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, "
|
||||
"granted_at = datetime('now')",
|
||||
(scope_type, scope_id, user_id, role, granted_by),
|
||||
)
|
||||
_prune_subsumed(scope_type=scope_type, scope_id=scope_id, user_id=user_id, role=role)
|
||||
|
||||
|
||||
def _prune_subsumed(*, scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
"""Remove this user's narrower rows that the just-written broader grant
|
||||
subsumes (same-or-lower role rank within the broader scope's subtree). A
|
||||
collection grant subsumes nothing narrower (the per-entry tier is separate);
|
||||
a project grant subsumes its collections; a global grant subsumes every
|
||||
project and collection."""
|
||||
rank = _ROLE_RANK[role]
|
||||
keep_ranks = [r for r, v in _ROLE_RANK.items() if v <= rank]
|
||||
if not keep_ranks:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in keep_ranks)
|
||||
if scope_type == "project":
|
||||
# Narrower = collection-scope rows for collections in this project.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type = 'collection' "
|
||||
f" AND role IN ({placeholders}) "
|
||||
f" AND scope_id IN (SELECT id FROM collections WHERE project_id = ?)",
|
||||
(user_id, *keep_ranks, scope_id),
|
||||
)
|
||||
elif scope_type == "global":
|
||||
# Narrower = every project- and collection-scope row for this user.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type IN ('project', 'collection') "
|
||||
f" AND role IN ({placeholders})",
|
||||
(user_id, *keep_ranks),
|
||||
)
|
||||
|
||||
|
||||
def revoke(*, scope_type: str, scope_id: str, user_id: int) -> bool:
|
||||
"""Remove a membership row at exactly this scope. Returns True if a row was
|
||||
removed. Revocation is scope-exact: it does not cascade to broader or
|
||||
narrower grants (each is its own administrative act)."""
|
||||
cur = db.conn().execute(
|
||||
"DELETE FROM memberships WHERE scope_type = ? AND scope_id = ? AND user_id = ?",
|
||||
(scope_type, scope_id, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def list_for_project(project_id: str) -> list[dict[str, Any]]:
|
||||
"""Every project-scope grant on this project plus every collection-scope
|
||||
grant on its collections, joined to the grantee's display fields — the data
|
||||
behind the project-Owner membership panel. Ordered project grants first,
|
||||
then by collection, then by role (Owner before Contributor)."""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.scope_type, m.scope_id, m.user_id, m.role, m.granted_at,
|
||||
u.display_name, u.email, u.permission_state,
|
||||
c.name AS collection_name
|
||||
FROM memberships m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN collections c ON c.id = m.scope_id AND m.scope_type = 'collection'
|
||||
WHERE (m.scope_type = 'project' AND m.scope_id = ?)
|
||||
OR (m.scope_type = 'collection'
|
||||
AND m.scope_id IN (SELECT id FROM collections WHERE project_id = ?))
|
||||
ORDER BY (m.scope_type != 'project'), m.scope_id,
|
||||
CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, u.display_name
|
||||
""",
|
||||
(project_id, project_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -38,22 +38,78 @@ from . import db, funder
|
||||
from .providers import BaseProvider
|
||||
|
||||
|
||||
def _models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""The `enabled_models` list inside a project/collection `config_json`,
|
||||
or None when the key is absent (meaning "no narrowing at this tier")."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def _narrow(universe: list[str], allowed: list[str] | None) -> list[str]:
|
||||
"""Intersect `universe` with `allowed`, preserving universe order. `allowed`
|
||||
None means no narrowing at this tier; an empty list narrows to empty (an
|
||||
opt-out), exactly like the §6.6 per-entry `models: []`."""
|
||||
if allowed is None:
|
||||
return universe
|
||||
allow = set(allowed)
|
||||
return [k for k in universe if k in allow]
|
||||
|
||||
|
||||
def _scope_narrowed_universe(
|
||||
collection_id: str | None, operator_keys: list[str]
|
||||
) -> list[str]:
|
||||
"""§22.12 — narrow the operator (deployment) universe by the entry's
|
||||
project then its collection `enabled_models`. Each tier may only narrow;
|
||||
a missing config at a tier is a no-op. The collection cannot widen its
|
||||
project because narrowing composes from the operator ceiling downward."""
|
||||
if collection_id is None:
|
||||
return list(operator_keys)
|
||||
conn = db.conn()
|
||||
crow = conn.execute(
|
||||
"SELECT project_id, config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
universe = list(operator_keys)
|
||||
if crow is None:
|
||||
return universe
|
||||
prow = conn.execute(
|
||||
"SELECT config_json FROM projects WHERE id = ?", (crow["project_id"],)
|
||||
).fetchone()
|
||||
universe = _narrow(universe, _models_from_config(prow["config_json"] if prow else None))
|
||||
universe = _narrow(universe, _models_from_config(crow["config_json"]))
|
||||
return universe
|
||||
|
||||
|
||||
def resolve_models_for_rfc(
|
||||
slug: str, providers: dict[str, BaseProvider]
|
||||
) -> list[str]:
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7 and
|
||||
§22.12.
|
||||
|
||||
The first entry is the RFC's default model. An empty list means
|
||||
AI is unavailable on this RFC and callers refuse the AI surface.
|
||||
"""
|
||||
# §6.7: the funder universe (if any) replaces the operator universe
|
||||
# as the base set the §6.6 frontmatter intersects against.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
base_universe = funder_universe if funder_universe is not None else list(providers.keys())
|
||||
row = db.conn().execute(
|
||||
"SELECT models_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT collection_id, models_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
collection_id = row["collection_id"] if row is not None else None
|
||||
# §22.12: first narrow the operator universe by the entry's project +
|
||||
# collection enabled_models (the deployment → project → collection chain).
|
||||
scope_universe = _scope_narrowed_universe(collection_id, list(providers.keys()))
|
||||
# §6.7: a consenting funder universe (if any) replaces the operator universe
|
||||
# as the base set — still bounded by the §22.12 scope narrowing above.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
if funder_universe is not None:
|
||||
base_universe = _narrow(list(funder_universe), scope_universe)
|
||||
else:
|
||||
base_universe = scope_universe
|
||||
if row is None or row["models_json"] is None:
|
||||
return list(base_universe)
|
||||
try:
|
||||
|
||||
@@ -324,6 +324,48 @@ def fan_out_contribution_request(
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_scope_role_granted(
|
||||
*,
|
||||
recipient_user_id: int,
|
||||
granter_user_id: int | None,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
role: str,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
collection_name: str | None,
|
||||
) -> int | None:
|
||||
"""§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
|
||||
Personal-direct — the recipient is the named subject — so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events.
|
||||
The scope facts ride in the payload so the inbox row (and email body) names
|
||||
the project and role without a second fetch. Actor is the granter (§15.9);
|
||||
a system/administrative grant with no granter renders as "the app".
|
||||
|
||||
Returns the notification id, or None when the grantee would be notifying
|
||||
themselves (a self-grant — no notification)."""
|
||||
if granter_user_id is not None and recipient_user_id == granter_user_id:
|
||||
return None
|
||||
details = {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"role": role,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"collection_name": collection_name or "",
|
||||
}
|
||||
return _emit_one(
|
||||
recipient_user_id=recipient_user_id,
|
||||
event_kind="scope_role_granted",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=granter_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def notify_contribution_decided(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
@@ -350,6 +392,95 @@ def notify_contribution_decided(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_join_request(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
requested_role: str,
|
||||
message: str | None,
|
||||
) -> list[int]:
|
||||
"""§22.8: a user asked to join a scope. Land one actionable notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11) and
|
||||
return their ids (the caller stamps the first onto the request row as the
|
||||
inbox-action handle — any of them can act on it).
|
||||
|
||||
Personal-direct: each Owner is a named subject able to act, so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events. The
|
||||
requested role + message ride in the payload so the inbox row shows the full
|
||||
ask inline. Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"requested_role": requested_role,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"message": message or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="join_request_on_scope",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_join_decided(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
granted_role: str | None,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""§22.8: tell the requester an Owner accepted (writing their `memberships`
|
||||
row) or declined their request to join. The scope + granted role ride in the
|
||||
payload so the inbox row names where they were let in without a second fetch."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=("join_request_accepted" if accepted else "join_request_declined"),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"granted_role": granted_role or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -625,6 +756,41 @@ def _admin_user_ids() -> set[int]:
|
||||
}
|
||||
|
||||
|
||||
def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
|
||||
"""The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
|
||||
the recipients of a request-to-join, aggregated upward so the request reaches
|
||||
everyone who could grant it. For a `collection`: its collection-scope Owners,
|
||||
its project's Owners, the global Owners, and deployment owners/admins. For a
|
||||
`project`: its project-scope Owners plus global Owners and deployment
|
||||
owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
|
||||
# Deployment owners/admins are global Owners by §B.1; explicit
|
||||
# scope_type='global' Owner grants join them.
|
||||
ids: set[int] = set(_admin_user_ids())
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
def _owners_at(stype: str, sid: str) -> None:
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships "
|
||||
"WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
|
||||
(stype, sid),
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
if scope_type == "collection":
|
||||
_owners_at("collection", scope_id)
|
||||
prow = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if prow and prow["project_id"]:
|
||||
_owners_at("project", prow["project_id"])
|
||||
elif scope_type == "project":
|
||||
_owners_at("project", scope_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _proposer_user_id(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -859,6 +1025,36 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||||
if event_kind == "contribution_request_declined":
|
||||
return f"{actor} declined your request to contribute to {title}."
|
||||
if event_kind == "scope_role_granted":
|
||||
# §22 S4 (C.2): names the role and the scope (the project, and the
|
||||
# collection when collection-scoped) per "a §15 notification naming the
|
||||
# project and role".
|
||||
role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
|
||||
project_label = extras.get("project_name") or extras.get("project_id") or "a project"
|
||||
scope_type = extras.get("scope_type")
|
||||
if scope_type == "collection":
|
||||
col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
|
||||
return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
|
||||
if scope_type == "global":
|
||||
return f"{actor} granted you {role_label} across the whole deployment."
|
||||
return f"{actor} granted you {role_label} on project {project_label}."
|
||||
if event_kind == "join_request_on_scope":
|
||||
# §22.8: owner-facing, actionable. Names who wants in, where, and as
|
||||
# what; the inbox row renders Accept/Decline beneath this line.
|
||||
role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
|
||||
scope_type = extras.get("scope_type")
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
|
||||
where = (
|
||||
f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
|
||||
)
|
||||
return f"{actor} asked to join {where} as {role_label}."
|
||||
if event_kind == "join_request_accepted":
|
||||
role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
|
||||
if event_kind == "join_request_declined":
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} declined your request to join {scope_label}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Project registry — the §22 multi-project layer.
|
||||
|
||||
A deployment hosts one or more projects (§22.1). The git registry mirror
|
||||
that lets a deployment declare projects lands in M3 and drives this module.
|
||||
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
|
||||
the registry mirror (`registry.refresh_registry`) is authoritative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROJECT_ID = "default"
|
||||
|
||||
|
||||
def resolved_default_id(config: Config) -> str:
|
||||
"""The id of the deployment's bootstrap/default project. Plan A: always
|
||||
'default' (the re-stamp to a config slug rides Plan B). The config knob is
|
||||
read here so Plan B can flip the resolution without touching call sites."""
|
||||
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def restamp_default_project(config: Config) -> None:
|
||||
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
|
||||
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
|
||||
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
|
||||
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
|
||||
|
||||
Renames `project_id` across every project-scoped table (discovered by
|
||||
column, so it stays correct as the schema grows), then drops the stale
|
||||
bootstrap `projects` row (its data has moved to the configured row, which
|
||||
the registry mirror already created). Idempotent and a no-op when the
|
||||
configured id is still 'default' or no bootstrap rows remain. Runs at
|
||||
startup after the registry mirror, with FK enforcement off for the rename
|
||||
(the composite FKs are kept consistent because parent and child rows are
|
||||
renamed together) and a foreign_key_check backstop before commit.
|
||||
"""
|
||||
target = resolved_default_id(config)
|
||||
if target == DEFAULT_PROJECT_ID:
|
||||
return
|
||||
conn = db.conn()
|
||||
# §22 three-tier: the entry-corpus tables key on collection_id now; detect a
|
||||
# lingering bootstrap project by the project-grain `collections.project_id`
|
||||
# (the PRAGMA scan below still renames every project_id column dynamically).
|
||||
has_rows = conn.execute(
|
||||
"SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
stale_proj = conn.execute(
|
||||
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
if not has_rows and not stale_proj:
|
||||
return
|
||||
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
|
||||
log.warning("restamp: target project %r not in registry yet; skipping", target)
|
||||
return
|
||||
|
||||
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
pid_tables = [
|
||||
t for t in tables
|
||||
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
|
||||
]
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
for t in pid_tables:
|
||||
conn.execute(
|
||||
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
|
||||
(target, DEFAULT_PROJECT_ID),
|
||||
)
|
||||
# The bootstrap row's data has moved to the configured (registry) row.
|
||||
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
conn.execute("ROLLBACK")
|
||||
raise RuntimeError(
|
||||
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
|
||||
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
||||
|
||||
|
||||
def default_content_repo(config: Config) -> str | None:
|
||||
"""The content repo the single-corpus mirror reads, from the default
|
||||
project's row (filled by the registry mirror). Replaces the retired
|
||||
META_REPO. None until the registry mirror has run."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?",
|
||||
(resolved_default_id(config),),
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def content_repo(project_id: str) -> str | None:
|
||||
"""The content repo for a specific project (§22.3). None if unknown/unset.
|
||||
The per-project successor to `default_content_repo` for the write path."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project's default collection
|
||||
(the per-corpus field moved down to the collection in migration 029).
|
||||
Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
|
||||
from . import collections as collections_mod
|
||||
return collections_mod.collection_initial_state(
|
||||
collections_mod.default_collection_id(project_id)
|
||||
)
|
||||
@@ -0,0 +1,350 @@
|
||||
"""§22.2 project registry mirror — the config-side analogue of
|
||||
cache.refresh_meta_repo.
|
||||
|
||||
A deployment declares its projects in a `projects.yaml` at the root of
|
||||
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
|
||||
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
|
||||
registry only — never from user actions. The mirror runs on the registry-repo
|
||||
webhook and on every reconciler sweep (Option A wiring, Task 5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VALID_TYPES = {"document", "specification", "bdd"}
|
||||
VALID_VISIBILITY = {"gated", "public", "unlisted"}
|
||||
VALID_INITIAL_STATE = {"super-draft", "active"}
|
||||
# §22.4b: per-type default landing state.
|
||||
_TYPE_DEFAULT_INITIAL_STATE = {
|
||||
"document": "super-draft",
|
||||
"specification": "super-draft",
|
||||
"bdd": "active",
|
||||
}
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""A registry document that fails validation, or a missing registry file.
|
||||
|
||||
Raised by parse_registry/refresh_registry. The caller decides severity:
|
||||
fatal at startup (no last-good to serve), tolerated on a running deployment
|
||||
(keep the last-good projects rows). See Task 5 wiring.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectEntry:
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
content_repo: str
|
||||
visibility: str
|
||||
initial_state: str
|
||||
config: dict = field(default_factory=dict) # theme, enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectionEntry:
|
||||
"""A named collection declared by a `.collection.yaml` manifest inside a
|
||||
project's content repo (S2). `visibility=None` means "inherit the project's
|
||||
visibility"."""
|
||||
type: str
|
||||
visibility: str | None
|
||||
initial_state: str
|
||||
name: str | None
|
||||
config: dict = field(default_factory=dict) # §22.12 enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryDoc:
|
||||
deployment_name: str
|
||||
deployment_tagline: str
|
||||
projects: list[ProjectEntry]
|
||||
|
||||
|
||||
def parse_registry(text: str) -> RegistryDoc:
|
||||
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
|
||||
raw = yaml.safe_load(text) or {}
|
||||
dep = raw.get("deployment") or {}
|
||||
projects_raw = raw.get("projects") or []
|
||||
if not isinstance(projects_raw, list) or not projects_raw:
|
||||
raise RegistryError("registry must declare at least one project")
|
||||
seen: set[str] = set()
|
||||
entries: list[ProjectEntry] = []
|
||||
for p in projects_raw:
|
||||
if not isinstance(p, dict):
|
||||
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
|
||||
pid = str(p.get("id") or "").strip()
|
||||
if not _SLUG_RE.match(pid):
|
||||
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
||||
if pid in seen:
|
||||
raise RegistryError(f"duplicate project id {pid!r}")
|
||||
seen.add(pid)
|
||||
name = str(p.get("name") or "").strip()
|
||||
if not name:
|
||||
raise RegistryError(f"project {pid!r} missing name")
|
||||
ptype = str(p.get("type") or "").strip()
|
||||
if ptype not in VALID_TYPES:
|
||||
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
|
||||
content_repo = str(p.get("content_repo") or "").strip()
|
||||
if not content_repo:
|
||||
raise RegistryError(f"project {pid!r} missing content_repo")
|
||||
vis = str(p.get("visibility") or "gated").strip()
|
||||
if vis not in VALID_VISIBILITY:
|
||||
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
|
||||
initial_state = str(
|
||||
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
|
||||
).strip()
|
||||
if initial_state not in VALID_INITIAL_STATE:
|
||||
raise RegistryError(
|
||||
f"project {pid!r} has invalid initial_state {initial_state!r}"
|
||||
)
|
||||
cfg: dict = {}
|
||||
if p.get("theme") is not None:
|
||||
cfg["theme"] = p["theme"]
|
||||
if p.get("enabled_models") is not None:
|
||||
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
|
||||
entries.append(
|
||||
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
|
||||
)
|
||||
return RegistryDoc(
|
||||
deployment_name=str(dep.get("name") or "").strip(),
|
||||
deployment_tagline=str(dep.get("tagline") or "").strip(),
|
||||
projects=entries,
|
||||
)
|
||||
|
||||
|
||||
def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
|
||||
|
||||
`type` is required and immutable (§22.4a, enforced at upsert). `visibility`
|
||||
is optional — omitted means inherit the project's. `initial_state` defaults
|
||||
per type (§22.4b)."""
|
||||
raw = yaml.safe_load(text) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise RegistryError("collection manifest must be a mapping")
|
||||
ctype = str(raw.get("type") or "").strip()
|
||||
if ctype not in VALID_TYPES:
|
||||
raise RegistryError(f"collection has invalid type {ctype!r}")
|
||||
vis = raw.get("visibility")
|
||||
if vis is not None:
|
||||
vis = str(vis).strip()
|
||||
if vis not in VALID_VISIBILITY:
|
||||
raise RegistryError(f"collection has invalid visibility {vis!r}")
|
||||
initial_state = str(
|
||||
raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype]
|
||||
).strip()
|
||||
if initial_state not in VALID_INITIAL_STATE:
|
||||
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
|
||||
name = raw.get("name")
|
||||
name = str(name).strip() if name else None
|
||||
# §22.12: an optional per-collection enabled_models list that narrows the
|
||||
# project's universe. Absent → no narrowing (inherit). Present (incl. empty)
|
||||
# → narrowing applies; [] opts the collection out of AI.
|
||||
cfg: dict = {}
|
||||
if raw.get("enabled_models") is not None:
|
||||
em = raw["enabled_models"]
|
||||
if not isinstance(em, list):
|
||||
raise RegistryError("collection enabled_models must be a list")
|
||||
cfg["enabled_models"] = [str(m) for m in em]
|
||||
return CollectionEntry(ctype, vis, initial_state, name, cfg)
|
||||
|
||||
|
||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||
"""The id of a project's default collection. The deployment's primary
|
||||
project (== `default_id`, the §22.13 resolved default) gets the stable
|
||||
literal `'default'` — matching migration 029's seed so the upsert *merges*
|
||||
onto the migration-seeded row rather than duplicating it (critical on a
|
||||
fresh deploy where the bootstrap `default` project is later restamped to the
|
||||
configured id). Any additional project keys its default collection by its own
|
||||
id, keeping the collection PK globally unique (pre-S5 multi-project)."""
|
||||
return "default" if project_id == default_id else project_id
|
||||
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None:
|
||||
"""Upsert the parsed registry into projects + their default collections +
|
||||
the deployment singleton. Idempotent.
|
||||
|
||||
§22 three-tier (S1): a project carries the grouping-tier fields (name,
|
||||
content_repo, visibility, config); the per-corpus fields (`type`,
|
||||
`initial_state`) live on the project's default collection. §22.4a: `type` is
|
||||
immutable — a change against an existing collection is rejected (skip the
|
||||
type change + log), never applied. Projects absent from the registry are
|
||||
left in place (archival is out of scope for M3; they stop refreshing).
|
||||
"""
|
||||
with db.tx() as conn:
|
||||
for e in doc.projects:
|
||||
cid = _default_collection_id(e.id, default_id)
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (cid,)
|
||||
).fetchone()
|
||||
type_locked = existing is not None and existing["type"] != e.type
|
||||
if type_locked:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
cid, existing["type"], e.type,
|
||||
)
|
||||
# The project (grouping tier) always refreshes.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(id, name, content_repo, visibility, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
content_repo = excluded.content_repo,
|
||||
visibility = excluded.visibility,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(e.id, e.name, e.content_repo, e.visibility, json.dumps(e.config), registry_sha),
|
||||
)
|
||||
# The default collection (corpus tier). On an immutable-type
|
||||
# conflict, keep the stored type but still refresh the rest.
|
||||
effective_type = existing["type"] if type_locked else e.type
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, '', ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
type = excluded.type,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(cid, e.id, effective_type, e.initial_state, e.visibility, e.name, registry_sha),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE deployment
|
||||
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
""",
|
||||
(doc.deployment_name, doc.deployment_tagline, registry_sha),
|
||||
)
|
||||
|
||||
|
||||
def _strictest_visibility(a: str, b: str) -> str:
|
||||
"""The stricter of two §22.5 visibilities on the public-exposure axis
|
||||
(`public` < `unlisted` < `gated`). Used to enforce that a collection is set
|
||||
only as strict or stricter than its project (S3 operator decision)."""
|
||||
rank = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
return a if rank.get(a, 2) >= rank.get(b, 2) else b
|
||||
|
||||
|
||||
def _upsert_named_collection(
|
||||
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
|
||||
) -> None:
|
||||
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
|
||||
change against an existing row is refused (logged, not applied). A None
|
||||
manifest visibility inherits the project's visibility; a manifest that tries
|
||||
to be *looser* than its project is clamped to the project's (S3 strictness:
|
||||
a collection may narrow but never widen its project's visibility)."""
|
||||
requested = ce.visibility or proj.visibility
|
||||
visibility = _strictest_visibility(requested, proj.visibility)
|
||||
if visibility != requested:
|
||||
log.warning(
|
||||
"registry: collection %s visibility %r looser than project %s %r — "
|
||||
"clamped to %r (S3 strictness)",
|
||||
subdir, requested, proj.id, proj.visibility, visibility,
|
||||
)
|
||||
with db.tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (subdir,)
|
||||
).fetchone()
|
||||
if existing is not None and existing["type"] != ce.type:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
subdir, existing["type"], ce.type,
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name,
|
||||
json.dumps(ce.config), sha),
|
||||
)
|
||||
|
||||
|
||||
async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None:
|
||||
"""§22 S2: named collections are declared by `.collection.yaml` manifests
|
||||
inside each project's content repo (the default collection comes from
|
||||
projects.yaml). Walk each content repo root; a subdir carrying a manifest
|
||||
becomes a collection keyed by the subdir name. Tolerant: a transport or
|
||||
parse failure on one project/collection logs and is skipped, never aborts
|
||||
the wider mirror (keep last-good)."""
|
||||
for proj in doc.projects:
|
||||
try:
|
||||
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
|
||||
except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate
|
||||
log.warning("registry: cannot list %s root: %s", proj.content_repo, e)
|
||||
continue
|
||||
for it in items:
|
||||
if it.get("type") != "dir":
|
||||
continue
|
||||
subdir = it["name"]
|
||||
manifest = await gitea.get_contents(
|
||||
config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main"
|
||||
)
|
||||
if not manifest or manifest.get("type") != "file":
|
||||
continue
|
||||
mtext = base64.b64decode(manifest["content"]).decode("utf-8")
|
||||
try:
|
||||
ce = parse_collection_manifest(mtext)
|
||||
except RegistryError as e:
|
||||
log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e)
|
||||
continue
|
||||
_upsert_named_collection(proj, subdir, ce, sha)
|
||||
|
||||
|
||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||
|
||||
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
||||
on transport failure; the caller chooses fatal-vs-tolerated.
|
||||
"""
|
||||
item = await gitea.get_contents(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if not item or item.get("type") != "file":
|
||||
raise RegistryError(
|
||||
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
||||
)
|
||||
text = base64.b64decode(item["content"]).decode("utf-8")
|
||||
# Prefer the file's last commit sha for provenance (production Gitea
|
||||
# includes it on the contents response); fall back to the blob sha.
|
||||
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
||||
doc = parse_registry(text)
|
||||
from . import projects as projects_mod
|
||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||
# §22 S2: discover + upsert named collections from each content repo.
|
||||
await _mirror_named_collections(config, gitea, doc, sha)
|
||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||
+16
-8
@@ -16,7 +16,7 @@ import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from . import cache, db
|
||||
from . import cache, db, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
@@ -79,9 +79,22 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
except Exception:
|
||||
payload = {}
|
||||
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
||||
meta_full = f"{config.gitea_org}/{config.meta_repo}"
|
||||
registry_full = f"{config.gitea_org}/{config.registry_repo}"
|
||||
content_repo = projects_mod.default_content_repo(config)
|
||||
if not content_repo:
|
||||
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
|
||||
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
|
||||
try:
|
||||
if repo_full == meta_full or not repo_full:
|
||||
if repo_full == registry_full:
|
||||
# §22.2: a registry-repo push re-mirrors the projects table.
|
||||
# Tolerate a malformed projects.yaml (keep last-good rows); let a
|
||||
# transport error bubble to the outer 500 so an unreachable Gitea
|
||||
# on a registry push is loud rather than silently dropped.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except registry_mod.RegistryError:
|
||||
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
|
||||
elif content_full and (repo_full == content_full or not repo_full):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -90,11 +103,6 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
if slug:
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
else:
|
||||
# v0.18.0: the proposal's "unknown-repo logging"
|
||||
# gesture — a hook on a fork or a stale repo binding
|
||||
# used to silently 200-OK here, hiding the
|
||||
# misconfiguration. Now the operator sees it in
|
||||
# the log.
|
||||
log.info(
|
||||
"webhook received for unknown repo: repo_full=%s event=%s "
|
||||
"(no cached_rfcs row matched; hook may be on a fork or stale)",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
|
||||
-- with the expanded constraint and copy the rows across. cached_rfcs is a
|
||||
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
|
||||
-- holds a foreign key into it, so the rebuild is safe; we preserve the
|
||||
-- existing rows anyway to avoid a needless full re-read on upgrade.
|
||||
--
|
||||
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
|
||||
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
|
||||
-- 009_per_rfc_models -> models_json
|
||||
-- 010_funder -> funder_login
|
||||
-- 021_proposed_use_case -> proposed_use_case
|
||||
-- They are appended last (matching the live column order) and copied
|
||||
-- across explicitly so nothing is dropped.
|
||||
--
|
||||
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
|
||||
-- swap is atomic.
|
||||
|
||||
CREATE TABLE cached_rfcs_new (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
|
||||
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT, -- 009_per_rfc_models
|
||||
funder_login TEXT, -- 010_funder
|
||||
proposed_use_case TEXT -- 021_proposed_use_case
|
||||
);
|
||||
|
||||
INSERT INTO cached_rfcs_new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case)
|
||||
SELECT
|
||||
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case
|
||||
FROM cached_rfcs;
|
||||
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
|
||||
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- §22 M3 (Plan A) — additive registry/runtime-config schema.
|
||||
--
|
||||
-- This is the additive half of M3's backend. It adds the project `type` and
|
||||
-- `initial_state` columns (mirrored from the registry), the deployment
|
||||
-- singleton (deployment name/tagline mirrored from the registry), and the
|
||||
-- §22.4c review columns on cached_rfcs. NO table rebuilds: the §22.13 PK
|
||||
-- rebuilds and the default->slug re-stamp ride a later migration (Plan B),
|
||||
-- just before a second project can collide (see migration 026's DEFERRED
|
||||
-- block and docs/superpowers/specs/2026-06-03-m3-backend-design.md §1/§6).
|
||||
|
||||
ALTER TABLE projects ADD COLUMN type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd'));
|
||||
ALTER TABLE projects ADD COLUMN initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active'));
|
||||
|
||||
-- Deployment-level identity (name, tagline) mirrored from the registry's
|
||||
-- `deployment:` block. A singleton: the CHECK pins it to one row.
|
||||
CREATE TABLE IF NOT EXISTS deployment (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
tagline TEXT,
|
||||
registry_sha TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO deployment (id) VALUES (1);
|
||||
|
||||
-- §22.4c review flag + provenance. unreviewed is git-truth (mirrored from
|
||||
-- entry frontmatter); it survives a cache rebuild like `state` does.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN unreviewed INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_at TEXT;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_by TEXT;
|
||||
@@ -0,0 +1,276 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
|
||||
-- constraints that migration 026 deliberately left global, so a *second*
|
||||
-- project can hold an entry with the same slug as the first. M1 (026) added
|
||||
-- project_id additively (no rebuild); this is the rebuild that activates
|
||||
-- project #2, enumerated in 026's header.
|
||||
--
|
||||
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
|
||||
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
|
||||
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
|
||||
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
|
||||
-- above tells the runner to toggle it and run foreign_key_check after).
|
||||
--
|
||||
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
|
||||
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
|
||||
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
|
||||
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
|
||||
-- their FK at its new composite (project_id, slug) key.
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (project_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
|
||||
|
||||
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
|
||||
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
|
||||
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
|
||||
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
|
||||
CREATE TABLE rfc_invitations__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitee_email TEXT NOT NULL,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||
token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
accepted_at TEXT,
|
||||
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
|
||||
DROP TABLE rfc_invitations;
|
||||
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
|
||||
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||
|
||||
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
|
||||
CREATE TABLE cached_branches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
head_sha TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_commit_at TEXT,
|
||||
closed_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
|
||||
DROP TABLE cached_branches;
|
||||
ALTER TABLE cached_branches__new RENAME TO cached_branches;
|
||||
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
|
||||
|
||||
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
|
||||
CREATE TABLE branch_visibility__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
read_public INTEGER NOT NULL DEFAULT 1,
|
||||
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
|
||||
DROP TABLE branch_visibility;
|
||||
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
||||
|
||||
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
|
||||
CREATE TABLE branch_contribute_grants__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
|
||||
);
|
||||
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
|
||||
DROP TABLE branch_contribute_grants;
|
||||
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
|
||||
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
|
||||
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
|
||||
|
||||
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
|
||||
CREATE TABLE stars__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO stars__new SELECT * FROM stars;
|
||||
DROP TABLE stars;
|
||||
ALTER TABLE stars__new RENAME TO stars;
|
||||
CREATE INDEX idx_stars_user ON stars (user_id);
|
||||
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
|
||||
|
||||
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
|
||||
CREATE TABLE watches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
|
||||
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_participation_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO watches__new SELECT * FROM watches;
|
||||
DROP TABLE watches;
|
||||
ALTER TABLE watches__new RENAME TO watches;
|
||||
CREATE INDEX idx_watches_user ON watches (user_id);
|
||||
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
|
||||
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
|
||||
|
||||
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
|
||||
CREATE TABLE pr_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
last_seen_commit_sha TEXT,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, pr_number)
|
||||
);
|
||||
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
|
||||
DROP TABLE pr_seen;
|
||||
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
||||
|
||||
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
||||
CREATE TABLE branch_chat_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
|
||||
DROP TABLE branch_chat_seen;
|
||||
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
||||
|
||||
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
|
||||
CREATE TABLE funder_consents__new (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
PRIMARY KEY (project_id, user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
|
||||
DROP TABLE funder_consents;
|
||||
ALTER TABLE funder_consents__new RENAME TO funder_consents;
|
||||
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
|
||||
|
||||
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
|
||||
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
|
||||
CREATE TABLE rfc_collaborators__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
|
||||
DROP TABLE rfc_collaborators;
|
||||
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
|
||||
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
|
||||
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
|
||||
CREATE TABLE contribution_requests__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
matched_term TEXT NOT NULL,
|
||||
who_i_am TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
use_case TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
|
||||
DROP TABLE contribution_requests;
|
||||
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
|
||||
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
|
||||
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
|
||||
CREATE UNIQUE INDEX idx_contribution_requests_one_open
|
||||
ON contribution_requests(project_id, rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
|
||||
CREATE TABLE proposed_use_cases__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, scope, pr_number)
|
||||
);
|
||||
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
|
||||
DROP TABLE proposed_use_cases;
|
||||
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,474 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project.
|
||||
--
|
||||
-- (1) a `collections` table beneath `projects`;
|
||||
-- (2) move the per-corpus fields (type, initial_state) down from `projects`
|
||||
-- (projects keeps id, name, content_repo, visibility, config_json, …);
|
||||
-- (3) one default collection per project (id='default' for the standard
|
||||
-- single-project deployment, subfolder = repo root), inheriting the
|
||||
-- project's type / initial_state / visibility;
|
||||
-- (4) re-key the 13 entry-corpus tables (project_id, slug) -> (collection_id,
|
||||
-- slug) via the migration-028 rebuild pattern, mapping each row to its
|
||||
-- project's default collection by JOIN;
|
||||
-- (5) generalise project_members -> memberships(scope_type ∈ {project,
|
||||
-- collection}, scope_id, …), collapsing the role enum to {owner,
|
||||
-- contributor} (§B.3).
|
||||
--
|
||||
-- SQLite can't ALTER a PK/UNIQUE in place, so each keyed table is rebuilt by the
|
||||
-- official create-copy-drop-rename procedure. FK enforcement is OFF for the file
|
||||
-- (the `migrate:no-foreign-keys` marker tells the runner to toggle it and run
|
||||
-- foreign_key_check after). cached_rfcs is rebuilt FIRST so the child tables can
|
||||
-- re-point their composite FK at its new (collection_id, slug) key.
|
||||
--
|
||||
-- The tables 026 tagged with project_id but 028 did NOT key (threads, changes,
|
||||
-- notifications, actions, pr_resolution_branches, cached_prs) keep project_id —
|
||||
-- they carry a project-grain tag, untouched in S1. See
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
|
||||
|
||||
-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ──────
|
||||
-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`)
|
||||
-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving
|
||||
-- rows with a stale `project_id` (e.g. 'default') that the per-project collection
|
||||
-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild
|
||||
-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each
|
||||
-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs
|
||||
-- are unique per collection and, pre-rebuild, globally), and drop rows whose
|
||||
-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from
|
||||
-- gitea). On a clean/fresh deployment every satellite is empty or already
|
||||
-- consistent, so this whole block is a no-op. (Discovered on the OHM data:
|
||||
-- ~1.3k `cached_branches` rows stranded at project_id='default'.)
|
||||
-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same
|
||||
-- branch cached under both the stale and the real project_id) — re-stamping them
|
||||
-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly-
|
||||
-- stamped copy is kept (it carries the current head_sha / visibility). Only the
|
||||
-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id)
|
||||
-- /(scope,pr_number) and have no stale data here, so they need no dedup.
|
||||
DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects)
|
||||
AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects));
|
||||
DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects)
|
||||
AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects));
|
||||
UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
|
||||
-- ── collections: the new typed-corpus grain beneath projects ───────────────
|
||||
CREATE TABLE collections (
|
||||
id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd')),
|
||||
subfolder TEXT NOT NULL DEFAULT '',
|
||||
initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active')),
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
name TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX idx_collections_project ON collections(project_id);
|
||||
|
||||
-- One default collection per project. id='default' for the standard
|
||||
-- single-project deployment (a stable literal across deploy histories); the
|
||||
-- project_id is used as a unique fallback id only if a non-standard
|
||||
-- multi-project deployment migrates (pre-S5; avoids a PK collision).
|
||||
INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name)
|
||||
SELECT
|
||||
CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END,
|
||||
p.id, p.type, '', p.initial_state, p.visibility, p.name
|
||||
FROM projects p;
|
||||
|
||||
-- ── projects: rebuild to DROP the per-corpus fields (type, initial_state) ───
|
||||
CREATE TABLE projects__new (
|
||||
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'))
|
||||
);
|
||||
INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at)
|
||||
SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects;
|
||||
DROP TABLE projects;
|
||||
ALTER TABLE projects__new RENAME TO projects;
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ────
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id),
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (collection_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at,
|
||||
graduated_by, owners_json, arbiters_json, tags_json, body, body_sha,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at, models_json,
|
||||
funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by)
|
||||
SELECT
|
||||
r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at,
|
||||
r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json,
|
||||
r.funder_login, r.proposed_use_case,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1),
|
||||
r.unreviewed, r.reviewed_at, r.reviewed_by
|
||||
FROM cached_rfcs r;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id);
|
||||
|
||||
-- ── rfc_invitations: single-col FK -> composite (collection_id, rfc_slug) ───
|
||||
CREATE TABLE rfc_invitations__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitee_email TEXT NOT NULL,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||
token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
accepted_at TEXT,
|
||||
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_invitations__new
|
||||
(id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token,
|
||||
expires_at, created_at, accepted_at, accepted_by_user_id, collection_id)
|
||||
SELECT
|
||||
i.id, i.rfc_slug, i.inviter_user_id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
||||
i.expires_at, i.created_at, i.accepted_at, i.accepted_by_user_id,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = i.project_id LIMIT 1)
|
||||
FROM rfc_invitations i;
|
||||
DROP TABLE rfc_invitations;
|
||||
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
|
||||
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||
|
||||
-- ── cached_branches: UNIQUE (project_id, rfc_slug, branch_name) -> collection
|
||||
CREATE TABLE cached_branches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
head_sha TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_commit_at TEXT,
|
||||
closed_at TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO cached_branches__new
|
||||
(id, rfc_slug, branch_name, head_sha, state, pinned, created_at, last_commit_at, closed_at, collection_id)
|
||||
SELECT
|
||||
b.id, b.rfc_slug, b.branch_name, b.head_sha, b.state, b.pinned, b.created_at, b.last_commit_at, b.closed_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = b.project_id LIMIT 1)
|
||||
FROM cached_branches b;
|
||||
DROP TABLE cached_branches;
|
||||
ALTER TABLE cached_branches__new RENAME TO cached_branches;
|
||||
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
|
||||
|
||||
-- ── branch_visibility: UNIQUE (project_id, rfc_slug, branch_name) -> collection
|
||||
CREATE TABLE branch_visibility__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
read_public INTEGER NOT NULL DEFAULT 1,
|
||||
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_visibility__new
|
||||
(id, rfc_slug, branch_name, read_public, contribute_mode, collection_id)
|
||||
SELECT
|
||||
v.id, v.rfc_slug, v.branch_name, v.read_public, v.contribute_mode,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = v.project_id LIMIT 1)
|
||||
FROM branch_visibility v;
|
||||
DROP TABLE branch_visibility;
|
||||
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
||||
|
||||
-- ── branch_contribute_grants: UNIQUE (..., grantee) -> +collection_id ───────
|
||||
CREATE TABLE branch_contribute_grants__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name, grantee_user_id)
|
||||
);
|
||||
INSERT INTO branch_contribute_grants__new
|
||||
(id, rfc_slug, branch_name, grantee_user_id, granted_by, granted_at, collection_id)
|
||||
SELECT
|
||||
g.id, g.rfc_slug, g.branch_name, g.grantee_user_id, g.granted_by, g.granted_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = g.project_id LIMIT 1)
|
||||
FROM branch_contribute_grants g;
|
||||
DROP TABLE branch_contribute_grants;
|
||||
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
|
||||
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
|
||||
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
|
||||
|
||||
-- ── stars: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ──────────
|
||||
CREATE TABLE stars__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO stars__new (id, user_id, rfc_slug, starred_at, collection_id)
|
||||
SELECT s.id, s.user_id, s.rfc_slug, s.starred_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
|
||||
FROM stars s;
|
||||
DROP TABLE stars;
|
||||
ALTER TABLE stars__new RENAME TO stars;
|
||||
CREATE INDEX idx_stars_user ON stars (user_id);
|
||||
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
|
||||
|
||||
-- ── watches: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ────────
|
||||
CREATE TABLE watches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
|
||||
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_participation_at TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO watches__new
|
||||
(id, user_id, rfc_slug, state, set_by, set_at, last_participation_at, collection_id)
|
||||
SELECT
|
||||
w.id, w.user_id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = w.project_id LIMIT 1)
|
||||
FROM watches w;
|
||||
DROP TABLE watches;
|
||||
ALTER TABLE watches__new RENAME TO watches;
|
||||
CREATE INDEX idx_watches_user ON watches (user_id);
|
||||
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
|
||||
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
|
||||
|
||||
-- ── pr_seen: UNIQUE (project_id, user_id, rfc_slug, pr_number) -> collection ─
|
||||
CREATE TABLE pr_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
last_seen_commit_sha TEXT,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug, pr_number)
|
||||
);
|
||||
INSERT INTO pr_seen__new
|
||||
(id, user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at, collection_id)
|
||||
SELECT
|
||||
p.id, p.user_id, p.rfc_slug, p.pr_number, p.last_seen_commit_sha, p.last_seen_message_id, p.seen_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = p.project_id LIMIT 1)
|
||||
FROM pr_seen p;
|
||||
DROP TABLE pr_seen;
|
||||
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
||||
|
||||
-- ── branch_chat_seen: UNIQUE (project_id, user_id, rfc_slug, branch) -> coll ─
|
||||
CREATE TABLE branch_chat_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_chat_seen__new
|
||||
(id, user_id, rfc_slug, branch_name, last_seen_message_id, seen_at, collection_id)
|
||||
SELECT
|
||||
s.id, s.user_id, s.rfc_slug, s.branch_name, s.last_seen_message_id, s.seen_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
|
||||
FROM branch_chat_seen s;
|
||||
DROP TABLE branch_chat_seen;
|
||||
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
||||
|
||||
-- ── funder_consents: PRIMARY KEY (project_id, user_id, rfc_slug) -> collection
|
||||
CREATE TABLE funder_consents__new (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
PRIMARY KEY (collection_id, user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO funder_consents__new (user_id, rfc_slug, created_at, collection_id)
|
||||
SELECT f.user_id, f.rfc_slug, f.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = f.project_id LIMIT 1)
|
||||
FROM funder_consents f;
|
||||
DROP TABLE funder_consents;
|
||||
ALTER TABLE funder_consents__new RENAME TO funder_consents;
|
||||
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
|
||||
|
||||
-- ── rfc_collaborators: UNIQUE idx + composite FK -> collection_id ───────────
|
||||
CREATE TABLE rfc_collaborators__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_collaborators__new
|
||||
(id, rfc_slug, user_id, role_in_rfc, invitation_id, created_at, collection_id)
|
||||
SELECT
|
||||
rc.id, rc.rfc_slug, rc.user_id, rc.role_in_rfc, rc.invitation_id, rc.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = rc.project_id LIMIT 1)
|
||||
FROM rfc_collaborators rc;
|
||||
DROP TABLE rfc_collaborators;
|
||||
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (collection_id, rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
|
||||
-- ── contribution_requests: UNIQUE idx (pending) + composite FK -> collection ─
|
||||
CREATE TABLE contribution_requests__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
matched_term TEXT NOT NULL,
|
||||
who_i_am TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
use_case TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO contribution_requests__new
|
||||
(id, rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case, status,
|
||||
created_at, decided_at, decided_by_user_id, invitation_id, notification_id, collection_id)
|
||||
SELECT
|
||||
cr.id, cr.rfc_slug, cr.requester_user_id, cr.matched_term, cr.who_i_am, cr.why, cr.use_case, cr.status,
|
||||
cr.created_at, cr.decided_at, cr.decided_by_user_id, cr.invitation_id, cr.notification_id,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = cr.project_id LIMIT 1)
|
||||
FROM contribution_requests cr;
|
||||
DROP TABLE contribution_requests;
|
||||
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
|
||||
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
|
||||
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
|
||||
CREATE UNIQUE INDEX idx_contribution_requests_one_open
|
||||
ON contribution_requests(collection_id, rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── proposed_use_cases: UNIQUE (project_id, scope, pr_number) -> collection ──
|
||||
CREATE TABLE proposed_use_cases__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, scope, pr_number)
|
||||
);
|
||||
INSERT INTO proposed_use_cases__new
|
||||
(id, scope, rfc_slug, pr_number, use_case, created_at, collection_id)
|
||||
SELECT
|
||||
u.id, u.scope, u.rfc_slug, u.pr_number, u.use_case, u.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = u.project_id LIMIT 1)
|
||||
FROM proposed_use_cases u;
|
||||
DROP TABLE proposed_use_cases;
|
||||
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
|
||||
-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─
|
||||
CREATE TABLE memberships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
|
||||
-- M2 project_members rows attached at what is now the *collection*; collapse the
|
||||
-- role enum (project_admin -> owner, project_contributor -> contributor;
|
||||
-- project_viewer dropped this pass, §B.3) and migrate onto the default
|
||||
-- collection of each project.
|
||||
INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT 'collection',
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1),
|
||||
pm.user_id,
|
||||
CASE pm.role WHEN 'project_admin' THEN 'owner'
|
||||
WHEN 'project_contributor' THEN 'contributor'
|
||||
ELSE 'contributor' END,
|
||||
pm.granted_by, pm.granted_at
|
||||
FROM project_members pm
|
||||
WHERE pm.role IN ('project_admin', 'project_contributor');
|
||||
DROP TABLE project_members;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table.
|
||||
--
|
||||
-- §B.2's resolver folds four layers (global → project → collection → per-entry).
|
||||
-- Migration 029 created `memberships` with scope_type ∈ {project, collection}
|
||||
-- only; the global tier was left to S3. A global grant is how a "global RFC
|
||||
-- Contributor" (a contributor who may propose in every collection of every
|
||||
-- project, distinct from a deployment owner/admin) is represented — see
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the
|
||||
-- C.1 "cleo" scenario.
|
||||
--
|
||||
-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the
|
||||
-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses
|
||||
-- a stable sentinel scope_id of '*' (one global tier per deployment); the
|
||||
-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per
|
||||
-- user, mirroring the project/collection rows.
|
||||
|
||||
CREATE TABLE memberships__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships;
|
||||
DROP TABLE memberships;
|
||||
ALTER TABLE memberships__new RENAME TO memberships;
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- §22.12 S6 — per-collection model universe.
|
||||
--
|
||||
-- A collection's `.collection.yaml` may carry an `enabled_models` list that
|
||||
-- NARROWS its project's universe (which narrows the deployment ENABLED_MODELS).
|
||||
-- Mirrored into a `config_json` blob on the collection row, paralleling
|
||||
-- `projects.config_json` (which already holds the project's enabled_models +
|
||||
-- theme). Additive only — no rebuild. NULL means "no per-collection narrowing;
|
||||
-- inherit the project's universe."
|
||||
ALTER TABLE collections ADD COLUMN config_json TEXT;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
--
|
||||
-- A gated project or collection is invisible to non-members (§22.5), so a user
|
||||
-- who knows a scope exists can ask to join it: they name a desired role and the
|
||||
-- request is recorded here, then fanned out to that scope's Owners *across the
|
||||
-- subtree* (a collection request reaches the collection's Owners, its project's
|
||||
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
|
||||
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
|
||||
-- the requester is §15-notified of the decision either way.
|
||||
--
|
||||
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
|
||||
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
|
||||
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
|
||||
-- project or collection a user discovers, not the deployment), and accept grants
|
||||
-- a scope role rather than minting an RFC invitation.
|
||||
--
|
||||
-- The request row is the persistent record; the inbox notification is the
|
||||
-- owner-facing actionable surface keyed back to it via `notification_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- The target scope. 'global' is intentionally excluded: the deployment is
|
||||
-- not a thing one "discovers and joins" (§22.8 names a project/collection).
|
||||
scope_type TEXT NOT NULL
|
||||
CHECK (scope_type IN ('project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The role the requester is asking for ({owner, contributor}, the §22.6
|
||||
-- unified vocabulary). The accepting Owner may grant this or a narrower role.
|
||||
requested_role TEXT NOT NULL
|
||||
CHECK (requested_role IN ('owner', 'contributor')),
|
||||
-- Optional free text — "who I am / why I want in". Bounded by the API layer.
|
||||
message TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The role actually granted on accept (may differ from requested_role if the
|
||||
-- Owner narrowed it); NULL until accepted.
|
||||
granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')),
|
||||
-- The owner-facing notification row that carries the Accept/Decline action.
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_requests_scope
|
||||
ON join_requests(scope_type, scope_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_requests_requester
|
||||
ON join_requests(requester_user_id, status);
|
||||
|
||||
-- At most one open (pending) request per (scope, requester): a second ask while
|
||||
-- one is still pending is a 409, not a duplicate row. A decided request
|
||||
-- (accepted/declined) does not block a fresh ask later.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
|
||||
ON join_requests(scope_type, scope_id, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
@@ -0,0 +1,139 @@
|
||||
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis, typ="document"):
|
||||
# §22 three-tier: a project (grouping tier) + its default collection (the
|
||||
# per-corpus type/initial_state moved down in migration 029). The default
|
||||
# collection keys by the project id so it is globally unique in tests.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid, vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
|
||||
(pid, pid, typ, vis, name),
|
||||
)
|
||||
|
||||
|
||||
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("pub", "Public", "public")
|
||||
_add_project("gat", "Gated", "gated")
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
r = client.get("/api/deployment")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Test Deployment"
|
||||
ids = {p["id"] for p in body["projects"]}
|
||||
assert "pub" in ids and "default" in ids # both public
|
||||
assert "gat" not in ids # gated, anon not a member
|
||||
assert "unl" not in ids # unlisted never enumerated
|
||||
|
||||
|
||||
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("gat", "Gated", "gated")
|
||||
assert client.get("/api/projects/gat").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = 'default'",
|
||||
('{"theme": {"accent": "#5b5bd6"}}',),
|
||||
)
|
||||
r = client.get("/api/projects/default")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == "default"
|
||||
assert body["type"] == "document"
|
||||
assert body["visibility"] == "public"
|
||||
assert body["theme"] == {"accent": "#5b5bd6"}
|
||||
|
||||
|
||||
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
assert client.get("/api/projects/unl").status_code == 200
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_even_for_superuser(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert client.get("/api/projects/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/projects/nope").status_code == 404
|
||||
|
||||
|
||||
def test_deployment_includes_default_project_id(app_with_fake_gitea):
|
||||
# §22.10 / M3-frontend guard contract: the frontend learns which project
|
||||
# is the corpus-served (default) one from the deployment payload.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_id"] == "default"
|
||||
|
||||
|
||||
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# The old per-RFC PR deep link is preserved too.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human/pr/7", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
|
||||
|
||||
|
||||
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/proposals/42", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/proposals/42"
|
||||
|
||||
|
||||
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("teamx", "Team X", "gated")
|
||||
provision_user_row(user_id=5, login="mia", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
|
||||
)
|
||||
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
|
||||
# member sees the gated project in the deployment directory
|
||||
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
||||
assert "teamx" in ids
|
||||
# member can read it directly
|
||||
r = client.get("/api/projects/teamx")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == "teamx"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""§22.4c — _upsert_cached_rfc mirrors the review fields into cached_rfcs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_upsert_writes_review_fields(app_with_fake_gitea):
|
||||
from app import cache, db, entry as entry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
e = entry_mod.Entry(
|
||||
slug="rev", title="Rev", state="active",
|
||||
unreviewed=True, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
cache._upsert_cached_rfc(e, body_sha="sha-rev")
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug = 'rev'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 1
|
||||
assert row["reviewed_at"] == "2026-06-03"
|
||||
assert row["reviewed_by"] == "ben"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot
|
||||
commits a `.collection.yaml`, and the registry mirror upserts the collections
|
||||
row (registry stays the source of truth)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def test_create_collection_commits_manifest_and_mirrors(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd", "name": "Features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["type"] == "bdd"
|
||||
|
||||
# The bot committed the manifest to the content repo's main.
|
||||
f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml"))
|
||||
assert f is not None
|
||||
assert "type: bdd" in f["content"]
|
||||
|
||||
# The registry refresh mirrored it into a collections row.
|
||||
row = db.conn().execute(
|
||||
"SELECT type, project_id, subfolder FROM collections WHERE id='features'"
|
||||
).fetchone()
|
||||
assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features")
|
||||
|
||||
# It is now navigable via the directory + scoped serve.
|
||||
items = client.get("/api/projects/default/collections").json()["items"]
|
||||
assert any(c["id"] == "features" for c in items)
|
||||
assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200
|
||||
|
||||
|
||||
def test_create_collection_requires_admin(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_anonymous_rejected(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_rejects_duplicate(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
ok = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
dup = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_create_collection_rejects_reserved_default_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "default", "type": "bdd"})
|
||||
assert r.status_code == 422
|
||||
@@ -0,0 +1,64 @@
|
||||
"""§22 S2 — collection read helpers: list_collections / get_collection /
|
||||
subfolder_of."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import collections as collections_mod, db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colhelp-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def _seed(project_id="ohm"):
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,))
|
||||
for cid, sub, vis, name in [
|
||||
("default", "", "public", "Model"),
|
||||
("features", "features", "public", "Features"),
|
||||
("secret", "secret", "unlisted", "Secret"),
|
||||
]:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
||||
"visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, "
|
||||
"'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, sub, vis, name))
|
||||
|
||||
|
||||
def test_list_collections_excludes_unlisted():
|
||||
_db()
|
||||
_seed()
|
||||
ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)]
|
||||
assert ids == ["default", "features"] # default first, then by name; 'secret' omitted
|
||||
|
||||
|
||||
def test_list_collections_include_unlisted():
|
||||
_db()
|
||||
_seed()
|
||||
ids = {c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=True)}
|
||||
assert ids == {"default", "features", "secret"}
|
||||
|
||||
|
||||
def test_get_collection_and_subfolder():
|
||||
_db()
|
||||
_seed()
|
||||
assert collections_mod.get_collection("features")["name"] == "Features"
|
||||
assert collections_mod.subfolder_of("features") == "features"
|
||||
assert collections_mod.subfolder_of("default") == ""
|
||||
assert collections_mod.get_collection("nope") is None
|
||||
@@ -0,0 +1,146 @@
|
||||
"""§22 S2 — the registry mirror reads `.collection.yaml` manifests inside each
|
||||
project's content repo and upserts a named collection per manifest. The default
|
||||
collection still flows from projects.yaml (test_registry.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db, registry
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colreg-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
# --- pure parser --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_collection_manifest_minimal():
|
||||
doc = registry.parse_collection_manifest("type: bdd\n")
|
||||
assert doc.type == "bdd"
|
||||
# §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit).
|
||||
assert doc.initial_state == "active"
|
||||
assert doc.visibility is None
|
||||
assert doc.name is None
|
||||
|
||||
|
||||
def test_parse_collection_manifest_full():
|
||||
doc = registry.parse_collection_manifest(
|
||||
"type: document\nvisibility: public\ninitial_state: active\nname: Model\n"
|
||||
)
|
||||
assert (doc.type, doc.visibility, doc.initial_state, doc.name) == (
|
||||
"document", "public", "active", "Model",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_collection_manifest_rejects_bad_type():
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: nonsense\n")
|
||||
|
||||
|
||||
def test_parse_collection_manifest_rejects_bad_visibility():
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
||||
|
||||
|
||||
# --- mirror discovery ---------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeGitea:
|
||||
"""Minimal Gitea stub: projects.yaml in the registry repo + a content repo
|
||||
whose root holds a `features/` subdir carrying a `.collection.yaml`."""
|
||||
|
||||
def __init__(self, projects_yaml: str, repo_tree: dict[str, dict[str, str]]):
|
||||
self._projects_yaml = projects_yaml
|
||||
self._repo_tree = repo_tree # {repo: {path: text}}
|
||||
|
||||
async def get_contents(self, org, repo, path, ref="main"):
|
||||
if path == "projects.yaml":
|
||||
return {"type": "file",
|
||||
"content": base64.b64encode(self._projects_yaml.encode()).decode(),
|
||||
"sha": "regsha-test"}
|
||||
text = self._repo_tree.get(repo, {}).get(path)
|
||||
if text is None:
|
||||
return None
|
||||
return {"type": "file",
|
||||
"content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"}
|
||||
|
||||
async def list_dir(self, org, repo, path, ref="main"):
|
||||
# Root listing: surface each top-level segment as a 'dir' entry.
|
||||
prefix = (path.rstrip("/") + "/") if path else ""
|
||||
dirs = set()
|
||||
for p in self._repo_tree.get(repo, {}):
|
||||
if not p.startswith(prefix):
|
||||
continue
|
||||
rest = p[len(prefix):]
|
||||
if "/" in rest:
|
||||
dirs.add(rest.split("/", 1)[0])
|
||||
return [{"type": "dir", "name": n, "path": prefix + n} for n in sorted(dirs)]
|
||||
|
||||
|
||||
_PROJECTS = (
|
||||
"deployment:\n name: Ohm\n tagline: t\n"
|
||||
"projects:\n - id: ohm\n name: Ohm\n type: document\n"
|
||||
" content_repo: ohm-rfc\n visibility: public\n"
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_registry_mirrors_named_collection():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
row = db.conn().execute(
|
||||
"SELECT type, subfolder, name, project_id, visibility FROM collections WHERE id='features'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm")
|
||||
assert row["name"] == "Features"
|
||||
# visibility inherits the project's (public) when the manifest omits it.
|
||||
assert row["visibility"] == "public"
|
||||
|
||||
|
||||
def test_refresh_registry_leaves_default_collection_intact():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
# The default collection (from projects.yaml) and the named one coexist.
|
||||
ids = {r["id"] for r in db.conn().execute("SELECT id FROM collections")}
|
||||
assert {"default", "features"} <= ids
|
||||
|
||||
|
||||
def test_refresh_registry_immutable_type_on_named_collection():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
# A later manifest that flips the type is refused (§22.4a immutable type).
|
||||
gitea._repo_tree["ohm-rfc"]["features/.collection.yaml"] = "type: document\n"
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
t = db.conn().execute("SELECT type FROM collections WHERE id='features'").fetchone()["type"]
|
||||
assert t == "bdd"
|
||||
@@ -0,0 +1,164 @@
|
||||
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
|
||||
|
||||
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
|
||||
holding entries under both the default `rfcs/` and a named collection's
|
||||
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import cache, db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
class _CorpusGitea:
|
||||
"""A content repo modelled as a flat {path: text} map, listing files under a
|
||||
directory prefix and reading them back."""
|
||||
|
||||
def __init__(self, tree: dict[str, str]):
|
||||
self._tree = tree
|
||||
|
||||
async def list_dir(self, org, repo, path, ref="main"):
|
||||
out = []
|
||||
prefix = (path.rstrip("/") + "/") if path else ""
|
||||
for p in self._tree:
|
||||
if p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
|
||||
return out
|
||||
|
||||
async def read_file(self, org, repo, path, ref="main"):
|
||||
t = self._tree.get(path)
|
||||
return (t, "sha-" + path) if t is not None else None
|
||||
|
||||
|
||||
def _entry_md(slug, title):
|
||||
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
|
||||
|
||||
|
||||
def _seed_project_with_two_collections():
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
|
||||
for cid, sub in [("default", ""), ("features", "features")]:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
||||
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
|
||||
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
|
||||
|
||||
|
||||
def test_mirror_keys_entries_by_collection():
|
||||
cfg = _db()
|
||||
_seed_project_with_two_collections()
|
||||
gitea = _CorpusGitea({
|
||||
"rfcs/a.md": _entry_md("a", "Default A"),
|
||||
"features/rfcs/b.md": _entry_md("b", "Feature B"),
|
||||
})
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea))
|
||||
got = {(r["collection_id"], r["slug"]) for r in
|
||||
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
||||
assert got == {("default", "a"), ("features", "b")}
|
||||
|
||||
|
||||
# --- collection-scoped serve + propose (full app) -----------------------------
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from test_propose_vertical import ( # noqa: E402,F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_features_collection(content_repo="meta"):
|
||||
"""Add a named 'features' collection (subfolder 'features') under the seeded
|
||||
default project, plus a single entry under features/rfcs/ in the db cache."""
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
||||
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
||||
"('features','default','document','features','super-draft','public','Features', "
|
||||
"datetime('now'), datetime('now'))")
|
||||
|
||||
|
||||
def test_scoped_list_returns_only_that_collection(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
# Seed one entry under each collection's rfcs dir + mirror them in.
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = {
|
||||
"content": _entry_md("a", "Default A"), "sha": "sa"}
|
||||
fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = {
|
||||
"content": _entry_md("b", "Feature B"), "sha": "sb"}
|
||||
from app import cache as cache_mod, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert [i["slug"] for i in r.json()["items"]] == ["b"]
|
||||
# The default collection still serves only its own entry.
|
||||
r2 = client.get("/api/projects/default/collections/default/rfcs")
|
||||
assert [i["slug"] for i in r2.json()["items"]] == ["a"]
|
||||
|
||||
|
||||
def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: an explicitly-created collection requires an explicit scope
|
||||
# grant to write (the grandfathered baseline covers only `default`).
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'features', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []})
|
||||
assert r.status_code == 200, r.text
|
||||
# The bot wrote the entry under features/rfcs/, not rfcs/.
|
||||
keys = {(k[1], k[3]) for k in fake.files
|
||||
if k[1] == "meta" and k[3].endswith("newb.md")}
|
||||
assert ("meta", "features/rfcs/newb.md") in keys
|
||||
|
||||
|
||||
def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/projects/default/collections/nope/rfcs")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
|
||||
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
|
||||
lands on its catalog → an empty catalog (200, no items), and the propose
|
||||
action is not available to them (the propose route rejects anonymous)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection() # public, no entries
|
||||
# Anonymous (no session cookie) reads the empty catalog — 200, [].
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["items"] == []
|
||||
# No propose action for an anonymous visitor.
|
||||
r2 = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
|
||||
assert r2.status_code == 401
|
||||
@@ -0,0 +1,202 @@
|
||||
"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the
|
||||
bot provisions a Gitea content repo + commits the project to `projects.yaml`, and
|
||||
the registry mirror upserts the `projects` + default `collections` rows (registry
|
||||
stays the source of truth). Plus the deployment-directory empty-state signals
|
||||
(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme", "type": "bdd",
|
||||
"visibility": "public"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == "acme"
|
||||
assert body["type"] == "bdd"
|
||||
assert body["visibility"] == "public"
|
||||
|
||||
# The bot provisioned the content repo (default name <id>-content) and
|
||||
# seeded a README so `main` exists.
|
||||
assert ("wiggleverse", "acme-content") in fake.repos
|
||||
readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md"))
|
||||
assert readme is not None and "acme" in readme["content"]
|
||||
|
||||
# The bot committed the new project into projects.yaml.
|
||||
reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml"))
|
||||
assert reg is not None
|
||||
assert "id: acme" in reg["content"]
|
||||
assert "content_repo: acme-content" in reg["content"]
|
||||
|
||||
# The registry refresh mirrored a projects row + its default collection.
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, content_repo, visibility FROM projects WHERE id='acme'"
|
||||
).fetchone()
|
||||
assert (prow["name"], prow["content_repo"], prow["visibility"]) == (
|
||||
"Acme", "acme-content", "public")
|
||||
crow = db.conn().execute(
|
||||
"SELECT type, project_id, subfolder FROM collections WHERE id='acme'"
|
||||
).fetchone()
|
||||
assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "")
|
||||
|
||||
# It is now visible in the deployment directory and readable.
|
||||
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
||||
assert "acme" in ids
|
||||
assert client.get("/api/projects/acme").status_code == 200
|
||||
|
||||
# An audit row records the structural action with the global Owner actor.
|
||||
act = db.conn().execute(
|
||||
"SELECT actor_user_id FROM actions WHERE action_kind='create_project'"
|
||||
).fetchone()
|
||||
assert act is not None and act["actor_user_id"] == 1
|
||||
|
||||
|
||||
def test_create_project_custom_content_repo_name(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "beta", "name": "Beta", "type": "document",
|
||||
"content_repo": "beta-corpus"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("wiggleverse", "beta-corpus") in fake.repos
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='beta'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "beta-corpus"
|
||||
|
||||
|
||||
def test_create_project_requires_global_owner(app_with_fake_gitea):
|
||||
# A plain deployment contributor is not a global Owner (C: + New project is a
|
||||
# global-Owner action), even though they may create collections.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "bdd"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_create_project_global_owner_grant_permitted(app_with_fake_gitea):
|
||||
# An explicit global-scope Owner grant (not a deployment owner/admin) may
|
||||
# create projects.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="gina", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 3, 'owner')"
|
||||
)
|
||||
sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina",
|
||||
role="contributor", email="gina@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "gproj", "name": "G", "type": "document"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_create_project_anonymous_rejected(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_project_rejects_duplicate(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
ok = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme", "type": "bdd"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
dup = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme 2", "type": "bdd"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_create_project_rejects_reserved_default_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "default", "name": "X", "type": "bdd"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_create_project_rejects_bad_type(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "nonsense"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------
|
||||
|
||||
|
||||
def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea):
|
||||
# C3.1: a global Owner is offered the create-project action.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["viewer"]["can_create_project"] is True
|
||||
|
||||
|
||||
def test_deployment_non_owner_no_create_capability(app_with_fake_gitea):
|
||||
# C3.2: a granted account with no roles is not offered create-project.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="vee", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["viewer"]["can_create_project"] is False
|
||||
|
||||
|
||||
def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea):
|
||||
# The seeded default project is public → readable → the N=1 redirect target
|
||||
# is valid (land-in-corpus preserved).
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_readable"] is True
|
||||
|
||||
|
||||
def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea):
|
||||
# C3.2: the only project is gated; a granted non-member sees no visible
|
||||
# projects AND default_project_readable False → the frontend renders the
|
||||
# empty directory (no 404 bounce).
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'")
|
||||
db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'")
|
||||
provision_user_row(user_id=2, login="vee", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["projects"] == []
|
||||
assert body["default_project_readable"] is False
|
||||
@@ -70,27 +70,32 @@ class _UpstreamHandler:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Returns a closure: `install(handler)` patches
|
||||
`app.docs_sessions.httpx.AsyncClient` so every constructed client
|
||||
uses the handler's transport.
|
||||
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
|
||||
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
|
||||
client uses the handler's transport.
|
||||
|
||||
NB: the upstream `app_with_fake_gitea` fixture also patches
|
||||
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
|
||||
and because `httpx` is a single shared module, that patch mutates
|
||||
the *same* `AsyncClient` attribute we're about to overwrite. We
|
||||
therefore import the unpatched class directly from the
|
||||
`httpx._client` module so our install path can construct a fresh
|
||||
real client around our MockTransport without going through the
|
||||
FakeGitea wrapper.
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
|
||||
|
||||
@@ -79,19 +79,28 @@ class _UpstreamHandler:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||
construct from the unpatched class directly to avoid the
|
||||
FakeGitea wrapper.
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import entry as entry_mod
|
||||
|
||||
|
||||
def test_parse_defaults_unreviewed_false_when_absent():
|
||||
text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n"
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_at is None
|
||||
assert e.reviewed_by is None
|
||||
|
||||
|
||||
def test_parse_reads_review_fields():
|
||||
text = (
|
||||
"---\nslug: ohm\ntitle: OHM\nstate: active\n"
|
||||
"unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n"
|
||||
)
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is True
|
||||
assert e.reviewed_at == "2026-06-03"
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_serialize_emits_review_fields_only_when_meaningful():
|
||||
e = entry_mod.Entry(slug="a", title="A", state="super-draft")
|
||||
assert "unreviewed" not in entry_mod.serialize(e)
|
||||
assert "reviewed_at" not in entry_mod.serialize(e)
|
||||
e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True)
|
||||
assert "unreviewed: true" in entry_mod.serialize(e2)
|
||||
|
||||
|
||||
def test_round_trip_preserves_review_fields():
|
||||
e = entry_mod.Entry(
|
||||
slug="b", title="B", state="active",
|
||||
unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
back = entry_mod.parse(entry_mod.serialize(e))
|
||||
assert back.reviewed_at == "2026-06-03"
|
||||
assert back.reviewed_by == "ben"
|
||||
assert back.unreviewed is False
|
||||
@@ -514,6 +514,120 @@ def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
||||
|
||||
|
||||
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
|
||||
"""§13.2 (optional number): a blank id is VALID — it means "graduate
|
||||
without a number." `can_submit` stays true (owners are set), and an
|
||||
absent `id` param behaves the same as an explicit empty string."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Explicit empty id.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["id"]["error"] is None
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# No id param at all — same default-accepted shape.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check").json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# A malformed (non-blank) id is still rejected.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
|
||||
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
|
||||
entry to `active` with `id: null`. The slug is the canonical identifier;
|
||||
the catalog shows the entry as active with no number, and the audit row
|
||||
records rfc_id null."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Blank rfc_id → graduate without a number.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["succeeded"] is True
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# Cache flipped to active with a null rfc_id.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] is None
|
||||
|
||||
# Catalog: present as active, identified by slug (no number).
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["state"] == "active"
|
||||
assert ohm["id"] is None
|
||||
|
||||
# Audit: graduate_complete with rfc_id null.
|
||||
complete = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
assert complete is not None
|
||||
assert _json.loads(complete["details"])["rfc_id"] is None
|
||||
|
||||
|
||||
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
|
||||
"""Omitting the rfc_id field entirely is treated the same as blank —
|
||||
graduates without a number — so older clients that drop the field don't
|
||||
break, and the supplied-number path stays exactly as before."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
"""§13.1: any signed-in contributor can claim ownership of an
|
||||
unclaimed super-draft; the result is a meta-repo PR
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""§22.4b — a project with initial_state='active' lands new entries active +
|
||||
unreviewed; the default 'super-draft' project is unchanged."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _propose(client):
|
||||
return client.post("/api/rfcs/propose", json={
|
||||
"title": "Active Lander", "slug": "active-lander",
|
||||
"pitch": "Lands active.", "tags": [],
|
||||
})
|
||||
|
||||
|
||||
def test_super_draft_default_unchanged(app_with_fake_gitea):
|
||||
from app import entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "super-draft"
|
||||
assert e.unreviewed is False
|
||||
|
||||
|
||||
def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
|
||||
from app import db, entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "active"
|
||||
assert e.unreviewed is True
|
||||
@@ -0,0 +1,339 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A user who knows a (gated) scope exists asks to join it, naming a desired role;
|
||||
the request is recorded and fanned out to that scope's Owners *across the
|
||||
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
|
||||
the `memberships` row via memberships.grant — or declines, and the requester is
|
||||
§15-notified either way.
|
||||
|
||||
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
|
||||
and test_s4_invitations_vertical.py (the scope/membership world-builders).
|
||||
|
||||
World: project "ohm" owns collections "model" (document, gated) and "features"
|
||||
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
|
||||
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
|
||||
(no scope role) — the would-be joiner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# World-builders (mirror the S4 vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "gated") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _join_requests(scope_type: str, scope_id: str):
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, requester_user_id, requested_role, status, granted_role "
|
||||
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
|
||||
(scope_type, scope_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
|
||||
return {
|
||||
r["recipient_user_id"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
|
||||
(event_kind,),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "gated")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
|
||||
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
|
||||
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
|
||||
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
|
||||
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
|
||||
_grant("project", "ohm", 5, "owner")
|
||||
_grant("collection", "features", 4, "owner")
|
||||
_grant("global", "*", 6, "owner")
|
||||
|
||||
|
||||
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
|
||||
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request → cross-collection fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
|
||||
"""A request to join a collection lands a row and notifies every Owner whose
|
||||
reach covers it — the collection's Owner, the project's Owner, a global
|
||||
Owner, and the deployment admin (the cross-collection inbox) — never the
|
||||
requester."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor", "message": "I work on BDD corpora."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
reqs = _join_requests("collection", "features")
|
||||
assert len(reqs) == 1
|
||||
assert reqs[0]["requester_user_id"] == 2
|
||||
assert reqs[0]["requested_role"] == "contributor"
|
||||
assert reqs[0]["status"] == "pending"
|
||||
|
||||
# Owners across the subtree are notified; ben (requester) is not.
|
||||
recips = _join_notif_recipients()
|
||||
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
|
||||
assert 2 not in recips
|
||||
|
||||
|
||||
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
|
||||
"""A project-scope request reaches the project's Owners + global Owners +
|
||||
admin, but NOT a collection-only Owner (their reach doesn't cover the
|
||||
project)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/project/ohm/join-requests",
|
||||
json={"role": "owner"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
recips = _join_notif_recipients()
|
||||
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
|
||||
assert 4 not in recips # dan is only a collection Owner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept → writes membership + notifies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
|
||||
"""The collection Owner accepts; a `memberships` row is written at the
|
||||
requested scope/role and the requester gets a join_request_accepted inbox
|
||||
row."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
# dan (collection Owner of features) accepts.
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["granted_role"] == "contributor"
|
||||
|
||||
# ben now holds the collection role and can contribute there.
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
ben = auth.SessionUser(
|
||||
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
|
||||
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
|
||||
)
|
||||
assert auth.can_contribute_in_collection(ben, "features") is True
|
||||
assert auth.can_contribute_in_collection(ben, "model") is False
|
||||
|
||||
# the row is closed; the requester is notified.
|
||||
assert _join_requests("collection", "features")[0]["status"] == "accepted"
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
|
||||
assert accepted, inbox
|
||||
assert "Features" in accepted[0]["summary"]
|
||||
|
||||
|
||||
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
|
||||
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
|
||||
the grant; the membership row carries the granted (not requested) role."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 5, "eve") # project Owner — reach covers the collection
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
|
||||
|
||||
|
||||
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _membership(2) == set()
|
||||
assert _join_requests("collection", "features")[0]["status"] == "declined"
|
||||
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gates & guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r2.status_code == 409, r2.text
|
||||
|
||||
|
||||
def test_existing_member_cannot_request(app_with_fake_gitea):
|
||||
"""dan already owns the collection — there is nothing to request (409)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 4, "dan")
|
||||
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_non_owner_cannot_accept(app_with_fake_gitea):
|
||||
"""A plain requester (or any non-Owner) is refused the accept action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
# provision a second plain account that tries to accept
|
||||
provision_user_row(user_id=12, login="mal", role="contributor")
|
||||
_login(client, 12, "mal")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
assert _membership(2) == set()
|
||||
|
||||
|
||||
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
|
||||
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "model")[0]["id"]
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_unknown_scope_404(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
assert client.post(
|
||||
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
assert client.post(
|
||||
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
# 'global' is not a join-able scope_type.
|
||||
assert client.post(
|
||||
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_join_target_reports_eligibility(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben: eligible (granted, no role).
|
||||
_login(client, 2, "ben")
|
||||
t = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t["eligible"] is True
|
||||
assert t["name"] == "Features"
|
||||
assert t["current_role"] is None
|
||||
# after requesting, already_requested flips and eligible drops.
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
t2 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t2["already_requested"] is True
|
||||
assert t2["eligible"] is False
|
||||
# dan: already a member → ineligible with current_role.
|
||||
_login(client, 4, "dan")
|
||||
t3 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t3["eligible"] is False
|
||||
assert t3["current_role"] == "owner"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _seed_unreviewed_active(fake, slug="feat"):
|
||||
"""Put an active+unreviewed entry on the meta repo main + cache."""
|
||||
from app import cache, entry as entry_mod
|
||||
body = entry_mod.serialize(entry_mod.Entry(
|
||||
slug=slug, title="Feat", state="active", unreviewed=True,
|
||||
owners=["ben"], proposed_by="ben",
|
||||
))
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1")
|
||||
|
||||
|
||||
def test_catalog_unreviewed_filter(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
from app import cache, entry as entry_mod
|
||||
ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"]))
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2")
|
||||
r = client.get("/api/rfcs", params={"unreviewed": "true"})
|
||||
slugs = {i["slug"] for i in r.json()["items"]}
|
||||
assert slugs == {"feat"}
|
||||
|
||||
|
||||
def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 200
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug='feat'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 0
|
||||
assert row["reviewed_by"] == "ben"
|
||||
assert row["reviewed_at"] # provenance stamped
|
||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
||||
from app import entry as entry_mod
|
||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||
e = entry_mod.parse(written)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Migration 027 — additive §22 M3 schema (projects.type/initial_state, the
|
||||
deployment singleton, cached_rfcs review columns). No table rebuilds in Plan A."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _fresh_config() -> Config:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="mig027-")) / "t.db"
|
||||
return Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry",
|
||||
oauth_client_id="x", oauth_client_secret="x", app_url="x",
|
||||
secret_key="x", database_path=tmp, owner_gitea_login="x",
|
||||
webhook_secret="x",
|
||||
)
|
||||
|
||||
|
||||
def test_027_adds_project_type_and_initial_state():
|
||||
# 027 added type/initial_state to `projects`; migration 029 (three-tier)
|
||||
# moved those per-corpus fields *down* onto `collections`. After the full
|
||||
# migration chain they live on the collection, not the project.
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols and "initial_state" not in proj_cols
|
||||
coll_cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert "type" in coll_cols and coll_cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in coll_cols and coll_cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_creates_deployment_singleton():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
rows = list(conn.execute("SELECT id FROM deployment"))
|
||||
assert [r["id"] for r in rows] == [1]
|
||||
try:
|
||||
conn.execute("INSERT INTO deployment (id) VALUES (2)")
|
||||
raised = False
|
||||
except Exception:
|
||||
raised = True
|
||||
assert raised
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_adds_review_columns_to_cached_rfcs():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(cached_rfcs)")}
|
||||
assert {"unreviewed", "reviewed_at", "reviewed_by"} <= cols
|
||||
conn.close()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Migration 029 — the collection grain beneath projects (§22 three-tier S1).
|
||||
|
||||
Proves: a `collections` table exists with one default collection per project
|
||||
(id='default', subfolder=repo root); the per-corpus fields (type, initial_state)
|
||||
moved off `projects`; the 13 entry-corpus tables re-key (project_id,slug) ->
|
||||
(collection_id,slug) with the composite PK/FK enforced; and project_members
|
||||
generalises into memberships(scope_type, …) with the role enum collapsed.
|
||||
Template: test_migration_028_project_scoped_keys.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def test_collections_table_exists_with_default_per_project():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert {"id", "project_id", "type", "subfolder",
|
||||
"initial_state", "visibility", "name", "registry_sha"} <= cols
|
||||
# one default collection seeded for the bootstrap 'default' project (026)
|
||||
row = conn.execute(
|
||||
"SELECT id, project_id, subfolder FROM collections WHERE project_id='default'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["id"] == "default"
|
||||
assert row["subfolder"] == "" # repo root
|
||||
|
||||
|
||||
def test_per_corpus_fields_moved_off_projects():
|
||||
conn = _fresh_db()
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols
|
||||
assert "initial_state" not in proj_cols
|
||||
# projects keeps the grouping-tier fields
|
||||
assert {"id", "name", "content_repo", "visibility"} <= proj_cols
|
||||
|
||||
|
||||
def test_entry_tables_rekeyed_to_collection_id():
|
||||
conn = _fresh_db()
|
||||
for t in ("cached_rfcs", "cached_branches", "stars", "watches",
|
||||
"rfc_collaborators", "contribution_requests", "proposed_use_cases",
|
||||
"branch_visibility", "branch_contribute_grants", "pr_seen",
|
||||
"branch_chat_seen", "funder_consents", "rfc_invitations"):
|
||||
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")}
|
||||
assert "collection_id" in cols, f"{t} missing collection_id"
|
||||
assert "project_id" not in cols, f"{t} still has project_id"
|
||||
|
||||
|
||||
def test_cached_rfcs_pk_is_collection_slug():
|
||||
conn = _fresh_db()
|
||||
# a second collection under the default project
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
|
||||
n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"]
|
||||
assert n == 2
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')")
|
||||
|
||||
|
||||
def test_cached_rfcs_collection_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('x','X','active','nope')")
|
||||
|
||||
|
||||
def test_collaborator_fk_is_composite_on_collection():
|
||||
conn = _fresh_db()
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')")
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','c2')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
# same slug, a collection with no such entry — composite FK rejects
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_collection():
|
||||
conn = _fresh_db()
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','c2')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
|
||||
|
||||
|
||||
def test_memberships_table_replaces_project_members():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")}
|
||||
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
|
||||
# project_members is gone
|
||||
assert conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
|
||||
).fetchone() is None
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (9,'x','X','contributor')")
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')")
|
||||
# scope_type and role are CHECK-constrained
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
|
||||
|
||||
|
||||
# ── regression: §22.13 satellite re-stamp repair (the OHM-data deploy fault) ──
|
||||
# Reproduces the shape that crashed the v0.46.0 deploy: the default→ohm re-stamp
|
||||
# updated cached_rfcs but left cached_branches at the stale project_id='default',
|
||||
# with (a) a stale row duplicating a freshly-stamped one, (b) a stale row with no
|
||||
# fresh counterpart, and (c) a stale row whose RFC no longer exists. 029 must
|
||||
# repair all three rather than hit NOT NULL / UNIQUE on the rebuild.
|
||||
|
||||
def _apply_through(path, ceiling):
|
||||
conn = sqlite3.connect(path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))")
|
||||
done = {r["version"] for r in conn.execute("SELECT version FROM schema_migrations")}
|
||||
for p in sorted(db.MIGRATIONS_DIR.glob("*.sql")):
|
||||
v = p.stem
|
||||
if v in done or v > ceiling:
|
||||
continue
|
||||
sql = p.read_text()
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
|
||||
return conn
|
||||
|
||||
|
||||
def test_029_repairs_stale_duplicate_and_orphan_satellite_rows():
|
||||
d = tempfile.mkdtemp()
|
||||
path = str(Path(d) / "t.db")
|
||||
conn = _apply_through(path, "028_project_scoped_keys")
|
||||
# simulate the §22.13 re-stamp having renamed the default project + its RFCs
|
||||
# to 'ohm', but NOT the satellite tables (the actual prod fault).
|
||||
conn.execute("UPDATE projects SET id='ohm' WHERE id='default'")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES ('human','Human','active','ohm')")
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','ohm')") # fresh/correct
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','default')") # stale DUP of the fresh one
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','edit-1','default')")# stale, unique -> re-stamp+keep
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('ghost','main','default')") # no live RFC -> drop
|
||||
conn.close()
|
||||
|
||||
# apply 029+ (the patched migration). Must NOT raise.
|
||||
db.run_migrations(_Cfg(path))
|
||||
conn = db.connect(path)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT rfc_slug, branch_name, collection_id FROM cached_branches"
|
||||
).fetchall()
|
||||
got = {(r["rfc_slug"], r["branch_name"]) for r in rows}
|
||||
# every surviving row mapped to a collection (the single-project 'default' one)
|
||||
assert all(r["collection_id"] is not None for r in rows)
|
||||
assert {r["collection_id"] for r in rows} == {"default"}
|
||||
# the duplicate collapsed to exactly one human/main
|
||||
assert len([r for r in rows if (r["rfc_slug"], r["branch_name"]) == ("human", "main")]) == 1
|
||||
# the unique stale row survived (re-stamped)
|
||||
assert ("human", "edit-1") in got
|
||||
# the no-RFC stale row was dropped
|
||||
assert ("ghost", "main") not in got
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Migration 030 — the global-scope grant (§22 three-tier S3).
|
||||
|
||||
Proves: `memberships.scope_type` now admits 'global' alongside 'project' and
|
||||
'collection' (§B.2's four-layer resolver), existing rows survive the rebuild,
|
||||
and the UNIQUE(scope_type, scope_id, user_id) shape is preserved.
|
||||
Template: test_migration_029_collections.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def test_global_scope_type_is_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
# global grant — the new tier — is accepted.
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1"
|
||||
).fetchone()
|
||||
assert row["scope_type"] == "global"
|
||||
assert row["scope_id"] == "*"
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
def test_project_and_collection_scopes_still_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'default', 1, 'owner')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'contributor')"
|
||||
)
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"]
|
||||
assert n == 2
|
||||
|
||||
|
||||
def test_unknown_scope_type_still_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "x")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('deployment', '*', 1, 'owner')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_global_grant_per_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'owner')"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Migration 032 — the join_requests table (§22.8 S6).
|
||||
|
||||
Proves: the table exists with its CHECK constraints (scope_type ∈
|
||||
{project,collection}; role/status enums), the one-open-per-(scope,user) partial
|
||||
unique index holds, and a decided request frees a fresh ask.
|
||||
Template: test_migration_030_global_scope.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _request(conn, scope_type="collection", scope_id="features", uid=1, role="contributor"):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, uid, role),
|
||||
)
|
||||
|
||||
|
||||
def test_join_request_row_round_trips():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
row = conn.execute("SELECT * FROM join_requests WHERE requester_user_id = 1").fetchone()
|
||||
assert row["scope_type"] == "collection"
|
||||
assert row["requested_role"] == "contributor"
|
||||
assert row["status"] == "pending"
|
||||
assert row["granted_role"] is None
|
||||
|
||||
|
||||
def test_global_scope_type_is_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, scope_type="global", scope_id="*")
|
||||
|
||||
|
||||
def test_bad_role_and_status_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, role="viewer")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role, status) "
|
||||
"VALUES ('project', 'ohm', 1, 'owner', 'maybe')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_open_request_per_scope_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn)
|
||||
|
||||
|
||||
def test_decided_request_frees_a_fresh_ask():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
conn.execute("UPDATE join_requests SET status = 'declined' WHERE requester_user_id = 1")
|
||||
# a second open ask is now allowed
|
||||
_request(conn)
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM join_requests WHERE requester_user_id = 1"
|
||||
).fetchone()["n"]
|
||||
assert n == 2
|
||||
@@ -0,0 +1,358 @@
|
||||
"""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)
|
||||
)
|
||||
|
||||
|
||||
# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
|
||||
# contributor} in the unified `memberships` table at the project's default
|
||||
# collection. The read-only `viewer` tier is deferred (folded into contributor
|
||||
# for this pass), so the legacy role names map: admin→owner, contributor and
|
||||
# viewer→contributor.
|
||||
_ROLE_MAP = {
|
||||
"project_admin": "owner",
|
||||
"project_contributor": "contributor",
|
||||
"project_viewer": "contributor",
|
||||
}
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', ?, ?, ?)",
|
||||
(cid, user_id, _ROLE_MAP[role]),
|
||||
)
|
||||
|
||||
|
||||
def _remove_member(project_id: str, user_id: int) -> None:
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
|
||||
(cid, 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,
|
||||
collection grain). The entry lands in the project's default collection
|
||||
(id == project_id for the single 'default' project under test)."""
|
||||
import json
|
||||
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
|
||||
VALUES (?, ?, ?, ?, '[]', '[]', ?)
|
||||
""",
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# §22 three-tier (§B.3): the read-only viewer tier is deferred — the
|
||||
# smallest grant is `contributor`, which grants read + discuss +
|
||||
# contribute across the subtree.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
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 True
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
|
||||
# project_admin → owner → 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_member_can_discuss_and_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
|
||||
|
||||
# §22 three-tier (§B.3): a `contributor` member can both discuss and
|
||||
# contribute (the viewer-only read tier is deferred this pass).
|
||||
_add_member("default", 2, "project_contributor")
|
||||
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 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
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Slice M1+M3 — the §22 multi-project spine.
|
||||
|
||||
Migration 026 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`.
|
||||
M3 retires the META_REPO startup backfill; content_repo now comes from the
|
||||
registry mirror (projects.yaml in REGISTRY_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,
|
||||
)
|
||||
|
||||
# §22 three-tier (migration 029): the entry-corpus grain is the collection, so
|
||||
# the 13 tables migration 028 keyed by project_id re-key to collection_id. The
|
||||
# remaining tables 026 tagged keep their denormalised project_id (project grain).
|
||||
COLLECTION_TABLES = [
|
||||
"cached_rfcs", "cached_branches", "branch_visibility",
|
||||
"branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen",
|
||||
"watches", "funder_consents", "rfc_invitations", "rfc_collaborators",
|
||||
"proposed_use_cases", "contribution_requests",
|
||||
]
|
||||
PROJECT_TAG_TABLES = [
|
||||
"cached_prs", "threads", "changes", "notifications", "actions",
|
||||
"pr_resolution_branches",
|
||||
]
|
||||
|
||||
|
||||
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"
|
||||
# M3: content_repo now comes from the registry mirror (projects.yaml),
|
||||
# not the retired META_REPO startup backfill.
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# The entry-corpus tables key on collection_id (NOT NULL, 'default').
|
||||
for table in COLLECTION_TABLES:
|
||||
cols = {r["name"]: r for r in db.conn().execute(f"PRAGMA table_info({table})")}
|
||||
assert "collection_id" in cols, f"{table} missing collection_id"
|
||||
assert "project_id" not in cols, f"{table} should no longer have project_id"
|
||||
col = cols["collection_id"]
|
||||
assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL"
|
||||
assert col["dflt_value"] == "'default'", f"{table}.collection_id default"
|
||||
# The project-tag tables keep their denormalised project_id.
|
||||
for table in PROJECT_TAG_TABLES:
|
||||
cols = {r["name"] for r in db.conn().execute(f"PRAGMA table_info({table})")}
|
||||
assert "project_id" in cols, f"{table} missing project_id tag"
|
||||
|
||||
|
||||
def test_existing_row_backfills_to_default(app_with_fake_gitea):
|
||||
"""A row inserted the old way (no collection grain) lands in the default
|
||||
collection — the trick that keeps every pre-three-tier 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 collection_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert got == "default"
|
||||
|
||||
|
||||
def test_memberships_table_shape(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# §22 three-tier: project_members generalised into memberships.
|
||||
assert db.conn().execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
|
||||
).fetchone() is None
|
||||
cols = {r["name"] for r in db.conn().execute("PRAGMA table_info(memberships)")}
|
||||
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
|
||||
db.conn().execute(
|
||||
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'owner')"
|
||||
)
|
||||
import sqlite3
|
||||
try:
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'nonsense')"
|
||||
)
|
||||
assert False, "CHECK should reject an unknown role"
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
def test_registry_mirror_is_idempotent(app_with_fake_gitea):
|
||||
"""Re-running the registry mirror is safe — it upserts (overwrites) the
|
||||
projects row from projects.yaml each time without raising. M3 retirement
|
||||
of seed_default_project: the registry mirror is now the sole authority."""
|
||||
import asyncio
|
||||
from app import db, registry as registry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
before = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
cfg = app.state.config
|
||||
gitea = app.state.gitea
|
||||
asyncio.run(registry_mod.refresh_registry(cfg, gitea))
|
||||
after = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
assert after == before
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
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"
|
||||
# M3: REGISTRY_REPO is now required — absent raises RuntimeError.
|
||||
monkeypatch.delenv("REGISTRY_REPO", raising=False)
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError, match="REGISTRY_REPO"):
|
||||
load_config()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands
|
||||
it in that project's content repo and surfaces under that project's proposals,
|
||||
isolated from the default project."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _register_ecomm(fake):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'ecomm-content', 'public')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('ecomm', 'ecomm', 'document', '', 'super-draft', 'public', 'Ecomm')"
|
||||
)
|
||||
fake._seed_repo("wiggleverse", "ecomm-content")
|
||||
|
||||
|
||||
def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_register_ecomm(fake)
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: the grandfathered implicit-public baseline covers only the N=1
|
||||
# `default` collection; a second project requires an explicit scope grant
|
||||
# to write. Grant alice contributor at the ecomm project.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'ecomm', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects/ecomm/rfcs/propose", json={
|
||||
"title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The idea PR shows under ecomm's proposals, not the default's.
|
||||
e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]}
|
||||
d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]}
|
||||
assert "cart" in e
|
||||
assert "cart" not in d
|
||||
|
||||
# It landed in ecomm's content repo, not the default 'meta' repo.
|
||||
assert ("wiggleverse", "ecomm-content") in {
|
||||
(o, rp) for (o, rp) in fake.branches if rp == "ecomm-content"
|
||||
}
|
||||
assert any(
|
||||
br.startswith("propose/cart")
|
||||
for br in fake.branches.get(("wiggleverse", "ecomm-content"), {})
|
||||
)
|
||||
|
||||
|
||||
def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
from app import db
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('secret', 'Secret', 'secret-content', 'gated')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('secret', 'secret', 'document', '', 'super-draft', 'gated', 'Secret')"
|
||||
)
|
||||
provision_user_row(user_id=4, login="bob", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/projects/secret/rfcs/propose", json={
|
||||
"title": "X", "slug": "x", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,74 @@
|
||||
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
|
||||
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
|
||||
from the default project and gated by §22.5 visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis="public"):
|
||||
# §22 three-tier: a project + its default collection (keyed by the project
|
||||
# id in tests, so default_collection_id(pid) == pid).
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid + "-content", vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, 'document', '', 'super-draft', ?, ?)",
|
||||
(pid, pid, vis, name),
|
||||
)
|
||||
|
||||
|
||||
def _add_rfc(slug, title, pid, state="active"):
|
||||
from app import db
|
||||
# entries key by the project's default collection (id == pid in these tests)
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, pid),
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
d = client.get("/api/projects/default/rfcs").json()["items"]
|
||||
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
|
||||
d_slugs = {i["slug"] for i in d}
|
||||
e_slugs = {i["slug"] for i in e}
|
||||
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
|
||||
assert {"intro", "only-ecomm"} <= e_slugs
|
||||
|
||||
|
||||
def test_entry_is_isolated_by_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
|
||||
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
|
||||
# a slug that exists only in ecomm 404s under default
|
||||
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
|
||||
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
|
||||
|
||||
|
||||
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("secret", "Secret", vis="gated")
|
||||
_add_rfc("hush", "Hush", "secret")
|
||||
assert client.get("/api/projects/secret/rfcs").status_code == 404
|
||||
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
|
||||
@@ -55,6 +55,24 @@ class FakeGitea:
|
||||
self._pr_counter = 0
|
||||
self._commit_counter = 0
|
||||
self._seed_repo("wiggleverse", "meta")
|
||||
# §22 M3: the deployment's project registry. Startup refresh_registry
|
||||
# reads projects.yaml here; the single 'default' project's content_repo
|
||||
# points back at the seeded meta repo so the corpus mirror is unchanged.
|
||||
self._seed_repo("wiggleverse", "registry")
|
||||
self.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": (
|
||||
"deployment:\n"
|
||||
" name: Test Deployment\n"
|
||||
" tagline: A test deployment\n"
|
||||
"projects:\n"
|
||||
" - id: default\n"
|
||||
" name: Test Deployment\n"
|
||||
" type: document\n"
|
||||
" content_repo: meta\n"
|
||||
" visibility: public\n"
|
||||
),
|
||||
"sha": "regsha0001",
|
||||
}
|
||||
|
||||
def _seed_repo(self, owner, repo):
|
||||
self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
|
||||
@@ -72,6 +90,28 @@ class FakeGitea:
|
||||
self._commit_counter += 1
|
||||
return f"sha{self._commit_counter:04d}"
|
||||
|
||||
def _dir_listing(self, owner, repo, ref, dirpath):
|
||||
"""Children directly under `dirpath` on (owner, repo, ref): files as
|
||||
`type: file` and immediate subdirectories as `type: dir` (the shape real
|
||||
Gitea returns for a contents listing)."""
|
||||
prefix = (dirpath.rstrip("/") + "/") if dirpath else ""
|
||||
files: dict[str, dict] = {}
|
||||
dirs: set[str] = set()
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix):
|
||||
continue
|
||||
rest = p[len(prefix):]
|
||||
if "/" in rest:
|
||||
dirs.add(rest.split("/", 1)[0])
|
||||
elif rest:
|
||||
files[p] = data
|
||||
children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)]
|
||||
children += [
|
||||
{"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]}
|
||||
for p, d in sorted(files.items())
|
||||
]
|
||||
return children
|
||||
|
||||
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
|
||||
"""Return the PR with mergeability fields filled in.
|
||||
|
||||
@@ -209,6 +249,16 @@ class FakeGitea:
|
||||
}
|
||||
return httpx.Response(201, json={"name": new})
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2:
|
||||
# the registry mirror walks the content-repo root for collection
|
||||
# subfolders, so the simulator models a root directory listing that
|
||||
# surfaces both file and `dir` children.
|
||||
m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
|
||||
if method == "GET" and m_root:
|
||||
owner, repo = m_root.groups()
|
||||
ref = request.url.params.get("ref", "main")
|
||||
return httpx.Response(200, json=self._dir_listing(owner, repo, ref, ""))
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||
if method == "GET" and m:
|
||||
@@ -224,17 +274,8 @@ class FakeGitea:
|
||||
"sha": f["sha"],
|
||||
"content": base64.b64encode(f["content"].encode()).decode(),
|
||||
})
|
||||
# Directory listing
|
||||
prefix = fpath.rstrip("/") + "/"
|
||||
children = []
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
children.append({
|
||||
"name": p.rsplit("/", 1)[-1],
|
||||
"path": p,
|
||||
"type": "file",
|
||||
"sha": data["sha"],
|
||||
})
|
||||
# Directory listing — both file and subdir children.
|
||||
children = self._dir_listing(owner, repo, ref, fpath)
|
||||
if children:
|
||||
return httpx.Response(200, json=children)
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
@@ -431,7 +472,7 @@ def tmp_env(monkeypatch):
|
||||
"GITEA_BOT_USER": "rfc-bot",
|
||||
"GITEA_BOT_TOKEN": "bot-token",
|
||||
"GITEA_ORG": "wiggleverse",
|
||||
"META_REPO": "meta",
|
||||
"REGISTRY_REPO": "registry",
|
||||
"OAUTH_CLIENT_ID": "cid",
|
||||
"OAUTH_CLIENT_SECRET": "csec",
|
||||
"APP_URL": "http://localhost:8000",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""§22.2 registry parse + apply: validation, type-immutability, upsert."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db, registry
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db():
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
VALID = """
|
||||
deployment:
|
||||
name: Open Human Model
|
||||
tagline: A model of human flourishing
|
||||
projects:
|
||||
- id: default
|
||||
name: Open Human Model
|
||||
type: document
|
||||
content_repo: meta
|
||||
visibility: public
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_valid_registry():
|
||||
doc = registry.parse_registry(VALID)
|
||||
assert doc.deployment_name == "Open Human Model"
|
||||
assert doc.deployment_tagline == "A model of human flourishing"
|
||||
assert len(doc.projects) == 1
|
||||
p = doc.projects[0]
|
||||
assert (p.id, p.type, p.content_repo, p.visibility) == ("default", "document", "meta", "public")
|
||||
assert p.initial_state == "super-draft"
|
||||
|
||||
|
||||
def test_parse_initial_state_defaults_per_type():
|
||||
doc = registry.parse_registry(
|
||||
"projects:\n - {id: a, name: A, type: bdd, content_repo: a}\n"
|
||||
)
|
||||
assert doc.projects[0].initial_state == "active" # bdd default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad,msg", [
|
||||
("projects: []\n", "at least one"),
|
||||
("projects:\n - just-a-string\n", "must be a mapping"),
|
||||
("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"),
|
||||
("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"),
|
||||
("projects:\n - {id: a, name: A, type: document}\n", "content_repo"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a, visibility: x}\n", "visibility"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a}\n - {id: a, name: B, type: document, content_repo: b}\n", "duplicate"),
|
||||
])
|
||||
def test_parse_rejects_invalid(bad, msg):
|
||||
with pytest.raises(registry.RegistryError) as e:
|
||||
registry.parse_registry(bad)
|
||||
assert msg in str(e.value)
|
||||
|
||||
|
||||
def test_apply_upserts_projects_and_deployment():
|
||||
_db()
|
||||
doc = registry.parse_registry(VALID)
|
||||
registry.apply_registry(doc, registry_sha="regsha1", default_id="default")
|
||||
# §22 three-tier: the project carries the grouping-tier fields; the
|
||||
# per-corpus type/initial_state live on its default collection.
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, content_repo, visibility, registry_sha FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["name"] == "Open Human Model"
|
||||
assert prow["content_repo"] == "meta"
|
||||
assert prow["registry_sha"] == "regsha1"
|
||||
crow = db.conn().execute(
|
||||
"SELECT type, initial_state FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
assert crow["initial_state"] == "super-draft"
|
||||
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] == "Open Human Model"
|
||||
assert drow["tagline"] == "A model of human flourishing"
|
||||
|
||||
|
||||
def test_apply_rejects_type_change_on_existing_project():
|
||||
_db()
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1", default_id="default")
|
||||
changed = VALID.replace("type: document", "type: specification")
|
||||
registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped
|
||||
# §22.4a immutable type — now enforced on the collection.
|
||||
t = db.conn().execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"]
|
||||
assert t == "document" # immutable — unchanged
|
||||
# The deployment row IS still advanced even though the type change was skipped.
|
||||
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["registry_sha"] == "s2"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Startup mirrors the registry; the registry webhook re-mirrors it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
|
||||
# §22 three-tier: type now lives on the default collection.
|
||||
crow = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] # deployment name mirrored from the registry
|
||||
|
||||
|
||||
def test_registry_webhook_remirrors(app_with_fake_gitea):
|
||||
import hashlib
|
||||
import hmac
|
||||
import json as _json
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
new_yaml = (
|
||||
"deployment:\n name: OHM\n tagline: Edited tagline\n"
|
||||
"projects:\n - id: default\n name: OHM\n type: document\n"
|
||||
" content_repo: meta\n visibility: public\n"
|
||||
)
|
||||
fake.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": new_yaml, "sha": "regsha2",
|
||||
}
|
||||
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
|
||||
secret = "test-webhook-secret-for-signature-verification"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
tagline = db.conn().execute("SELECT tagline FROM deployment WHERE id=1").fetchone()["tagline"]
|
||||
assert tagline == "Edited tagline"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
|
||||
DEFAULT_PROJECT_ID. §22 three-tier (S1): the entry-corpus tables key on
|
||||
collection_id now, so the re-stamp renames the *project grain* — the
|
||||
`collections.project_id` link and the denormalised project_id tags — while the
|
||||
entries stay in their collection. The stale 'default' projects row is dropped,
|
||||
the composite FKs stay intact, and it is idempotent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
from app import projects
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path, default_id):
|
||||
self.database_path = path
|
||||
self.default_project_id = default_id
|
||||
|
||||
|
||||
def _setup(monkeypatch, default_id="ohm"):
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, default_id)
|
||||
db.run_migrations(cfg) # seeds the bootstrap 'default' project + its default collection
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
# A registry-mirrored 'ohm' project coexists with the bootstrap pre-restamp.
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,content_repo,visibility) "
|
||||
"VALUES ('ohm','Open Human Model','ohm-content','public')")
|
||||
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||
# Entry data lives in the default collection (id='default'); the entry grain
|
||||
# is the collection and does not move on a re-stamp.
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','default')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
|
||||
"VALUES ('human',1,'contributor','default')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','default')")
|
||||
return cfg, conn
|
||||
|
||||
|
||||
def test_restamp_moves_project_grain_and_drops_bootstrap_row(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
# The project grain (the collection's parent link) re-stamps to 'ohm'.
|
||||
assert conn.execute("SELECT COUNT(*) c FROM collections WHERE project_id='default'").fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
# Entries stay in their collection — the collection_id is unchanged.
|
||||
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||
assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||
# stale bootstrap projects row removed; 'ohm' remains
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||
# FK integrity intact after the rename
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_restamp_is_idempotent(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
projects.restamp_default_project(cfg) # second call: no bootstrap rows left → no-op
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_restamp_noop_when_default_id_unchanged(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
|
||||
projects.restamp_default_project(cfg)
|
||||
# nothing renamed; the default collection still belongs to the bootstrap project
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
|
||||
|
||||
Retire is an in-place frontmatter flip on the meta entry (state →
|
||||
`retired`) committed via an auto-merged PR — the same machinery as
|
||||
graduation, reused. The distinguishing rules under test:
|
||||
|
||||
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
|
||||
holders may retire; app admins may NOT. Un-retire is site-owners-only.
|
||||
* Visibility (§13.7): a retired entry drops out of the catalog
|
||||
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
|
||||
a site owner (so the un-retire affordance has a surface).
|
||||
* Reversibility: a site owner can un-retire, restoring the prior state
|
||||
(and keeping the integer id intact); an RFC owner cannot.
|
||||
|
||||
These walk against the in-process FakeGitea from test_propose_vertical.py,
|
||||
reusing the super-draft seed + sync graduation seam from the graduation
|
||||
suite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
|
||||
|
||||
|
||||
def _catalog_slugs(client) -> set[str]:
|
||||
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
|
||||
|
||||
|
||||
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
|
||||
"""An RFC owner (frontmatter, role contributor) retires their own
|
||||
super-draft. The entry flips to `retired`, drops out of the catalog,
|
||||
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"], arbiters=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# The RFC owner is NOT a site owner → 404 on the entry read.
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Audit row records the prior state for un-retire.
|
||||
row = db.conn().execute(
|
||||
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert _json.loads(row["details"])["prior_state"] == "super-draft"
|
||||
|
||||
|
||||
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
|
||||
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
|
||||
(so the un-retire UI has a surface); an admin, a non-owner contributor,
|
||||
and an anonymous viewer all get 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Site owner: 200.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Admin (not site owner): 404.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Non-owner contributor: 404.
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
|
||||
def test_admin_cannot_retire(app_with_fake_gitea):
|
||||
"""§3.1: retire authority excludes app admins. An admin who is not an
|
||||
RFC owner gets 403 — the one lifecycle action where admin authority
|
||||
does not apply."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
|
||||
"""A signed-in contributor who is not an RFC owner gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 403
|
||||
|
||||
|
||||
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
|
||||
"""Round-trip on an active RFC: graduate (with a number) → retire →
|
||||
un-retire. The integer id survives, and un-retire restores `active`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Graduate with a number.
|
||||
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
|
||||
|
||||
# Retire (site owner).
|
||||
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# Un-retire (site owner) restores active, id intact.
|
||||
r = client.post("/api/rfcs/ohm/unretire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
|
||||
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
|
||||
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
|
||||
bring it back (the soft-delete is recoverable only by the operator)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
# Same RFC owner tries to un-retire → 403.
|
||||
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
|
||||
|
||||
|
||||
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
|
||||
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
|
||||
an admin (who lacks un-retire authority) gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Admin: 403.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/admin/retired-rfcs").status_code == 403
|
||||
|
||||
# Site owner: 200, sees ohm with its restore target.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/admin/retired-rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["restores_to"] == "super-draft"
|
||||
|
||||
|
||||
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
|
||||
"""A retired entry refuses content reads of every shape (§13.7) — the
|
||||
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# The /main branch surface no longer serves it.
|
||||
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""@S1 acceptance — the collection grain exists (invisible default) and N=1 is
|
||||
unchanged.
|
||||
|
||||
Part C scenarios C3.7 (single-collection project skips the directory) and C3.8
|
||||
(single-project deployment skips the directory) are the client-side redirect
|
||||
contract asserted in the frontend; this module asserts the backend N=1
|
||||
invariants behind the slice: every entry keys on a real collection_id, the
|
||||
shipped project-scoped serving still resolves through the default collection,
|
||||
and the legacy /rfc/<slug> URL 308-redirects through /c/<default>/.
|
||||
|
||||
Binding: docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _seed_entry(slug, title, collection_id="default", state="active"):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, collection_id),
|
||||
)
|
||||
|
||||
|
||||
def test_s1_migration_seeds_one_default_collection_for_the_default_project(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = 'default'"
|
||||
).fetchall()
|
||||
assert len(row) == 1
|
||||
assert row[0]["id"] == "default"
|
||||
|
||||
|
||||
def test_s1_entry_served_under_default_collection(app_with_fake_gitea):
|
||||
"""N=1 unchanged: an entry is keyed by collection_id under the hood and the
|
||||
shipped project-scoped serving endpoint still resolves it."""
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_entry("human", "Human")
|
||||
# the row carries a real collection grain (the default collection)
|
||||
cid = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug='human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert cid == "default"
|
||||
# project-scoped serving (collection = default) still returns it
|
||||
r = client.get("/api/projects/default/rfcs/human")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["slug"] == "human"
|
||||
# and it appears in the project catalog
|
||||
slugs = [i["slug"] for i in client.get("/api/projects/default/rfcs").json()["items"]]
|
||||
assert "human" in slugs
|
||||
|
||||
|
||||
def test_s1_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea):
|
||||
"""The shipped /rfc/<slug> now 308s through the default collection segment."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_s1_deployment_reports_single_project(app_with_fake_gitea):
|
||||
"""C3.8 precondition: the N=1 deployment reports exactly one visible project
|
||||
and its default id (the frontend uses this to skip the directory)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_id"] == "default"
|
||||
assert [p["id"] for p in body["projects"]] == ["default"]
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3).
|
||||
|
||||
The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc
|
||||
docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus
|
||||
the operator's S3 visibility requirements (a collection settable public/hidden;
|
||||
hidden = visible to project/global scope contributors but not the public; a
|
||||
collection's visibility may be set only as strict or stricter than its project).
|
||||
|
||||
The §B.2 resolver folds four layers — global → project → collection → per-entry —
|
||||
most-permissively, with no negative override. The scenarios below are exercised
|
||||
directly against the resolver/gate helpers, and the visibility ones additionally
|
||||
through the HTTP surface.
|
||||
|
||||
Background (C.1): a deployment with a project "ohm" owning collections "model"
|
||||
(document) and "features" (bdd); a second project "acme" with collection
|
||||
"specs". Plus a hidden ("gated") collection "secret" under ohm for the
|
||||
hidden-from-public scenarios.
|
||||
"""
|
||||
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 = "contributor", *, state: str = "granted"):
|
||||
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 _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "public", subfolder: str | None = None) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
||||
visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
"""The C.1 background plus a hidden collection and a second project."""
|
||||
_project("ohm", "public")
|
||||
_collection("ohm", "ohm", subfolder="") # ohm's structural default
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
_collection("secret", "ohm", visibility="gated") # hidden from public
|
||||
_project("acme", "public")
|
||||
_collection("specs", "acme", ctype="specification")
|
||||
# the cast
|
||||
for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"),
|
||||
(5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]:
|
||||
provision_user_row(user_id=uid, login=login, role="contributor")
|
||||
_grant("collection", "model", 1, "contributor") # ada
|
||||
_grant("project", "ohm", 2, "contributor") # ben
|
||||
_grant("global", "*", 3, "contributor") # cleo
|
||||
_grant("collection", "features", 4, "owner") # dan
|
||||
_grant("project", "ohm", 5, "owner") # eve
|
||||
_grant("collection", "model", 6, "contributor") # fay (+ project owner below)
|
||||
_grant("project", "ohm", 6, "owner") # fay
|
||||
_grant("project", "ohm", 7, "contributor") # gil
|
||||
# hana (8): no grant.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.1 — role usage: inheritance and the most-permissive union
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
ada = _su(1, "ada")
|
||||
# may submit a new entry in ohm/model
|
||||
assert auth.can_contribute_in_collection(ada, "model") is True
|
||||
# ohm/features is read-only and propose is not offered
|
||||
assert auth.can_read_collection(ada, "features") is True
|
||||
assert auth.can_contribute_in_collection(ada, "features") is False
|
||||
|
||||
|
||||
def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
ben = _su(2, "ben")
|
||||
assert auth.can_contribute_in_collection(ben, "model") is True
|
||||
assert auth.can_contribute_in_collection(ben, "features") is True
|
||||
# a collection added later is writable with no new grant
|
||||
_collection("roadmap", "ohm", ctype="document")
|
||||
assert auth.can_contribute_in_collection(ben, "roadmap") is True
|
||||
|
||||
|
||||
def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
cleo = _su(3, "cleo")
|
||||
assert auth.can_contribute_in_collection(cleo, "model") is True
|
||||
assert auth.can_contribute_in_collection(cleo, "specs") is True # acme
|
||||
|
||||
|
||||
def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
dan = _su(4, "dan")
|
||||
# graduate / mark-reviewed / manage membership in ohm/features
|
||||
assert auth.is_collection_superuser(dan, "features") is True
|
||||
# but not change ohm project settings
|
||||
assert auth.is_project_superuser(dan, "ohm") is False
|
||||
assert auth.can_create_collection(dan, "ohm") is False
|
||||
# and not act on entries in ohm/model
|
||||
assert auth.is_collection_superuser(dan, "model") is False
|
||||
assert auth.can_contribute_in_collection(dan, "model") is False
|
||||
|
||||
|
||||
def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
eve = _su(5, "eve")
|
||||
assert auth.is_collection_superuser(eve, "model") is True
|
||||
assert auth.is_collection_superuser(eve, "features") is True
|
||||
assert auth.is_project_superuser(eve, "ohm") is True # edit project settings
|
||||
assert auth.can_create_collection(eve, "ohm") is True # create a new collection
|
||||
|
||||
|
||||
def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
fay = _su(6, "fay")
|
||||
# collection RFC Contributor at model + project Owner at ohm → acts as Owner in model
|
||||
assert auth.effective_scope_role(fay, "model") == "owner"
|
||||
assert auth.is_collection_superuser(fay, "model") is True
|
||||
|
||||
|
||||
def test_c1_7_no_negative_override(app_with_fake_gitea):
|
||||
from app import auth, db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
gil = _su(7, "gil")
|
||||
# gil can propose in ohm/model via the project grant…
|
||||
assert auth.can_contribute_in_collection(gil, "model") is True
|
||||
# …and there is no collection-scope row to remove at model while keeping
|
||||
# the project grant (a child cannot subtract a parent grant).
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'"
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
hana = _su(8, "hana")
|
||||
# may read public collections
|
||||
assert auth.can_read_collection(hana, "model") is True
|
||||
# but is not offered the propose action anywhere (no scope role; the
|
||||
# grandfathered baseline covers only the N=1 `default` collection)
|
||||
assert auth.can_contribute_in_collection(hana, "model") is False
|
||||
assert auth.can_contribute_in_collection(hana, "features") is False
|
||||
assert auth.can_contribute_in_collection(hana, "specs") is False
|
||||
# gated (hidden) collections do not appear for her
|
||||
assert auth.can_read_collection(hana, "secret") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection-grain visibility — the operator's S3 requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea):
|
||||
"""A gated collection is omitted from the directory and 404s on read for the
|
||||
public, yet is listed + readable for a scope-role contributor."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# anonymous: the gated 'secret' collection is not listed, and 404s.
|
||||
listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" not in listed
|
||||
assert "model" in listed # public ones still listed
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
||||
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404
|
||||
|
||||
# ben (project contributor) sees and reads it.
|
||||
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
||||
listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" in listed2
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 200
|
||||
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200
|
||||
|
||||
# hana (granted, no role) is back to the public view.
|
||||
sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor")
|
||||
listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" not in listed3
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
||||
|
||||
|
||||
def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea):
|
||||
"""A collection may be created only as strict or stricter than its project;
|
||||
a looser request is refused (422). On a gated project, a 'public' collection
|
||||
is rejected."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_project("locked", "gated")
|
||||
_collection("locked", "locked", subfolder="", visibility="gated")
|
||||
# eve is a deployment owner here to clear the create-authority gate;
|
||||
# the strictness check fires regardless.
|
||||
provision_user_row(user_id=9, login="root", role="owner")
|
||||
sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner")
|
||||
r = client.post("/api/projects/locked/collections", json={
|
||||
"collection_id": "wideopen", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "looser" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea):
|
||||
"""§B.1: a project-scope Owner may create a collection; a plain granted
|
||||
contributor with no project/global grant may not (403)."""
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# gil is only a project *contributor* on ohm — per §B.1 a project-scope
|
||||
# contributor CAN create collections (the project-level create
|
||||
# affordance). A collection-scope grant cannot.
|
||||
sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor")
|
||||
r_ok = client.post("/api/projects/ohm/collections", json={
|
||||
"collection_id": "fromgil", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate
|
||||
|
||||
# ada holds only a *collection*-scope grant (at model) — no create right.
|
||||
sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor")
|
||||
r_no = client.post("/api/projects/ohm/collections", json={
|
||||
"collection_id": "fromada", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r_no.status_code == 403, r_no.text
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Slice S4 — invitation surfaces + role-aware empty states (@S4).
|
||||
|
||||
The acceptance gate for S4 is "every Part C.2 invitation scenario passes" (the
|
||||
design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.2,
|
||||
tagged @S4), plus the capability flags that drive the C.3 (@S4) role-aware
|
||||
empty states.
|
||||
|
||||
An Owner grants {owner, contributor} at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account looked up by
|
||||
email. The grant writes a `memberships` row immediately and §15-notifies the
|
||||
grantee (no accept round-trip). Reach is bounded by the inviter's Owner reach;
|
||||
re-granting at a broader scope supersedes the narrower row; a `pending`
|
||||
deployment account's grant is recorded but confers no write.
|
||||
|
||||
Background (C.2): project "ohm" owns collections "model" (document) and
|
||||
"features" (bdd). eve is project Owner of ohm; dan is collection Owner of
|
||||
features only; ben is a project Contributor of ohm. ivy / jo / jet are
|
||||
grantees; kim is a pending deployment account.
|
||||
"""
|
||||
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 (mirror the S3 vertical's world-builders)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
|
||||
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 _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "public", subfolder: str | None = None) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
||||
visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
"""The set of (scope_type, scope_id, role) rows a user holds."""
|
||||
from app import db
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "public")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
# the cast
|
||||
provision_user_row(user_id=2, login="ben", role="contributor")
|
||||
provision_user_row(user_id=4, login="dan", role="contributor")
|
||||
provision_user_row(user_id=5, login="eve", role="contributor")
|
||||
provision_user_row(user_id=10, login="ivy", role="contributor")
|
||||
provision_user_row(user_id=11, login="jo", role="contributor")
|
||||
provision_user_row(user_id=12, login="jet", role="contributor")
|
||||
provision_user_row(user_id=13, login="kim", role="contributor")
|
||||
_grant("project", "ohm", 5, "owner") # eve — project Owner
|
||||
_grant("collection", "features", 4, "owner") # dan — collection Owner only
|
||||
_grant("project", "ohm", 2, "contributor") # ben — project Contributor
|
||||
# kim is a pending deployment account.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE users SET permission_state = 'pending' WHERE id = 13"
|
||||
)
|
||||
|
||||
|
||||
def _login_eve(client) -> None:
|
||||
sign_in_as(client, user_id=5, gitea_login="eve", display_name="Eve", role="contributor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.2 — invitation: who may invite whom, at which scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c2_1_project_owner_invites_at_project_scope(app_with_fake_gitea):
|
||||
"""A project Owner grants at project scope; the grant covers every
|
||||
collection, and the grantee is §15-notified naming the project and role."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
# a membership row is written at scope project "ohm"
|
||||
assert ("project", "ohm", "contributor") in _membership(10)
|
||||
# ivy may propose in every collection of "ohm"
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "model") is True
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
# ivy receives a §15 notification naming the project and role
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
granted = [n for n in inbox if n["event_kind"] == "scope_role_granted"]
|
||||
assert granted, inbox
|
||||
assert "Ohm" in granted[0]["summary"]
|
||||
assert "RFC Contributor" in granted[0]["summary"]
|
||||
|
||||
|
||||
def test_c2_2_owner_invites_at_specific_collection(app_with_fake_gitea):
|
||||
"""A grant at a single collection scope reaches that collection only."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jo@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(11)
|
||||
jo = _su(11, "jo")
|
||||
assert auth.can_contribute_in_collection(jo, "features") is True
|
||||
assert auth.can_contribute_in_collection(jo, "model") is False
|
||||
|
||||
|
||||
def test_c2_3_invitation_reach_bounded_by_inviter_scope(app_with_fake_gitea):
|
||||
"""A collection Owner may invite within that collection, but is not offered
|
||||
(is refused) the control to invite at the project or globally."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan is Owner at collection "features" only.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
# may grant at his collection
|
||||
ok = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
# but not at the project scope
|
||||
no = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no.status_code == 403, no.text
|
||||
# the capability flags the UI reads agree: no project invite, yes collection
|
||||
proj = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert proj["can_invite"] is False
|
||||
col = client.get("/api/projects/ohm/collections/features").json()["viewer"]
|
||||
assert col["can_invite"] is True
|
||||
|
||||
|
||||
def test_c2_4_contributors_do_not_manage_membership(app_with_fake_gitea):
|
||||
"""An RFC Contributor (project- or collection-scoped) holds no invite
|
||||
capability and the grant endpoints refuse them."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben is a project *Contributor* on ohm.
|
||||
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
||||
no_proj = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no_proj.status_code == 403, no_proj.text
|
||||
no_col = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert no_col.status_code == 403, no_col.text
|
||||
# no invite control surfaced anywhere
|
||||
assert client.get("/api/projects/ohm/collections").json()["viewer"]["can_invite"] is False
|
||||
assert client.get("/api/projects/ohm/collections/features").json()["viewer"]["can_invite"] is False
|
||||
|
||||
|
||||
def test_c2_5_no_grant_at_parent_revoke_at_child_option(app_with_fake_gitea):
|
||||
"""The grant surface offers only role + scope (project or one collection);
|
||||
there is no way to grant at the project yet carve out a child collection —
|
||||
a project grant reaches every collection, full stop."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
# the only knobs are role and an optional single collection_id; an
|
||||
# "exclude" field has no effect (it is not part of the contract).
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"exclude_collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
# the project grant still reaches the supposedly-excluded collection
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
|
||||
|
||||
def test_c2_6_broader_scope_supersedes_narrower(app_with_fake_gitea):
|
||||
"""Re-granting at a broader scope removes the subsumed narrower row; the
|
||||
grantee holds the role across the whole project."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "contributor") # jet starts narrow
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
# the project grant is present…
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# …and the redundant collection-scope row is gone (subsumed)
|
||||
assert ("collection", "features", "contributor") not in rows
|
||||
jet = _su(12, "jet")
|
||||
assert auth.can_contribute_in_collection(jet, "model") is True
|
||||
assert auth.can_contribute_in_collection(jet, "features") is True
|
||||
|
||||
|
||||
def test_c2_6b_narrower_stronger_role_is_not_subtracted(app_with_fake_gitea):
|
||||
"""No negative override: a child Owner grant survives a parent Contributor
|
||||
grant (the stronger collection role is kept)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "owner") # jet is collection Owner
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# the stronger collection-Owner row is NOT pruned by a weaker project grant
|
||||
assert ("collection", "features", "owner") in rows
|
||||
|
||||
|
||||
def test_c2_7_pending_account_grant_confers_no_write(app_with_fake_gitea):
|
||||
"""A grant to a pending deployment account is recorded but confers no write
|
||||
until the account is granted at the deployment (§6)."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "kim@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["pending"] is True
|
||||
# the grant row is recorded…
|
||||
assert ("project", "ohm", "contributor") in _membership(13)
|
||||
# …but confers no write while pending (the §6 admission floor)
|
||||
kim = _su(13, "kim", state="pending")
|
||||
assert auth.effective_scope_role(kim, "model") is None
|
||||
assert auth.can_contribute_in_collection(kim, "model") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.3 (@S4) — the capability flags behind the role-aware empty states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c3_3_project_owner_sees_create_first_collection_capability(app_with_fake_gitea):
|
||||
"""C3.3: a project Owner landing on an empty project may create a
|
||||
collection — the flag the 'Create your first collection' CTA reads."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is True
|
||||
assert caps["role"] == "owner"
|
||||
|
||||
|
||||
def test_c3_4_contributor_without_create_rights_has_no_create_capability(app_with_fake_gitea):
|
||||
"""C3.4: a contributor whose only grant is at a collection elsewhere has no
|
||||
create-collection capability — the empty directory shows no create action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan holds only a collection-scope grant (features); no project create right.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is False
|
||||
|
||||
|
||||
def test_c3_5_collection_contributor_sees_propose_first_capability(app_with_fake_gitea):
|
||||
"""C3.5: a collection contributor landing on an empty collection may propose
|
||||
— the flag the 'Propose the first entry' CTA reads; an anonymous reader may
|
||||
not (the sign-in prompt path, already shipped in S2)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "model", 10, "contributor") # ivy contributes in model
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps["can_contribute"] is True
|
||||
# anonymous reader: no propose capability
|
||||
client.cookies.clear()
|
||||
caps_anon = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps_anon["can_contribute"] is False
|
||||
@@ -0,0 +1,141 @@
|
||||
"""§22.12 S6 — per-collection model universe.
|
||||
|
||||
A collection's `.collection.yaml` may carry an `enabled_models` list that
|
||||
NARROWS its project's universe, which in turn narrows the deployment
|
||||
ENABLED_MODELS. The resolution chain (extending §6.6/§6.7) is:
|
||||
|
||||
funder ∩ per-entry models ∩ collection universe ∩ project universe
|
||||
(operator providers = the ceiling)
|
||||
|
||||
These tests prove (1) the manifest parser reads `enabled_models`, (2) the
|
||||
mirror stores it on the collection row, (3) the resolver narrows the base
|
||||
universe by project then collection, with absent = inherit and [] = opt-out,
|
||||
and (4) the collection API surfaces the collection's own narrowing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db, models_resolver, registry
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
|
||||
|
||||
|
||||
def _install_two_providers(app) -> None:
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
|
||||
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
|
||||
|
||||
|
||||
def _set_project_models(project_id: str, models) -> None:
|
||||
cfg = {} if models is None else {"enabled_models": models}
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = ?",
|
||||
(json.dumps(cfg), project_id),
|
||||
)
|
||||
|
||||
|
||||
def _set_collection_models(collection_id: str, models) -> None:
|
||||
cfg = None if models is None else json.dumps({"enabled_models": models})
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = ?",
|
||||
(cfg, collection_id),
|
||||
)
|
||||
|
||||
|
||||
# ── manifest parsing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manifest_parses_enabled_models_into_config():
|
||||
ce = registry.parse_collection_manifest(
|
||||
"type: bdd\nname: Features\nenabled_models: [claude, gemini]\n"
|
||||
)
|
||||
assert ce.config.get("enabled_models") == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_manifest_without_enabled_models_has_no_key():
|
||||
ce = registry.parse_collection_manifest("type: document\nname: Model\n")
|
||||
assert "enabled_models" not in ce.config
|
||||
|
||||
|
||||
def test_manifest_rejects_non_list_enabled_models():
|
||||
import pytest
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n")
|
||||
|
||||
|
||||
# ── resolver narrowing (the §22.12 chain) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# No project/collection narrowing → full operator universe.
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_resolver_narrows_by_project_universe(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_project_models("default", ["gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
def test_resolver_collection_narrows_within_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project allows both; the collection narrows to claude only.
|
||||
_set_project_models("default", ["claude", "gemini"])
|
||||
_set_collection_models("default", ["claude"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude"]
|
||||
|
||||
|
||||
def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_collection_models("default", []) # opt this collection out of AI
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == []
|
||||
|
||||
|
||||
def test_resolver_collection_cannot_widen_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project restricts to gemini; the collection naming claude+gemini
|
||||
# cannot re-add claude (narrowing only).
|
||||
_set_project_models("default", ["gemini"])
|
||||
_set_collection_models("default", ["claude", "gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
# ── API surfacing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_collection_api_surfaces_enabled_models(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
_set_collection_models("default", ["claude"])
|
||||
r = client.get("/api/projects/default/collections/default")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("enabled_models") == ["claude"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""§22.4a S6 — the type-driven entry noun.
|
||||
|
||||
The displayed noun for an entry is a framework concept keyed on the
|
||||
collection's immutable type: document→"RFC", specification→"Spec", bdd→"Feature".
|
||||
The chrome reads it from the API rather than hardcoding "RFC", so the propose
|
||||
CTA + entry chrome name entries correctly per collection type with no
|
||||
per-deployment config.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import collections, db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_entry_noun_map():
|
||||
assert collections.entry_noun("document") == "RFC"
|
||||
assert collections.entry_noun("specification") == "Spec"
|
||||
assert collections.entry_noun("bdd") == "Feature"
|
||||
# An unknown/future type falls back to the generic noun, never label-less.
|
||||
assert collections.entry_noun("mystery") == "RFC"
|
||||
|
||||
|
||||
def test_collection_api_surfaces_entry_noun(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
# Flip the default collection to a bdd type and confirm the noun follows.
|
||||
db.conn().execute("UPDATE collections SET type='bdd' WHERE id='default'")
|
||||
r = client.get("/api/projects/default/collections/default")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["entry_noun"] == "Feature"
|
||||
|
||||
|
||||
def test_directory_items_carry_entry_noun(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET visibility='public' WHERE id='default'")
|
||||
db.conn().execute("UPDATE collections SET type='specification' WHERE id='default'")
|
||||
r = client.get("/api/deployment")
|
||||
assert r.status_code == 200, r.text
|
||||
item = next(p for p in r.json()["projects"] if p["id"] == "default")
|
||||
assert item["entry_noun"] == "Spec"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""§22 S6 — two-project / multi-collection integrative pass.
|
||||
|
||||
Proves the S6 surface holds across a deployment with two projects, each owning
|
||||
distinct collections, with the new per-collection knobs (§22.12 enabled_models,
|
||||
§22.4a type noun) resolving independently per collection — no cross-project or
|
||||
cross-collection bleed.
|
||||
|
||||
Slug note: the resolver is slug-keyed, so this test uses distinct slugs across
|
||||
collections (the documented limitation — same-slug-across-collections model
|
||||
resolution is part of the broader collection-id-threading follow-up).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import collections, db, models_resolver
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
from test_rfc_view_vertical import FakeProvider # noqa: F401
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "public") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, config_json, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), f"{pid}-content", visibility, None),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str, enabled_models=None) -> None:
|
||||
cfg = None if enabled_models is None else json.dumps({"enabled_models": enabled_models})
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, config_json, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', 'public', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, cid, cid.capitalize(), cfg),
|
||||
)
|
||||
|
||||
|
||||
def _entry(slug: str, collection_id: str) -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, collection_id) "
|
||||
"VALUES (?, ?, 'active', ?)",
|
||||
(slug, slug.upper(), collection_id),
|
||||
)
|
||||
|
||||
|
||||
def _three_providers(app) -> None:
|
||||
app.state.providers.clear()
|
||||
for k in ("claude", "gemini", "gpt"):
|
||||
app.state.providers[k] = FakeProvider("TITLE: A\nDESCRIPTION: B")
|
||||
|
||||
|
||||
def test_two_projects_multicollection_model_universe_isolated(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_three_providers(app)
|
||||
# alpha: a document collection narrowed to claude.
|
||||
_project("alpha")
|
||||
_collection("alpha-docs", "alpha", ctype="document", enabled_models=["claude"])
|
||||
_entry("alpha-intro", "alpha-docs")
|
||||
# beta: two collections — a spec narrowed to gemini, a bdd left open.
|
||||
_project("beta")
|
||||
_collection("beta-specs", "beta", ctype="specification", enabled_models=["gemini"])
|
||||
_collection("beta-features", "beta", ctype="bdd") # no narrowing → all three
|
||||
_entry("beta-runtime", "beta-specs")
|
||||
_entry("beta-login", "beta-features")
|
||||
|
||||
# Each entry resolves against ITS collection's universe — no bleed.
|
||||
assert models_resolver.resolve_models_for_rfc("alpha-intro", app.state.providers) == ["claude"]
|
||||
assert models_resolver.resolve_models_for_rfc("beta-runtime", app.state.providers) == ["gemini"]
|
||||
assert models_resolver.resolve_models_for_rfc("beta-login", app.state.providers) == [
|
||||
"claude", "gemini", "gpt"
|
||||
]
|
||||
|
||||
|
||||
def test_two_projects_multicollection_type_noun_isolated(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_project("alpha")
|
||||
_collection("alpha-docs", "alpha", ctype="document")
|
||||
_project("beta")
|
||||
_collection("beta-specs", "beta", ctype="specification")
|
||||
_collection("beta-features", "beta", ctype="bdd")
|
||||
|
||||
assert collections.get_collection("alpha-docs")["entry_noun"] == "RFC"
|
||||
assert collections.get_collection("beta-specs")["entry_noun"] == "Spec"
|
||||
assert collections.get_collection("beta-features")["entry_noun"] == "Feature"
|
||||
@@ -73,6 +73,7 @@ def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("REGISTRY_REPO", "registry")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
@@ -92,6 +93,7 @@ def test_config_loads_with_secret_set(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("REGISTRY_REPO", "registry")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
|
||||
@@ -20,15 +20,24 @@ The v1 build is complete (8 slices shipped, 125 passing integration tests). New
|
||||
|
||||
The RFC app runs on its own dedicated GCP VM in a separate project from the Gitea VM. The two coexist under `wiggleverse.org` but are otherwise unrelated infrastructure.
|
||||
|
||||
> **⚠️ Infrastructure was realigned (GCP name-alignment, ~2026-05).** The
|
||||
> GCP project, VM, install path, system user, and systemd unit were all
|
||||
> renamed, the static IP changed, and SSH is now **IAP-only** (direct
|
||||
> port-22 connections time out). The tables below are the current truth;
|
||||
> if you find an older clone of this doc naming `wiggleverse-rfc` /
|
||||
> `rfc-app` / `/opt/rfc-app` / `34.132.29.41`, it predates the alignment.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| GCP project | `wiggleverse-rfc` |
|
||||
| VM name | `rfc-app` |
|
||||
| GCP project | `wiggleverse-ohm` |
|
||||
| VM name | `ohm-rfc-app` |
|
||||
| VM type | e2-small |
|
||||
| Zone | us-central1-a |
|
||||
| OS | Debian 12 (bookworm) |
|
||||
| Static IP | 34.132.29.41 |
|
||||
| Linux user (OS Login) | `benstull` |
|
||||
| External IP | `136.116.40.66` |
|
||||
| SSH | **IAP-only** — `gcloud compute ssh … --tunnel-through-iap` (port 22 is firewalled off the public internet) |
|
||||
| OS Login SSH user | `ben_wiggleverse_org` (auto-derived; you don't type it) |
|
||||
| App system user | `ohm-rfc-app` |
|
||||
|
||||
For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.55.46.221.
|
||||
|
||||
@@ -36,7 +45,7 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
|
||||
|
||||
| Record | Type | Value | Proxy |
|
||||
|--------|------|-------|-------|
|
||||
| `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
|
||||
| `ohm.wiggleverse.org` | A | `136.116.40.66` | DNS-only (gray cloud) |
|
||||
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
|
||||
|
||||
> Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
|
||||
@@ -48,24 +57,25 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
|
||||
| Component | Details |
|
||||
|-----------|---------|
|
||||
| Backend | Python 3.11, FastAPI, uvicorn (single process) |
|
||||
| Database | SQLite in WAL mode at `/opt/rfc-app/backend/data/rfc-app.db` |
|
||||
| Database | SQLite in WAL mode at `/opt/ohm-rfc-app/backend/data/rfc-app.db` |
|
||||
| Frontend | React 19, Vite 8, Tiptap 3, React Router 7 |
|
||||
| Web server | nginx — serves `frontend/dist/` as static SPA, proxies `/api/` and `/auth/` to uvicorn on `127.0.0.1:8000` |
|
||||
| Process manager | systemd unit `rfc-app.service`, runs as `rfc-app` system user |
|
||||
| Process manager | systemd unit `ohm-rfc-app.service`, runs as `ohm-rfc-app` system user |
|
||||
| TLS | Let's Encrypt via certbot |
|
||||
| Git backend | Gitea at `git.wiggleverse.org`, bot service account `rfc-bot` |
|
||||
| Content Gitea (the bot's writes) | Gitea at `git.wiggleverse.org`, org `wiggleverse`, meta repo `ohm-content` (`wiggleverse/ohm-content`), bot service account `rfc-bot` |
|
||||
| Code-deploy source (the VM's git origin) | **`https://git.benstull.org/benstull/rfc-app.git`** — a *different* Gitea from the content one. See the two-remote note under "Deploying a New Version." |
|
||||
| Email | Google Workspace SMTP relay (`smtp-relay.gmail.com:587`), AUTH'd as `ben@wiggleverse.org`, From `notifications@wiggleverse.org` |
|
||||
|
||||
### Key Paths on the VM
|
||||
|
||||
| Path | Contents |
|
||||
|------|---------|
|
||||
| `/opt/rfc-app/` | App root (owned by `rfc-app` user) |
|
||||
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
|
||||
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
|
||||
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
|
||||
| `/opt/ohm-rfc-app/` | App root (owned by `ohm-rfc-app` user) |
|
||||
| `/opt/ohm-rfc-app/backend/.env` | All secrets and config (mode 0600) |
|
||||
| `/opt/ohm-rfc-app/backend/data/rfc-app.db` | SQLite database |
|
||||
| `/opt/ohm-rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
|
||||
| `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
|
||||
| `/etc/systemd/system/rfc-app.service` | systemd unit |
|
||||
| `/etc/systemd/system/ohm-rfc-app.service` | systemd unit |
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +101,7 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
|
||||
|
||||
### Meta repo
|
||||
|
||||
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
|
||||
`wiggleverse/ohm-content` (the `META_REPO` value is `ohm-content`) — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
|
||||
|
||||
### OAuth2 app
|
||||
|
||||
@@ -167,23 +177,52 @@ HYGIENE_TICK_SECONDS=3600
|
||||
|
||||
## Deploying a New Version
|
||||
|
||||
SSH into the VM:
|
||||
> **⚠️ Two-remote step — do this FIRST.** The framework's release flow
|
||||
> (branches, PRs, version tags) happens on **`git.wiggleverse.org`**
|
||||
> (`ben.stull/rfc-app`). But the VM's git origin is a *separate* Gitea,
|
||||
> **`git.benstull.org/benstull/rfc-app`**, which does **not** auto-mirror
|
||||
> from the release Gitea. So after a release is merged + tagged on
|
||||
> `git.wiggleverse.org`, you must push `main` and the new tag to the
|
||||
> `benstull` remote before the VM can fetch them:
|
||||
> ```bash
|
||||
> # from your local rfc-app clone, on main at the merged release tip:
|
||||
> git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
|
||||
> ```
|
||||
> If `git ls-remote benstull vX.Y.Z` comes back empty, the VM cannot see
|
||||
> the release yet — push it first. (Wiring the two Gitea instances to
|
||||
> mirror would remove this step; until then it's manual.)
|
||||
|
||||
SSH into the VM (IAP-only — the `--tunnel-through-iap` flag is required):
|
||||
```bash
|
||||
gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc
|
||||
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
|
||||
```
|
||||
|
||||
Pull the latest code, reinstall deps, restart:
|
||||
Fetch + check out the release tag (the VM tracks a **detached** tag, not
|
||||
a branch), then restart. Backend deps only need reinstalling when
|
||||
`requirements.txt` changed:
|
||||
```bash
|
||||
sudo -u rfc-app git -C /opt/rfc-app pull
|
||||
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/rfc-app/backend/requirements.txt
|
||||
sudo systemctl restart rfc-app
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
|
||||
# only if backend deps changed:
|
||||
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/ohm-rfc-app/backend/requirements.txt
|
||||
sudo systemctl restart ohm-rfc-app
|
||||
```
|
||||
|
||||
For frontend changes, build on the VM directly (Node 20+ is already there):
|
||||
```bash
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
|
||||
sudo -u rfc-app npm run build
|
||||
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci
|
||||
sudo -u ohm-rfc-app npm run build
|
||||
```
|
||||
|
||||
Smoke test (asset hashes change on every rebuild, so a match proves the
|
||||
new bundle is live):
|
||||
```bash
|
||||
# public:
|
||||
curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' https://ohm.wiggleverse.org/
|
||||
curl -s https://ohm.wiggleverse.org/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.(js|css)'
|
||||
# backend startup line, on the VM:
|
||||
sudo journalctl -u ohm-rfc-app -n 20 --no-pager | grep 'RFC app started'
|
||||
```
|
||||
|
||||
`npm ci` installs strictly from the committed `package-lock.json` and
|
||||
@@ -192,7 +231,7 @@ to rewrite the lockfile in place (notably stripping `libc` fields on
|
||||
optional rollup native packages), which then conflicts with `git
|
||||
checkout <tag>` on the next deploy.
|
||||
|
||||
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
|
||||
The output lands in `/opt/ohm-rfc-app/frontend/dist/` owned by `ohm-rfc-app` — nginx serves it directly, no copy step needed.
|
||||
|
||||
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
|
||||
|
||||
@@ -202,6 +241,15 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
|
||||
|
||||
## First-Time Deployment (new server)
|
||||
|
||||
> **Note on names:** the command blocks in this section predate the GCP
|
||||
> name-alignment and still spell the pre-alignment project/VM/path/user
|
||||
> (`wiggleverse-rfc` / `rfc-app` / `/opt/rfc-app` / `rfc-app` /
|
||||
> `34.132.29.41`). They're kept as the structural reference. When standing
|
||||
> up a box today, substitute the current values from the Host/Paths tables
|
||||
> at the top: project `wiggleverse-ohm`, VM `ohm-rfc-app`, install path
|
||||
> `/opt/ohm-rfc-app`, system user `ohm-rfc-app`, service `ohm-rfc-app`, IP
|
||||
> `136.116.40.66`, and SSH via `--tunnel-through-iap`.
|
||||
|
||||
### 1. Add DNS record
|
||||
Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
|
||||
|
||||
@@ -307,7 +355,7 @@ Paste the following into a new Claude session to continue development:
|
||||
|
||||
---
|
||||
|
||||
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
|
||||
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`ohm-rfc-app` in the `wiggleverse-ohm` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
|
||||
>
|
||||
> **Stack:**
|
||||
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
|
||||
@@ -323,11 +371,12 @@ Paste the following into a new Claude session to continue development:
|
||||
> - 10 append-only schema migrations in `backend/migrations/`
|
||||
>
|
||||
> **Deployment:**
|
||||
> - SSH: `gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc`
|
||||
> - Code at `/opt/rfc-app/` on the VM, owned by `rfc-app` system user
|
||||
> - `.env` at `/opt/rfc-app/backend/.env` (mode 0600)
|
||||
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/rfc-app/frontend/dist/`
|
||||
> - Restart to deploy: `sudo systemctl restart rfc-app`
|
||||
> - SSH (IAP-only): `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap`
|
||||
> - Code at `/opt/ohm-rfc-app/` on the VM, owned by `ohm-rfc-app` system user; tracks a detached release **tag**
|
||||
> - The VM's git origin is `git.benstull.org/benstull/rfc-app` — a *different* Gitea from the release one (`git.wiggleverse.org`); push `main` + the new tag to the `benstull` remote before deploying
|
||||
> - `.env` at `/opt/ohm-rfc-app/backend/.env` (mode 0600)
|
||||
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/ohm-rfc-app/frontend/dist/`
|
||||
> - Restart to deploy: `sudo systemctl restart ohm-rfc-app`
|
||||
> - Migrations run automatically on startup
|
||||
>
|
||||
> **Source is at `~/git/rfc-app/`.**
|
||||
|
||||
+45
-10
@@ -1,9 +1,26 @@
|
||||
# Runbook
|
||||
|
||||
Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
|
||||
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
|
||||
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
|
||||
no separate worker.
|
||||
Single-host deployment of the RFC app at `ohm.wiggleverse.org`. The shape
|
||||
matches §4.2: one process, one SQLite file, no separate worker.
|
||||
|
||||
> **⚠️ Current deployment names (post GCP name-alignment, ~2026-05).**
|
||||
> The command blocks below were written with the original names and use
|
||||
> `/opt/rfc-app`, system user `rfc-app`, and service `rfc-app`. The live
|
||||
> OHM box uses the realigned names — substitute throughout:
|
||||
>
|
||||
> | Was | Now |
|
||||
> | --- | --- |
|
||||
> | GCP project `wiggleverse-rfc` | `wiggleverse-ohm` |
|
||||
> | VM `rfc-app` | `ohm-rfc-app` |
|
||||
> | install path `/opt/rfc-app` | `/opt/ohm-rfc-app` |
|
||||
> | system user `rfc-app` | `ohm-rfc-app` |
|
||||
> | service `rfc-app.service` | `ohm-rfc-app.service` |
|
||||
> | SSH | IAP-only: `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap` |
|
||||
> | meta repo `wiggleverse/meta` | `wiggleverse/ohm-content` |
|
||||
>
|
||||
> The VM's git origin is **`git.benstull.org/benstull/rfc-app`** — a
|
||||
> *different* Gitea from the release one (`git.wiggleverse.org`). See §2.5.
|
||||
> The full current infra table lives in `DEPLOY-NEW-SESSION-PROMPT.md`.
|
||||
|
||||
Bring-up order: host prep → Gitea side (bot, OAuth, meta repo) → app side
|
||||
(code, venv, build, .env) → web server side (nginx, certbot) → systemd →
|
||||
@@ -345,16 +362,34 @@ pinned = 1 WHERE rfc_slug = ? AND branch_name = ?`).
|
||||
|
||||
### 2.5 Updating after a push
|
||||
|
||||
The live OHM box uses the realigned names (see the callout at the top)
|
||||
and deploys by checking out a **release tag** (detached HEAD), not by
|
||||
pulling a branch. Its git origin is `git.benstull.org/benstull/rfc-app`,
|
||||
which does **not** auto-mirror from the release Gitea
|
||||
(`git.wiggleverse.org`) — so first push `main` + the new tag there:
|
||||
|
||||
```sh
|
||||
sudo -u rfc-app git -C /opt/rfc-app pull
|
||||
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/rfc-app/backend/requirements.txt
|
||||
# Rebuild the frontend locally and rsync dist/ as in 1.3.2.
|
||||
sudo systemctl restart rfc-app
|
||||
# from your local rfc-app clone, on main at the merged release tip:
|
||||
git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
|
||||
```
|
||||
|
||||
Then on the VM (SSH is IAP-only):
|
||||
|
||||
```sh
|
||||
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
|
||||
# only when backend deps changed:
|
||||
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/ohm-rfc-app/backend/requirements.txt
|
||||
# frontend changes: build on the VM (Node 20+ is there) — output is served directly by nginx:
|
||||
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci && sudo -u ohm-rfc-app npm run build
|
||||
sudo systemctl restart ohm-rfc-app
|
||||
```
|
||||
|
||||
The §5 schema migrations run on startup and are append-only. A restart
|
||||
is the entire deploy.
|
||||
is the entire backend deploy; a frontend-only change is live as soon as
|
||||
the new `dist/` is built (nginx serves it directly).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,24 +8,53 @@
|
||||
# /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Then add the Let's Encrypt cert:
|
||||
# sudo certbot --nginx -d ohm.wiggleverse.org
|
||||
# Certbot will rewrite this file to add the 443 listener and certificate
|
||||
# directives; the rest of the config below stays as written.
|
||||
# TLS: this vhost terminates with the *.wiggleverse.org wildcard cert installed
|
||||
# on the VM (see the ssl_* directives in the 443 block below). This REPLACES the
|
||||
# old per-host certbot cert — the live file certbot previously rewrote on the VM
|
||||
# is superseded once you install this one. Put the cert files in place first:
|
||||
# sudo install -m 600 -o root -g root privkey.pem /etc/ssl/private/wiggleverse-wildcard.key
|
||||
# sudo install -m 644 fullchain.pem /etc/ssl/certs/wiggleverse-wildcard.crt
|
||||
# then `sudo nginx -t && sudo systemctl reload nginx`. Once cut over, retire the
|
||||
# old cert: `sudo certbot delete --cert-name ohm.wiggleverse.org`.
|
||||
#
|
||||
# Cloudflare: with an origin wildcard in place, set SSL/TLS mode to Full (strict)
|
||||
# and flip ohm to the orange cloud (proxied). If this is a Cloudflare Origin CA
|
||||
# cert it ONLY validates behind the proxy — so cut the cert over and proxy in the
|
||||
# same change; do not leave ohm grey-cloud with an Origin CA cert.
|
||||
|
||||
# HTTP → HTTPS redirect. Cloudflare also redirects at the edge once proxied, but
|
||||
# keep the origin honest for direct hits.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ohm.wiggleverse.org;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ohm.wiggleverse.org;
|
||||
|
||||
# TLS — *.wiggleverse.org wildcard installed on the VM (see top comment).
|
||||
# These files must exist before `nginx -t` / reload, or nginx fails to start.
|
||||
ssl_certificate /etc/ssl/certs/wiggleverse-wildcard.crt;
|
||||
ssl_certificate_key /etc/ssl/private/wiggleverse-wildcard.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSLwiggle:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# NOTE: once proxied behind Cloudflare, $remote_addr is a Cloudflare edge IP.
|
||||
# To restore the real client IP (for logging / Turnstile / rate limits), add a
|
||||
# real_ip block (set_real_ip_from <CF ranges>; real_ip_header CF-Connecting-IP;).
|
||||
# Left out here to avoid baking stale CF ranges into the repo — see follow-up.
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 M2/L8)
|
||||
#
|
||||
# NOTE: certbot promotes THIS server block to the HTTPS listener
|
||||
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
||||
# block (see the install comment above). These response headers
|
||||
# therefore ride into the HTTPS server block on the VM. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated
|
||||
# error responses (4xx/5xx), not just 200s.
|
||||
# NOTE: these response headers live in the HTTPS (443) server block. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated error
|
||||
# responses (4xx/5xx), not just 200s.
|
||||
#
|
||||
# `server_tokens off` (L8) — suppress the nginx version in the
|
||||
# Server header and on error pages so we don't advertise the
|
||||
@@ -99,6 +128,28 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# §22.10: the old corpus-root URLs (/rfc/<slug>, /rfc/<slug>/pr/<n>,
|
||||
# /proposals/<n>) are 308-redirected to the project-scoped /p/<default>/…
|
||||
# routes by the backend, so route them to FastAPI rather than serving the
|
||||
# SPA index.html. Must precede the `location /` SPA fallback.
|
||||
location /rfc/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /proposals/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — any non-asset path falls back to index.html so
|
||||
# React Router can take over.
|
||||
location / {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preview container entrypoint (flotilla SPEC §15).
|
||||
#
|
||||
# 1. Render nginx against Cloud Run's $PORT.
|
||||
# 2. Start the single-process uvicorn (which runs DB migrations on startup via
|
||||
# the app lifespan — §4.2: one process, one SQLite file, never scale workers).
|
||||
# 3. On a brand-new DB, apply the SYNTHETIC seed fixture so the preview shows
|
||||
# realistic content with no real user PII (§15).
|
||||
# 4. Hand the foreground to nginx.
|
||||
#
|
||||
# This is preview-only plumbing — production runs uvicorn under systemd and nginx
|
||||
# under the OS, never this script.
|
||||
set -euo pipefail
|
||||
|
||||
: "${PORT:=8080}"
|
||||
: "${DATABASE_PATH:=/opt/rfc-app/backend/data/rfc-app.db}"
|
||||
export PORT DATABASE_PATH
|
||||
|
||||
mkdir -p "$(dirname "$DATABASE_PATH")"
|
||||
|
||||
# Render the nginx config with the injected port.
|
||||
envsubst '${PORT}' \
|
||||
< /opt/rfc-app/deploy/preview/nginx.conf.template \
|
||||
> /etc/nginx/nginx.conf
|
||||
|
||||
fresh=0
|
||||
[ -f "$DATABASE_PATH" ] || fresh=1
|
||||
|
||||
# Start uvicorn (backgrounded); the app's lifespan runs migrations on boot.
|
||||
cd /opt/rfc-app/backend
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
|
||||
UVICORN_PID=$!
|
||||
|
||||
# Seed synthetic data once the schema exists (only on a fresh DB).
|
||||
if [ "$fresh" = "1" ]; then
|
||||
for _ in $(seq 1 30); do
|
||||
if [ -f "$DATABASE_PATH" ] \
|
||||
&& sqlite3 "$DATABASE_PATH" "SELECT 1 FROM schema_migrations LIMIT 1;" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if sqlite3 "$DATABASE_PATH" < /opt/rfc-app/deploy/preview/seed.sql; then
|
||||
echo "[preview-entrypoint] applied synthetic seed" >&2
|
||||
else
|
||||
# Best-effort: a seed that drifts from the schema must not block the
|
||||
# preview from booting (it still serves /api/health + an empty app).
|
||||
echo "[preview-entrypoint] WARNING: synthetic seed failed (schema drift?) — continuing" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# nginx in the foreground becomes the container's main process. (uvicorn is a
|
||||
# reparented child; for an ephemeral preview the hard stop on instance teardown
|
||||
# is fine — a process supervisor is a follow-up if graceful drain matters.)
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,64 @@
|
||||
# nginx config TEMPLATE for the preview container (flotilla SPEC §15).
|
||||
# `${PORT}` is substituted by deploy/preview/entrypoint.sh (envsubst) with the
|
||||
# port Cloud Run injects. Mirrors the prod vhost
|
||||
# (deploy/nginx/ohm.wiggleverse.org.conf) minus TLS (Cloud Run terminates TLS)
|
||||
# and minus the prod security headers tuned for the public host.
|
||||
|
||||
worker_processes 1;
|
||||
pid /run/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
listen [::]:${PORT};
|
||||
server_name _;
|
||||
|
||||
root /opt/rfc-app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# API + auth proxy to the single-process uvicorn. SSE chat streams need
|
||||
# buffering off so chunks reach the browser immediately.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback so React Router can take over.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
client_max_body_size 4M;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Preview environment shape (flotilla SPEC §15) — TEST values only.
|
||||
#
|
||||
# These are the env vars a per-PR preview boots with. The operator loads them
|
||||
# into flotilla's PREVIEW overlay layer (NOT the base overlay, NOT secrets):
|
||||
#
|
||||
# while IFS='=' read -r k v; do
|
||||
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
|
||||
# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac
|
||||
# done < deploy/preview/preview.env.example
|
||||
#
|
||||
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
|
||||
# Every value here is synthetic or a documented public test value. None is a
|
||||
# real credential. Do NOT bind real `secret_refs` for previews.
|
||||
|
||||
# --- Turnstile: Cloudflare's documented ALWAYS-PASS test keys (public) -------
|
||||
# Site key is also baked into the image at build time (Dockerfile ARG); the
|
||||
# secret key here makes server-side verification always succeed.
|
||||
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
|
||||
# --- Email: a catch-all SMTP sink (run Mailpit/Inbucket as a sidecar/service)-
|
||||
# No mail leaves the preview; everything lands in the sink's web UI.
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_STARTTLS=0
|
||||
SMTP_PASSWORD=preview-sink-no-auth
|
||||
EMAIL_FROM=preview@preview.invalid
|
||||
EMAIL_FROM_NAME=RFC App (preview)
|
||||
|
||||
# --- Analytics: no-op'd -------------------------------------------------------
|
||||
VITE_AMPLITUDE_API_KEY=
|
||||
|
||||
# --- App identity / required env (synthetic) ---------------------------------
|
||||
# These satisfy backend/app/config.py's required vars with non-secret test
|
||||
# values. SECRET_KEY is a throwaway — sessions in an ephemeral preview need a
|
||||
# key, not a SECRET one. GITEA_* point at whatever read surface the preview
|
||||
# uses; for a content-light framework-PR preview the seeded synthetic DB
|
||||
# carries the visible state and Gitea need not be reachable.
|
||||
SECRET_KEY=preview-throwaway-not-a-secret-0000000000
|
||||
GITEA_URL=https://git.wiggleverse.org
|
||||
GITEA_BOT_USER=preview-bot
|
||||
GITEA_BOT_TOKEN=preview-not-a-real-token
|
||||
GITEA_ORG=preview
|
||||
GITEA_WEBHOOK_SECRET=preview-webhook-not-a-secret
|
||||
OAUTH_CLIENT_ID=preview-oauth-client
|
||||
OAUTH_CLIENT_SECRET=preview-oauth-not-a-secret
|
||||
# APP_URL: set to the Cloud Run service URL once `preview up` reports it, if the
|
||||
# app's OAuth redirect / absolute-URL building needs it for your review flow.
|
||||
APP_URL=http://localhost:8080
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Synthetic seed for per-PR preview environments (flotilla SPEC §15).
|
||||
--
|
||||
-- SYNTHETIC DATA ONLY. This file is version-controlled and reviewable, carries
|
||||
-- NO real user PII, and is applied by deploy/preview/entrypoint.sh onto a
|
||||
-- brand-new preview DB AFTER the app's own migrations have created the schema.
|
||||
-- It must never be applied to a production database.
|
||||
--
|
||||
-- Applied best-effort: if a future migration changes a table shape this seed
|
||||
-- references, the entrypoint logs a warning and the preview still boots (it
|
||||
-- just shows less content). Keep the inserts conservative and schema-stable;
|
||||
-- grow richer fixtures (RFCs, branches, threads, PRs) here as the preview's
|
||||
-- review value warrants — they are reproducible because they live in git.
|
||||
|
||||
-- A small synthetic user set covering each role (§6.1 owner/admin/contributor).
|
||||
-- Negative gitea_ids keep these clear of any real Gitea account id space.
|
||||
INSERT OR IGNORE INTO users (gitea_id, gitea_login, email, display_name, role) VALUES
|
||||
(-1, 'preview-owner', 'owner@preview.invalid', 'Preview Owner', 'owner'),
|
||||
(-2, 'preview-admin', 'admin@preview.invalid', 'Preview Admin', 'admin'),
|
||||
(-3, 'preview-alice', 'alice@preview.invalid', 'Alice Preview', 'contributor'),
|
||||
(-4, 'preview-bob', 'bob@preview.invalid', 'Bob Preview', 'contributor');
|
||||
@@ -90,6 +90,99 @@ the framework at that pin against your Gitea; the framework knows
|
||||
nothing about your specific deployment beyond what your `.env`
|
||||
files told it.
|
||||
|
||||
## The registry: projects and collections (§22)
|
||||
|
||||
From **v0.45.0** a deployment is **three tiers** — deployment →
|
||||
**project** → **RFC collection** — instead of one corpus. Most of
|
||||
the single-corpus setup above still applies (a deployment that wants
|
||||
one corpus runs the **N=1 case** unchanged), but two things move into
|
||||
**git-truth the framework mirrors**, exactly the way RFC bodies do.
|
||||
The binding model is [`SPEC.md` §22](../SPEC.md); this is the operator
|
||||
view of the file formats.
|
||||
|
||||
### The registry repo (`REGISTRY_REPO`) declares projects
|
||||
|
||||
`VITE_APP_NAME` and `META_REPO` are superseded. The framework now reads
|
||||
a **registry repo** — a dedicated repo under your Gitea org, named
|
||||
whatever you like, whose location you pass in `backend/.env` as
|
||||
`REGISTRY_REPO` (the framework fails loudly at startup if it is unset).
|
||||
Its root holds a `projects.yaml`:
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse # deployment display name (was VITE_APP_NAME)
|
||||
tagline: A substrate for collaborative standardization
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique in the deployment → /p/ohm/
|
||||
name: Open Human Model
|
||||
content_repo: ohm-content # ONE repo under your org; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides
|
||||
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
|
||||
```
|
||||
|
||||
Each project owns **exactly one content repo**. Adding, reconfiguring,
|
||||
or archiving a project is a PR against `projects.yaml`; the framework's
|
||||
webhook + reconciler mirror it into the `projects` cache table. Project
|
||||
**membership** is app state (it churns at user speed), not registry
|
||||
data. The deployment's display name and tagline come from here, served
|
||||
at runtime via `GET /api/deployment` — no rebuild needed to rename.
|
||||
|
||||
### The content repo declares collections via `.collection.yaml`
|
||||
|
||||
A project's collections are **typed subfolders** of its content repo,
|
||||
each carrying a `.collection.yaml` manifest the registry mirror reads:
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
rfcs/intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
rfcs/runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
rfcs/login.md
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/model/.collection.yaml
|
||||
type: document # document | specification | bdd — IMMUTABLE once set
|
||||
visibility: gated # defaults to the project's; may only NARROW it
|
||||
initial_state: super-draft # super-draft | active — defaults from type
|
||||
name: The Model
|
||||
# enabled_models: [claude] # optional; may only narrow the project's universe
|
||||
```
|
||||
|
||||
`type` is fixed at creation (the framework refuses to change it on a
|
||||
later mirror). `visibility` may be as strict as or stricter than the
|
||||
project's, never looser (`public` < `unlisted` < `gated`); reading or
|
||||
writing a collection requires passing **both** the project and the
|
||||
collection gate. `enabled_models`, if present, narrows the project's
|
||||
model universe for that collection.
|
||||
|
||||
### Default project + default collection (the N=1 upgrade)
|
||||
|
||||
A deployment upgrading from a pre-§22 version is migrated automatically:
|
||||
its single corpus becomes one **default project** carrying one **default
|
||||
collection** (`id` `default`, subfolder = repo root), inheriting the old
|
||||
`type` / `initial_state` / visibility. Old URLs 308-redirect into the
|
||||
`/p/<project>/c/default/…` form, so existing links survive. Until you
|
||||
add a second project or collection the deployment is functionally
|
||||
identical to before, with one extra path segment. The ordered upgrade
|
||||
actions are the [`CHANGELOG.md`](../CHANGELOG.md) entry's upgrade-steps
|
||||
block (§20.4).
|
||||
|
||||
### Creating projects and collections in-app
|
||||
|
||||
Both tiers can also be created from the UI by an Owner — the action
|
||||
wraps a bot commit (a new content repo + `projects.yaml` entry for a
|
||||
project; a new subfolder + `.collection.yaml` for a collection), so
|
||||
everything still flows from git. Nothing becomes app state the mirror
|
||||
cannot rebuild.
|
||||
|
||||
## The two-repo working pattern
|
||||
|
||||
Once a deployment is live, day-to-day changes split across the
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
# Draft spec — §22 refactor: three tiers (deployment → project → RFC collection)
|
||||
|
||||
> Status: **draft for review.** Binding voice, but not yet merged into
|
||||
> `SPEC.md`. This doc **revises the §22 model** in
|
||||
> [`multi-project-spec.md`](./multi-project-spec.md) from two tiers
|
||||
> (deployment → project, where a "project" *is* a corpus) to **three tiers**
|
||||
> (deployment → project → RFC collection, where the *collection* is the
|
||||
> corpus). It supersedes the conflicting parts of that draft; the parts it does
|
||||
> not touch (the registry-is-git-truth stance, the cache mirror, visibility
|
||||
> semantics, the `type`/`initial_state`/`unreviewed` machinery) carry over
|
||||
> unchanged, re-homed onto the collection. Rationale and the decisions behind
|
||||
> this live in [`multi-project.md`](./multi-project.md) and session 0072.
|
||||
>
|
||||
> ⚠️ **CORRECTION (session 0072, after code re-check).** Parts of §0/§A.3/§E
|
||||
> were drafted on a stale-memory premise that "Plan B (migration 028) and
|
||||
> M3-frontend have not shipped." **That is false.** As of v0.39.0 the entire
|
||||
> **two-tier** model is shipped to `main`: migration 028 already rebuilt the
|
||||
> slug PK to `(project_id, slug)`; v0.35.0 shipped `/p/<project>/` routing and
|
||||
> the live `/p/<project>/e/<slug>` URLs; v0.37.0/0.38.0 shipped per-project
|
||||
> read + propose. Inserting the third tier is therefore an **evolution of a
|
||||
> shipped system**, not a revision of unshipped designs. The migration strategy
|
||||
> (Part E) was **re-decided on these corrected facts** (session 0072): a new
|
||||
> **migration 029** adds a *collection* grain *beneath* today's project, plus a
|
||||
> breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>` URL
|
||||
> change with 308s. The structural model (Parts A–D) is unaffected. Target
|
||||
> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2).
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this revision
|
||||
|
||||
The original §22 (`multi-project-spec.md`) gave a deployment **N projects**,
|
||||
where each project *was* a single typed corpus: one content repo, one `type`,
|
||||
one slug namespace, one member roster. That conflates two responsibilities —
|
||||
**organizational grouping** and **a typed body of entries** — into one noun.
|
||||
|
||||
This revision splits them. A **project** becomes a pure grouping tier (settings
|
||||
+ one content repo) that holds **any number of RFC collections**; an **RFC
|
||||
collection** is the typed corpus the original §22 called a "project." Everything
|
||||
the original §22 said about a corpus (type, slug namespace, catalog, philosophy,
|
||||
landing state, review flag, membership) moves down one level to the collection;
|
||||
the deployment level is unchanged.
|
||||
|
||||
⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt
|
||||
the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live
|
||||
(v0.35.0). So inserting the third tier evolves a shipped system — see Part E
|
||||
for the decided strategy (a new migration 029 adding a collection grain beneath
|
||||
today's project, + a breaking URL change with 308s).
|
||||
|
||||
---
|
||||
|
||||
# Part A — The three-tier model
|
||||
|
||||
## A.1 The tiers
|
||||
|
||||
```
|
||||
deployment (= "global" in the UI) one Gitea org, one bot, one account
|
||||
│ system, one inbox, one running process;
|
||||
│ the surface a visitor first lands on.
|
||||
└─ project ◀ NEW a named grouping + project settings;
|
||||
│ owns exactly ONE content repo. No type.
|
||||
└─ RFC collection a typed corpus: type, slug namespace,
|
||||
│ catalog, philosophy, initial_state,
|
||||
│ unreviewed flag, members. (= what the
|
||||
│ original §22 called a "project".)
|
||||
└─ entry an RFC / spec / feature, identified by
|
||||
its slug within the collection.
|
||||
```
|
||||
|
||||
- **Deployment / "global."** Unchanged top tier. Owns accounts, the §6
|
||||
admission gate, the §15 inbox, the §1 bot, and the deployment landing
|
||||
directory. Its management surface is **projects + global settings**.
|
||||
- **Project** *(new)*. Belongs to exactly one deployment; never moves. Owns one
|
||||
content repo (§A.2) and carries project settings (name, tagline, theme,
|
||||
visibility, model universe). Has **no `type`** of its own. Its management
|
||||
surface is **RFC collections + project settings**.
|
||||
- **RFC collection.** A typed subfolder of its project's content repo (§A.2).
|
||||
Carries everything the original §22 pinned on a "project": the immutable
|
||||
`type` (§22.4a `document` | `specification` | `bdd` | …), the per-collection
|
||||
slug namespace (§A.3), `initial_state` (§22.4b), the `unreviewed` flag
|
||||
(§22.4c), catalog, philosophy. This is "closest to what OHM originally
|
||||
managed as a single corpus."
|
||||
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
|
||||
|
||||
A collection belongs to exactly one project; a project to exactly one
|
||||
deployment. Isolation (§22.1) now holds at the **collection** grain: an RFC,
|
||||
branch, thread, star, or watch belongs to exactly one collection.
|
||||
|
||||
## A.2 Storage and git-truth
|
||||
|
||||
Two git sources, both read by the bot, both mirrored into cache tables the §4
|
||||
way:
|
||||
|
||||
1. **The registry repo** (`projects.yaml`, located by `REGISTRY_REPO`, §22.2)
|
||||
declares **projects** — `id`, `name`, `content_repo`, settings, `visibility`,
|
||||
`theme`, `enabled_models`. `content_repo` moves **up** from the collection
|
||||
(original §22) to the project: a project owns exactly one content repo.
|
||||
|
||||
2. **Each project's content repo** declares its **collections** as typed
|
||||
subfolders, each carrying a **`.collection.yaml` manifest** (the collection's
|
||||
`type`, `visibility`, `initial_state`). The registry mirror walks the content
|
||||
repo and reads these manifests, so collection configuration is git-truth and
|
||||
survives a cache rebuild — exactly as entry frontmatter does.
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm
|
||||
name: Open Human Model
|
||||
content_repo: ohm-content # ONE repo; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" }
|
||||
enabled_models: [claude, gemini]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/features/.collection.yaml (one per collection subfolder)
|
||||
type: bdd # document | specification | bdd — immutable
|
||||
visibility: gated # defaults to the project's, may narrow
|
||||
initial_state: active # defaults from type (§22.4b)
|
||||
name: Feature scenarios
|
||||
```
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
login.md
|
||||
```
|
||||
|
||||
**Creation is in-app, wrapping a bot commit, at both tiers:**
|
||||
|
||||
- **+ New project** (a global Owner action): the bot **creates a Gitea content
|
||||
repo** under the deployment org, **commits a project entry** to
|
||||
`projects.yaml`, and the mirror picks it up.
|
||||
- **+ New collection** (a project Owner / RFC Contributor-with-create action):
|
||||
the bot **commits a new subfolder + `.collection.yaml`** to the project's
|
||||
content repo; the mirror picks it up.
|
||||
|
||||
The in-app button is a thin convenience over a git write; nothing becomes app
|
||||
state that git cannot rebuild. `projects` and `collections` cache rows are never
|
||||
written from user actions directly — they flow from the mirror only (§22.2).
|
||||
**Membership** (§B-roles) remains app state, as `rfc_collaborators` always has
|
||||
been — it churns at user speed and is not document state.
|
||||
|
||||
## A.3 Identity and routing
|
||||
|
||||
The slug is unique **within a collection**; the fully-qualified identity is
|
||||
`(project, collection, slug)`. `model/intro` and `specs/intro` coexist. No type
|
||||
prefix, no numbers (the §22.4 retirement of `RFC-NNNN` allocation stands;
|
||||
legacy `id` frontmatter remains a frozen, non-identity display label).
|
||||
|
||||
Canonical route:
|
||||
|
||||
```
|
||||
/p/<project>/c/<collection>/e/<slug>
|
||||
```
|
||||
|
||||
The `c/` segment keeps collection ids from colliding with reserved
|
||||
project-level segments (project settings, the collection directory). Reserved
|
||||
**collection-level** siblings (`proposals`, `philosophy`) sit under
|
||||
`/p/<project>/c/<collection>/…`. The displayed entry noun ("RFC", "Spec",
|
||||
"Feature") is the collection type's label (§22.4a), not part of the path.
|
||||
|
||||
The root `/` is the deployment landing: a **directory of projects** the visitor
|
||||
can see (§22.5). `/p/<project>/` is the project landing: a **directory of
|
||||
collections** in that project the visitor can see. Conveniences:
|
||||
|
||||
- `/p/<project>/` redirects to its sole collection when the project has exactly
|
||||
one visible collection.
|
||||
- `/` redirects to the sole visible project when there is exactly one (the N=1
|
||||
case, §A.6).
|
||||
|
||||
⚠️ **Backcompat is heavier than first drafted.** `/p/<project>/e/<slug>` URLs
|
||||
**are live** (v0.35.0), so adding the `/c/<collection>/` segment is a breaking
|
||||
URL change: the shipped `/p/<project>/e/<slug>` must **308-redirect** to
|
||||
`/p/<project>/c/<default-collection>/e/<slug>`, alongside the pre-multi-project
|
||||
`/rfc/<slug>` → `/p/<default-project>/c/<default-collection>/…` redirect. Both
|
||||
are handled in the migration (§A.6 / Part E).
|
||||
|
||||
---
|
||||
|
||||
# Part B — Roles and authorization
|
||||
|
||||
## B.1 One role vocabulary, attached at a scope
|
||||
|
||||
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
|
||||
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
|
||||
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
|
||||
levels" is therefore literal — the same two words at every tier, not a fresh
|
||||
pair invented per tier.
|
||||
|
||||
| Role | Capabilities within its scope's subtree |
|
||||
|---|---|
|
||||
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
|
||||
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project — the "anyone at the project level with permission to create a collection" affordance. (A *collection*-scope grant cannot create sibling collections; creating one is a project-level action.) |
|
||||
|
||||
This **reconciles** the role names the prior drafts accumulated — they were
|
||||
different words for the same idea:
|
||||
|
||||
| Prior spec term | Tier it lived at | Unified role |
|
||||
|---|---|---|
|
||||
| deployment `owner` / `admin` (§6.1) | global | **Owner** (global) |
|
||||
| deployment `contributor` (§6.1) | global | **RFC Contributor** (global) |
|
||||
| `project_admin` (M2 §22.6) | the corpus → now the **collection** | **Owner** (collection) |
|
||||
| `project_contributor` (M2 §22.6) | the corpus → now the **collection** | **RFC Contributor** (collection) |
|
||||
| `project_viewer` (M2 §22.6) | the corpus | *deferred* (read-only grant; not one of this pass's two) |
|
||||
|
||||
> **Scope-narrowing, not renaming.** Collapsing `owner`/`admin` into one
|
||||
> **Owner** and dropping `viewer` for this pass are deliberate deferrals (the
|
||||
> launch ask: "we don't need to get all permissions right yet"). When they
|
||||
> return they **re-split out of** Owner / add a tier; they are not aliases of
|
||||
> the unified roles. The richer set is future work.
|
||||
|
||||
## B.2 Inheritance and resolution
|
||||
|
||||
Grants inherit **downward**, are **additive**, and admit **no negative
|
||||
override**:
|
||||
|
||||
- A grant at **global** covers every project and collection in the deployment.
|
||||
- A grant at **project** covers every collection in that project.
|
||||
- A grant at **collection** covers just that collection.
|
||||
- You **cannot** grant a role at a parent scope and revoke it at a child (the
|
||||
launch ask: "too complex"). Resolution never subtracts a parent grant.
|
||||
|
||||
Effective authority on an entry generalizes the §22.7 most-permissive union
|
||||
from three layers to four (global → project → collection → per-entry):
|
||||
|
||||
```
|
||||
effective authority on an entry =
|
||||
global role (users.role)
|
||||
∪ project role (membership at the entry's project)
|
||||
∪ collection role (membership at the entry's collection)
|
||||
∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
|
||||
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
|
||||
```
|
||||
|
||||
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
|
||||
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12); the
|
||||
three named scopes apply to a *subtree*. Per-entry authority is unchanged and
|
||||
sits beneath collection in the union. `arbiter` is narrower than Owner (one
|
||||
entry, not a subtree) and stays distinct.
|
||||
|
||||
## B.3 Schema impact
|
||||
|
||||
- `users.role` continues to carry the **global** role (deployment owner /
|
||||
contributor).
|
||||
- M2's `project_members(project_id, role)` rows were attached at what we now
|
||||
call the **collection**. They generalize into a single polymorphic
|
||||
**`memberships(scope_type ∈ {project, collection}, scope_id, user_id, role,
|
||||
granted_by, granted_at)`** table; the M2 rows migrate to
|
||||
`scope_type='collection'`. The **project** tier gets the same two roles,
|
||||
freshly grantable.
|
||||
- The M2 three-role enum (`viewer`/`contributor`/`admin`) collapses to
|
||||
`{owner, contributor}`: `project_admin → owner`, `project_contributor →
|
||||
contributor`, `project_viewer →` a read grant (no write) folded into
|
||||
visibility, not a membership role this pass.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Behavioral scenarios (BDD)
|
||||
|
||||
> These Gherkin scenarios are the behavioral spec for **role usage**,
|
||||
> **invitation**, and **empty-state** experiences. They attach to the rewritten
|
||||
> §22 as **§22.6a (role & invitation scenarios)**. They are written so they can
|
||||
> *also* seed a `bdd`-type collection later (the framework dogfooding its own
|
||||
> model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in
|
||||
> the `Given` is global / project / collection.
|
||||
>
|
||||
> **Each scenario carries a `@S<n>` tag** naming the **slice** (Part E) that
|
||||
> makes it pass — the "which scenarios are done after this slice" marker. After
|
||||
> shipping slice S<n>, its acceptance gate is "every `@S<n>` scenario passes"
|
||||
> (e.g. `--tags @S3`). The Part E table is the inverse index (slice →
|
||||
> scenarios).
|
||||
|
||||
## C.1 Role usage — inheritance and the most-permissive union
|
||||
|
||||
```gherkin
|
||||
Feature: Scope roles grant authority over a subtree
|
||||
As a member of the deployment
|
||||
I want a role granted at one tier to apply to everything beneath it
|
||||
So that I can be invited once and work across the right set of collections
|
||||
|
||||
Background:
|
||||
Given a deployment with a project "ohm"
|
||||
And "ohm" owns collections "model" (document) and "features" (bdd)
|
||||
|
||||
@S3
|
||||
Scenario: Collection RFC Contributor may propose only in that collection
|
||||
Given "ada" is RFC Contributor at collection "ohm/model"
|
||||
When "ada" opens the propose form in "ohm/model"
|
||||
Then she may submit a new entry
|
||||
When "ada" opens "ohm/features"
|
||||
Then she sees it read-only and the propose action is not offered
|
||||
|
||||
@S3
|
||||
Scenario: Project RFC Contributor may propose in every collection of the project
|
||||
Given "ben" is RFC Contributor at project "ohm"
|
||||
Then "ben" may propose in "ohm/model"
|
||||
And "ben" may propose in "ohm/features"
|
||||
And a collection added to "ohm" later is writable by "ben" with no new grant
|
||||
|
||||
@S3
|
||||
Scenario: Global RFC Contributor may propose in every collection of every project
|
||||
Given a second project "acme" with collection "acme/specs"
|
||||
And "cleo" is RFC Contributor at global scope
|
||||
Then "cleo" may propose in "ohm/model" and "acme/specs"
|
||||
|
||||
@S3
|
||||
Scenario: Collection Owner administers one collection only
|
||||
Given "dan" is Owner at collection "ohm/features"
|
||||
Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features"
|
||||
But "dan" may not change "ohm" project settings
|
||||
And "dan" may not act on entries in "ohm/model"
|
||||
|
||||
@S3
|
||||
Scenario: Project Owner administers all collections and may create more
|
||||
Given "eve" is Owner at project "ohm"
|
||||
Then "eve" may manage membership in "ohm/model" and "ohm/features"
|
||||
And "eve" may edit "ohm" project settings
|
||||
And "eve" may create a new collection in "ohm"
|
||||
|
||||
@S3
|
||||
Scenario: Most-permissive union — the higher grant wins
|
||||
Given "fay" is RFC Contributor at collection "ohm/model"
|
||||
And "fay" is Owner at project "ohm"
|
||||
Then "fay" acts as Owner in "ohm/model"
|
||||
|
||||
@S3
|
||||
Scenario: No negative override — a child cannot subtract a parent grant
|
||||
Given "gil" is RFC Contributor at project "ohm"
|
||||
Then there is no control to remove "gil" from "ohm/model" while keeping the project grant
|
||||
And "gil" can propose in "ohm/model"
|
||||
|
||||
@S3
|
||||
Scenario: A granted account with no scope role sees only public content
|
||||
Given "hana" has a granted deployment account but no global, project, or collection role
|
||||
Then "hana" may read public collections under the §6.1 anonymous-read contract
|
||||
But "hana" is not offered the propose action anywhere
|
||||
And gated projects and collections do not appear for her
|
||||
```
|
||||
|
||||
## C.2 Invitation — who may invite whom, at which scope
|
||||
|
||||
```gherkin
|
||||
Feature: Inviting users to a scope role
|
||||
As an Owner of a scope
|
||||
I want to grant Owner or RFC Contributor at my scope or any scope beneath it
|
||||
So that collaborators get exactly the reach they need
|
||||
|
||||
@S4
|
||||
Scenario: Project Owner invites at project scope (covers all collections)
|
||||
Given "eve" is Owner at project "ohm"
|
||||
When "eve" invites "ivy" as RFC Contributor at project "ohm"
|
||||
Then a membership row is written at scope project "ohm"
|
||||
And "ivy" receives a §15 notification naming the project and role
|
||||
And "ivy" may propose in every collection of "ohm"
|
||||
|
||||
@S4
|
||||
Scenario: Owner invites at a specific collection
|
||||
When "eve" invites "jo" as RFC Contributor at collection "ohm/features"
|
||||
Then a membership row is written at scope collection "ohm/features"
|
||||
And "jo" may propose in "ohm/features" but not "ohm/model"
|
||||
|
||||
@S4
|
||||
Scenario: Invitation reach is bounded by the inviter's scope
|
||||
Given "dan" is Owner at collection "ohm/features"
|
||||
Then "dan" may invite users to roles in "ohm/features"
|
||||
But "dan" is not offered the control to invite at project "ohm" or global scope
|
||||
|
||||
@S4
|
||||
Scenario: RFC Contributors do not manage membership
|
||||
Given "ben" is RFC Contributor at project "ohm"
|
||||
Then "ben" may propose and create collections in "ohm"
|
||||
But "ben" is not offered any invite control (membership is an Owner capability)
|
||||
|
||||
@S4
|
||||
Scenario: The invite UI offers no grant-at-parent-revoke-at-child option
|
||||
Given "eve" is Owner at project "ohm"
|
||||
When "eve" opens the invite control for "ivy" at project "ohm"
|
||||
Then she may choose role Owner or RFC Contributor and scope project or a single collection
|
||||
But there is no option to grant at "ohm" and exclude a child collection
|
||||
|
||||
@S4
|
||||
Scenario: Re-inviting at a broader scope supersedes the narrower grant
|
||||
Given "jo" is RFC Contributor at collection "ohm/features"
|
||||
When "eve" invites "jo" as RFC Contributor at project "ohm"
|
||||
Then "jo" has the role across all of "ohm"
|
||||
And the redundant collection-scope row is removed or shown as subsumed
|
||||
|
||||
@S4
|
||||
Scenario: A pending deployment account cannot be granted write
|
||||
Given "kim" has permission_state "pending" at the deployment
|
||||
When "eve" invites "kim" as RFC Contributor at project "ohm"
|
||||
Then the grant is recorded but confers no write capability until "kim" is granted at the deployment (§6)
|
||||
```
|
||||
|
||||
## C.3 Empty-state experiences
|
||||
|
||||
```gherkin
|
||||
Feature: Empty states at each tier
|
||||
As a viewer of a tier with nothing in it yet
|
||||
I want a clear, role-appropriate empty state
|
||||
So that I know whether there is an action to take or simply nothing to see
|
||||
|
||||
@S5
|
||||
Scenario: Global directory with no projects — Owner
|
||||
Given a deployment with no projects
|
||||
And "root" is Owner at global scope
|
||||
When "root" lands on "/"
|
||||
Then she sees an empty directory with a "Create your first project" call to action
|
||||
|
||||
@S5
|
||||
Scenario: Global directory with no visible projects — non-owner
|
||||
Given a deployment whose only projects are gated
|
||||
And "vee" is a granted account with no roles
|
||||
When "vee" lands on "/"
|
||||
Then she sees an empty directory with no create action
|
||||
And a note that there is nothing shared with her yet
|
||||
|
||||
@S4
|
||||
Scenario: Project with no collections — project Owner
|
||||
Given project "ohm" with no collections
|
||||
And "eve" is Owner at project "ohm"
|
||||
When "eve" lands on "/p/ohm/"
|
||||
Then she sees an empty collection directory with a "Create your first collection" call to action
|
||||
And the action lets her choose a type and subfolder
|
||||
|
||||
@S4
|
||||
Scenario: Project with no collections — RFC Contributor without create rights
|
||||
Given project "ohm" with no collections
|
||||
And "ben" is RFC Contributor at collection scope elsewhere only
|
||||
When "ben" lands on "/p/ohm/"
|
||||
Then he sees an empty collection directory with no create action
|
||||
|
||||
@S4
|
||||
Scenario: Collection with no entries — a contributor
|
||||
Given collection "ohm/model" with no entries
|
||||
And "ada" is RFC Contributor at collection "ohm/model"
|
||||
When "ada" lands on "/p/ohm/c/model/"
|
||||
Then she sees an empty catalog with a "Propose the first entry" call to action
|
||||
|
||||
@S2
|
||||
Scenario: Collection with no entries — an anonymous reader
|
||||
Given a public collection "ohm/model" with no entries
|
||||
When an anonymous visitor lands on "/p/ohm/c/model/"
|
||||
Then they see an empty catalog with no propose action and a sign-in prompt
|
||||
|
||||
@S1
|
||||
Scenario: Single-collection project skips the directory
|
||||
Given project "ohm" with exactly one visible collection "model"
|
||||
When a visitor lands on "/p/ohm/"
|
||||
Then they are redirected to "/p/ohm/c/model/"
|
||||
|
||||
@S1
|
||||
Scenario: Single-project deployment skips the directory
|
||||
Given a deployment with exactly one visible project "ohm"
|
||||
When a visitor lands on "/"
|
||||
Then they are redirected to "/p/ohm/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part D — Amendments to the original §22 draft
|
||||
|
||||
Applied in place when §22 is rewritten; listed here as the change surface.
|
||||
|
||||
- **§22 preamble / §22.1.** "A deployment hosts N projects, each a corpus" →
|
||||
"a deployment hosts N **projects**, each owning one content repo and holding
|
||||
N **RFC collections**, each collection a typed corpus." Isolation moves to the
|
||||
collection grain.
|
||||
- **§22.2 Registry.** `projects.yaml` declares projects with one `content_repo`
|
||||
each (no per-collection `content_repo`). New: collections are declared by
|
||||
`.collection.yaml` manifests inside the content repo; the mirror reads them.
|
||||
In-app create-project / create-collection actions wrap bot commits.
|
||||
- **§22.3 Content repos.** "One per project" (not per collection); collections
|
||||
are subfolders within it.
|
||||
- **§22.4 / §22.4a-c.** Slug is unique **per collection**. `type`,
|
||||
`initial_state`, and `unreviewed` are **collection** properties (re-homed from
|
||||
"project"). Unchanged otherwise.
|
||||
- **§22.5 Visibility.** Applies at **both** project and collection. A collection
|
||||
defaults to its project's visibility and may narrow it; reading/writing a
|
||||
collection requires passing both gates.
|
||||
- **§22.6 Membership and roles → the unified model (Part B).** Replace the three
|
||||
`project_*` roles with `{owner, contributor}` at `{global, project,
|
||||
collection}` via a polymorphic `memberships` table. Add **§22.6a** = the
|
||||
Part C scenarios.
|
||||
- **§22.7 Composition.** Four-layer most-permissive union (global → project →
|
||||
collection → per-entry); no negative override.
|
||||
- **§22.9 / §22.10 Branding & routing.** Routes gain the collection segment:
|
||||
`/p/<project>/c/<collection>/…`. `GET /api/deployment` lists visible projects;
|
||||
add `GET /api/projects/:id` (lists visible collections + project settings) and
|
||||
`GET /api/projects/:id/collections/:cid` (collection settings incl. `type`).
|
||||
- **§22.11 Notifications / §22.13 migration / §5 amendments.** `project_id`
|
||||
becomes `collection_id` on every entry-scoped row (the corpus grain is now the
|
||||
collection); a separate `project_id` exists only on the `collections` table
|
||||
and project-scoped rows. The §22.13 default project gains a default collection
|
||||
(§A.6 below).
|
||||
|
||||
---
|
||||
|
||||
# Part E — Revised slicing plan (the roadmap re-slot)
|
||||
|
||||
**Strategy (session 0072, decided on corrected facts).** The two-tier model is
|
||||
shipped end-to-end (v0.39.0): migration 028 keyed entries `(project_id, slug)`;
|
||||
v0.35.0 shipped `/p/<project>/` routing + live `/p/<project>/e/<slug>` URLs;
|
||||
v0.37.0/0.38.0 shipped per-project read + propose. Inserting the third tier is
|
||||
therefore an **evolution of a shipped system**. The chosen mapping **adds a
|
||||
collection grain *beneath* today's project** — the shipped `projects` table
|
||||
stays the grouping tier (it already owns `content_repo`, where §A.2 wants it),
|
||||
a new `collections` table holds the per-corpus fields, and entries re-key to the
|
||||
finer `(collection_id, slug)`.
|
||||
|
||||
**Slicing principle (session 0072): every slice ends in a *usable* deployment,
|
||||
and declares the Part C scenarios it makes pass** (its `@S<n>` tag). "Usable"
|
||||
means the deployment runs and either gains a capability or provably loses none
|
||||
(N=1 unchanged). A slice is done when its `@S<n>` scenarios are green.
|
||||
|
||||
- **Landed, unchanged (v0.39.0):** M1–M2, M3-backend Plan A **and** Plan B
|
||||
(read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13
|
||||
re-stamp. None of this is rebuilt; it is *evolved* by the slices below.
|
||||
|
||||
- **S1 — The collection grain exists (invisible default).** Migration 029 +
|
||||
backend threading + the default-routing redirect, shipped **together** (they
|
||||
are coupled — renaming `project_id`→`collection_id` breaks every reader until
|
||||
the code is threaded, so a green tree needs both). Migration 029
|
||||
(`029_collections.sql`): (1) add a `collections` table
|
||||
`(id, project_id, type, subfolder, initial_state, visibility, name,
|
||||
registry_sha)`; (2) move the per-corpus fields (`type`, `initial_state`,
|
||||
visibility) **down** from `projects` (leaving it `(id, content_repo,
|
||||
visibility, name, tagline, theme, enabled_models, …)`); (3) create one default
|
||||
collection per project (id `default`, `subfolder` = repo root); (4) re-key
|
||||
every entry-scoped table `(project_id, slug)` → `(collection_id, slug)` via the
|
||||
`028_project_scoped_keys.sql` rebuild pattern (`__new`, copy, drop, rename,
|
||||
FK-off + `foreign_key_check`); (5) generalize `project_members` →
|
||||
`memberships(scope_type ∈ {project, collection}, …)`, collapsing the role enum
|
||||
(§B.3). Then thread `collection_id` through `app/auth.py` / `app/projects.py`
|
||||
/ `app/cache.py` / the `api_*` writers, and **308** `/p/<project>/e/<slug>` →
|
||||
`/p/<project>/c/<default>/e/<slug>`. **Usable end-state:** the deployment runs
|
||||
exactly as before, now with a real collection layer and one extra path segment.
|
||||
**Completes:** `@S1` (the single-collection / single-project redirect skips).
|
||||
|
||||
- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the
|
||||
registry mirror to
|
||||
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
|
||||
endpoint (authorized by existing deployment owner/admin for now — the scoped
|
||||
role surface lands in S3); the project collection-directory at `/p/<project>/`;
|
||||
collection-scoped propose/serve under `/p/<project>/c/<collection>/`.
|
||||
**Usable end-state:** an admin creates a `bdd` collection beside the document
|
||||
one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
|
||||
an empty collection catalog).
|
||||
|
||||
- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
|
||||
most-permissive resolver (§B.2) over `{owner, contributor}` grants at
|
||||
`{global, project, collection}` (migration 030 adds the `global` scope_type),
|
||||
with grants applied administratively (DB / the Owner-authorized create
|
||||
surface); every write gate re-checked under the collection axis. **Plus the
|
||||
operator's S3 visibility requirements:** collection-grain visibility is
|
||||
enforced — a `gated` collection is hidden from the public (404, omitted from
|
||||
the directory) yet visible to scope-role contributors; a collection's
|
||||
visibility may be set only as strict or stricter than its project's
|
||||
(`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):**
|
||||
§B.1/§B.3's literal "deployment contributor = global RFC Contributor"
|
||||
contradicted the C.1 "hana" scenario and the M2 implicit-public baseline;
|
||||
resolved as — a plain granted account is a granted *account*, not a
|
||||
write-everywhere global role; "global RFC Contributor" is an explicit
|
||||
`scope_type='global'` grant; the implicit-public write baseline is
|
||||
grandfathered onto the migration-seeded `default` collection only (N=1
|
||||
preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable
|
||||
end-state:** a user granted RFC Contributor at a scope can contribute across
|
||||
exactly that subtree, Owners administer their subtree, and a collection can be
|
||||
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
|
||||
inheritance, union, no-negative-override).
|
||||
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** *(Shipped v0.43.0.)*
|
||||
The invite UI
|
||||
(Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it,
|
||||
with §15 notifications and the broader-scope-supersedes rule; the
|
||||
create-first-collection / propose-first empty states keyed to the actor's role.
|
||||
Modelled as a **direct grant** to an existing account looked up by email (the
|
||||
C.2 scenarios write the membership row immediately and §15-notify an existing
|
||||
user — no accept round-trip; inviting a not-yet-account email is out of S4
|
||||
scope, handled by the admin-create-invite path). **Usable end-state:** an Owner
|
||||
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
|
||||
of C.2 — invitation; plus the project/collection empty states C3.3–C3.5).
|
||||
|
||||
- **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)*
|
||||
The global-Owner
|
||||
**create-project** action (bot provisions a Gitea content repo + commits to
|
||||
`projects.yaml`); the deployment directory empty states. Modelled as a
|
||||
global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an
|
||||
explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the
|
||||
content repo defaults to `<id>-content` and is seeded with a `README.md` so
|
||||
`main` exists. The deployment payload gains `viewer.can_create_project` +
|
||||
`default_project_readable` so the directory renders the role-aware empty state
|
||||
rather than bouncing into an unreadable/absent default. **Usable end-state:**
|
||||
a global Owner stands up a new project end-to-end from the UI. **Completes:**
|
||||
`@S5` (the global-directory empty states C3.1–C3.2).
|
||||
|
||||
- **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type
|
||||
frontmatter + surfaces selected on the **collection's** `type`; request-to-join
|
||||
+ cross-collection inbox; per-collection `enabled_models`; the registry +
|
||||
manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
|
||||
the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in
|
||||
place). **Usable end-state:** the model is fully realized and merged into
|
||||
`SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's
|
||||
role focus).
|
||||
- *Shipped in S6 core (v0.45.0):* the SPEC merge, per-collection
|
||||
`enabled_models`, the type-driven entry noun (§22.4a item 2).
|
||||
- *Shipped as the S6 remainder (v0.46.0):* request-to-join + the
|
||||
cross-collection inbox (§22.8).
|
||||
- *Spec'd, not yet built — the last S6 item:* the per-type **frontmatter
|
||||
schemas** (§22.4a item 1) and **surfaces** (§22.4a item 3, the
|
||||
`specification` release-planning + `bdd` scenario/coverage views). The
|
||||
discovery/spec pass + BDD scenarios + slicing (S7a–S7c) are in
|
||||
[`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md).
|
||||
|
||||
### Slice → scenario index (the inverse of the `@S<n>` tags)
|
||||
|
||||
| Slice | Usable thing it ships | Completes (`@S<n>`) |
|
||||
|---|---|---|
|
||||
| **S1** | collection grain + default + redirects; N=1 unchanged | C3.7, C3.8 (`@S1`) |
|
||||
| **S2** | create + navigate + propose a 2nd collection | C3.6 (`@S2`) |
|
||||
| **S3** | scope-role enforcement across global/project/collection | C1.1–C1.8 (`@S3`) |
|
||||
| **S4** | invitation UI + role-aware empty states | C2.1–C2.7, C3.3–C3.5 (`@S4`) |
|
||||
| **S5** | in-app create-project + global directory | C3.1, C3.2 (`@S5`) |
|
||||
| **S6** | type surfaces, lifecycle, hardening, SPEC merge | type-specific (new) |
|
||||
|
||||
Each slice is a candidate single session: it lands a usable deployment and a
|
||||
runnable acceptance gate (`--tags @S<n>`). **S1 is the natural first session** —
|
||||
the coupled migration 029 + threading + redirect, sized as one usable increment
|
||||
(answering the in-session question: bundled, it is right-sized, not too much).
|
||||
|
||||
## E.1 (= §A.6) Migration — the default collection (the N=1 case)
|
||||
|
||||
A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 so it
|
||||
keeps running unchanged:
|
||||
|
||||
1. The existing `projects` row **stays as the project** (it already owns
|
||||
`content_repo` and its config-derived `id` from §22.13 step 1).
|
||||
2. A **default collection** (`id='default'`, `subfolder` = repo root) is created
|
||||
per project, inheriting that project's `type` / `initial_state` / visibility;
|
||||
those fields are then dropped from `projects`.
|
||||
3. Every entry-scoped `project_id` row is re-keyed with the default
|
||||
`collection_id` (PK `(project_id, slug)` → `(collection_id, slug)`).
|
||||
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
|
||||
the default collection, role-collapsed (§B.3).
|
||||
5. **308 redirects:** the shipped `/p/<project>/e/<slug>` →
|
||||
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
|
||||
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
|
||||
|
||||
Until a second collection is added, the deployment is functionally identical to
|
||||
before, with one extra path segment. This is the §20.4 upgrade-steps content for
|
||||
the release.
|
||||
|
||||
## E.2 Scope of the first implementation pass
|
||||
|
||||
Per the launch ask — "we don't need to get all permissions right yet, just have
|
||||
Owner at all levels, and RFC Contributor at the global, project, and RFC
|
||||
collection level" — the **role surface** this pass implements is exactly
|
||||
`{owner, contributor}` × `{global, project, collection}` (Part B), plus the
|
||||
unchanged per-entry layer. `viewer`, the owner/admin split, request-to-join
|
||||
nuances, and per-type role labels are deferred (§B.1 note).
|
||||
@@ -0,0 +1,446 @@
|
||||
# Solution Design: Configurable Collection Metadata (clean-doc tagging)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Author(s)** | Ben Stull |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `draft` |
|
||||
| **Version** | v0.1.6 |
|
||||
| **Source artifacts** | Reference modeled: retired **BDD Release Planner** (`wiggleverse/wiggleverse-ecomm-bdd-release-planner-app`, RETIRED 2026-06-04) · Related: [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md) (§22) · Corpus: ecomm Shopify-modeled BDD (`wiggleverse-ecomm-meta/research/shopify`, ~1,238 scenarios) · **Supersedes:** [`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md) |
|
||||
|
||||
**Change log**
|
||||
|
||||
| Date | Version | Change | By |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-06 | v0.1.0 | Initial draft from discovery session OHM-0079.0 | Ben Stull |
|
||||
| 2026-06-06 | v0.1.1 | Value-only Executive Summary; add Pain Points | Ben Stull |
|
||||
| 2026-06-06 | v0.1.2 | Business Outcomes restated as business (adoption/diversity); Business Use Cases → solution-agnostic | Ben Stull |
|
||||
| 2026-06-06 | v0.1.3 | Supersede per-type-surfaces draft (harvest patterns; bdd coverage future; §22.4a amendment); split Business Actors / Product Personas | Ben Stull |
|
||||
| 2026-06-06 | v0.1.4 | Two-part restructure: §1 Business Context (solution-agnostic, 1.1–1.9) + §2 Solution Proposal; renumber | Ben Stull |
|
||||
| 2026-06-06 | v0.1.5 | Move Business Actors to §1.3 (define roles before Problem/Pain reference them) | Ben Stull |
|
||||
| 2026-06-06 | v0.1.6 | §7.1 execution convention — each slice is its own writing-plans→executing-plans coding session, plans just-in-time | Ben Stull |
|
||||
|
||||
---
|
||||
|
||||
## 1. Business Context
|
||||
|
||||
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
|
||||
|
||||
### 1.1 Executive Summary
|
||||
|
||||
A deployment's corpus is only as valuable as the ability of the people running it to prioritise it, navigate it, and act on it — and as valuable as the downstream tools that can read structured signal out of it. Today that value is stranded: operators and contributors can't rank what matters or find content by what matters, and the tools meant to plan and build from the corpus have nothing structured to consume. The value at stake is **lower-friction corpus planning** for operators and contributors, **broader adoption** by teams whose document types the platform couldn't previously serve, and **a corpus external tooling can consume without bespoke glue**. *(Value summary; the solution is proposed in §2.)*
|
||||
|
||||
### 1.2 Background
|
||||
|
||||
The framework hosts RFC standardization for multiple deployments. One deployment hosts the ecomm BDD corpus — ~1,238 Shopify-modeled scenarios, one markdown file per scenario, slugged by feature ID (`DD-FF-NNNN-slug`). A standalone **BDD Release Planner** previously let operators search that corpus, attach metadata (priority P0–P3, owner, status), cluster scenarios into named releases, and emit each release as a roadmap phase. §22 (three-tier projects/collections) absorbed the planner's *corpus hosting* into rfc-app (the corpus now runs as a `bdd` project on the RFC deployment) and the planner was retired — but its *annotation* half (priority/tags on scenarios, filtering, bulk assignment) was never rebuilt. Teams evaluating rfc-app for *other* document types often need structured attributes (a priority, a status, domain tags) the platform can't yet express — so they go elsewhere.
|
||||
|
||||
### 1.3 Business Actors / Roles
|
||||
|
||||
Real-world roles, **solution-agnostic** — they exist whether or not rfc-app does. They are defined here, before the Problem (§1.4) and Pain Points (§1.5) reference them; the Business Use Cases (§1.9) are about these roles, and the Product Personas (§3) map onto them.
|
||||
|
||||
| Role | Responsible for (in the business) |
|
||||
| --- | --- |
|
||||
| Standards owner | Owns an organization's RFC / standards / requirements process; decides what's tracked and how |
|
||||
| Release planner | Decides what work belongs in upcoming releases |
|
||||
| Requirements author | Proposes and curates the requirements (e.g. BDD scenarios) |
|
||||
| Requirements consumer | A person or downstream tool that plans or builds from the requirements |
|
||||
| Reader | Anyone navigating the corpus to find what's relevant to them |
|
||||
|
||||
### 1.4 Problem Statement
|
||||
|
||||
rfc-app cannot express or surface structured signal about its content. Tags are free-form strings with no filtering; there is no notion of priority or any other collection-defined attribute; the catalog is a flat list; and what little metadata exists is mixed into the top of every document. As a result, a corpus cannot be prioritised, navigated by attribute, planned in bulk, or cleanly consumed by downstream tools — and teams whose workflows depend on such attributes cannot adopt the platform at all.
|
||||
|
||||
### 1.5 Pain Points
|
||||
|
||||
| # | Pain | Who feels it | Cost / frequency today |
|
||||
| --- | --- | --- | --- |
|
||||
| PP-1 | Scenarios carry no priority, so triage and planning happen off-platform, in spreadsheets and memory | Release planner, contributor | Every planning cycle; signal lives off-platform and goes stale |
|
||||
| PP-2 | The catalog is a flat, unfilterable list — at ~1,200 scenarios, "show me the P0 checkout scenarios" is impractical | Reader, release planner | Every browse/triage; finding the right work is slow and error-prone |
|
||||
| PP-3 | Tags are free-form with no filtering payoff, so they're decorative and go unmaintained | Contributor | Ongoing; the one existing affordance rots |
|
||||
| PP-4 | Annotating many scenarios means opening many PRs, so bulk planning has no home in the tool | Release planner | Every batch; the core planning gesture is effectively impossible |
|
||||
| PP-5 | rfc-app metadata clutters the top of every document, hurting readability and making the corpus awkward to consume cleanly | Reader, downstream consumer | Every read; every downstream integration |
|
||||
| PP-6 | Downstream tools have no structured signal to read — the retired planner's capability left a gap | Downstream consumer | Continuous since the planner's retirement |
|
||||
| PP-7 | Teams whose document types need structured attributes can't model them, so they don't adopt rfc-app | Prospective adopter (org/team) | Every evaluation that ends in "not yet" |
|
||||
|
||||
### 1.6 Targeted Business Outcomes
|
||||
|
||||
Business outcomes for rfc-app as a platform — adoption, reach, and diversity of use — **not** solution outputs. (Whether documents carry a priority is a solution output, tracked as a slice's Definition of Done in §7, not here.)
|
||||
|
||||
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Teams blocked by missing structured attributes now adopt rfc-app | # organizations on rfc-app; # active users | internal deployments only → external orgs onboard | existing deployments don't churn | deployment registry + usage analytics; quarterly |
|
||||
| The platform hosts a wider variety of workflows and document types | # distinct document/collection types & field schemas in use | today's handful → broader mix | existing types' experience unchanged | type/schema census; quarterly |
|
||||
| Corpus planning happens on-platform rather than in side tools | share of prioritisation/planning done in rfc-app vs spreadsheets | largely off-platform → on-platform | — | operator interviews + usage signals; quarterly |
|
||||
|
||||
### 1.7 Scope (business)
|
||||
|
||||
- **In scope:** the corpus can carry per-item importance and categorisation; people can find items by those attributes; the signal is captured durably and is consumable by other people and tools; teams with new document types can express the attributes their workflow needs.
|
||||
- **Out of scope (business):** deciding *what* a given deployment's priorities or categories should be (that's the deployment's editorial choice); release sequencing and ship tracking as a business process (stays a downstream/operator concern).
|
||||
- **Non-goals:** modelling "releases" as a first-class business object inside the platform.
|
||||
|
||||
*(Solution-specific scope/non-goals are in §2.)*
|
||||
|
||||
### 1.8 Assumptions · Constraints · Dependencies
|
||||
|
||||
- **Assumptions:** git remains the content source of truth and downstream consumers can read the corpus from git; the BDD grain is one markdown file per scenario (already true for the ecomm corpus).
|
||||
- **Constraints:** rfc-app is a framework hosting multiple deployments — any change must be **mechanical and non-breaking**, with §20 changelog/upgrade-steps; the hard secrets rule (§6.3) holds; edits must respect scope-role authorization (§22 Part B / S3); the §22.4a "engine unchanged" rule holds (INV-8).
|
||||
- **Dependencies:** the S3 scope-role resolver (`auth.effective_scope_role`); the existing git write-through used by `edit-meta` (§9.5); the §22 collection model; the binding `SPEC.md` §22.4a contract, which §2's solution amends (§7 SLICE-0).
|
||||
|
||||
### 1.9 Business Use Cases
|
||||
|
||||
Solution-agnostic: what an actor (§1.3) wants to accomplish, *why* (value), and what *success* looks like — **no reference to any product**. Each could be satisfied by a person by hand before any software. Form: "As a … I can … so that …".
|
||||
|
||||
**BUC-1 — As a release planner, I can prioritise the requirements in a body of work, so that I can decide what belongs in upcoming releases.**
|
||||
```gherkin
|
||||
Scenario: BUC-1 — Prioritise to plan releases
|
||||
Given a body of requirements of varying importance
|
||||
When the planner weighs which matter most
|
||||
Then they hold a ranking of those requirements by importance
|
||||
And can decide a release's contents from it
|
||||
```
|
||||
- **Acceptance:** the planner can select and justify the next release's contents from the relative importance of the work.
|
||||
|
||||
**BUC-2 — As a planner facing a large body of requirements, I can organise and triage it within a normal working session, so that planning actually gets done rather than deferred or improvised.**
|
||||
```gherkin
|
||||
Scenario: BUC-2 — Triage at scale
|
||||
Given more requirements than can be weighed one at a time
|
||||
When the planner ranks and groups them in bulk
|
||||
Then the body of work reflects those decisions without per-item drudgery
|
||||
```
|
||||
- **Acceptance:** a planner moves from an unsorted corpus to a prioritised plan in one sitting.
|
||||
|
||||
**BUC-3 — As a team, I want the importance and categorisation of our requirements captured durably and shareably, so that other people and tools can plan from it without re-deriving it.**
|
||||
```gherkin
|
||||
Scenario: BUC-3 — Durable, shareable signal
|
||||
Given requirements that have been weighed and categorised
|
||||
When someone or something else needs to plan from them
|
||||
Then they can read what matters and why without asking the original author
|
||||
```
|
||||
- **Acceptance:** a second party — person or tool — can pick up the work and plan from it unaided.
|
||||
|
||||
**BUC-4 — As a team with a specialised body of documents, I can capture the attributes that make them actionable (importance, status, category), so that I can manage that work the way my domain requires.**
|
||||
```gherkin
|
||||
Scenario: BUC-4 — Manage a domain's work on its own terms
|
||||
Given documents whose usefulness depends on domain-specific attributes
|
||||
When the team records and works with those attributes
|
||||
Then they can run their workflow with the distinctions it depends on
|
||||
```
|
||||
- **Acceptance:** the team can capture and act on the distinctions their domain requires — success is them choosing to manage the work this way.
|
||||
|
||||
**BUC-5 — As someone consuming a large corpus, I can find the items that matter to my current purpose, so that I act on the right things instead of wading through everything.**
|
||||
```gherkin
|
||||
Scenario: BUC-5 — Find what matters
|
||||
Given a large body of items
|
||||
When the consumer looks for the important ones for their task
|
||||
Then they can locate them quickly
|
||||
```
|
||||
- **Acceptance:** a person narrows a large corpus to the relevant, important subset for their task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Proposal
|
||||
|
||||
**The solution is to build it into rfc-app.** Give every collection a small, declared **field schema** (in its `.collection.yaml`) so it can carry structured metadata — priority, tags, and any custom fields the deployment defines. Store each entry's values in a **clean sidecar** file so the document body stays pure prose. rfc-app then **renders those fields as forms, filters the catalog by them (faceted, with counts), and lets authorized users tag in single and bulk gestures** committed straight to git; downstream tools read the values from the sidecars directly. It is one generic mechanism — tags and priority are just *fields* — not per-type special-casing and not a bespoke "release" entity.
|
||||
|
||||
**Why a software solution (and not a manual one).** A non-build alternative — operators maintaining priorities/tags in a shared spreadsheet — was considered and rejected: it leaves the corpus unfilterable in-tool (PP-2), keeps documents and the side-sheet out of sync, produces no durable git-readable signal for downstream tools (PP-5/PP-6), and does nothing for the adoption outcome (§1.6, PP-7). The value only lands if the structure lives with the content.
|
||||
|
||||
**Solution-specific scope.** *Out:* release ordering, ship status, roadmap emission, the `specification` release-planning surface — all downstream, reading sidecars from git. In-app management of field definitions (edit `.collection.yaml` in git for v1); corpus-wide tag rename/merge/delete; sub-document grain; a whole-corpus export endpoint. *Future (recorded, not v1):* a **bdd coverage surface** — a `verifies`-style **`ref` field type** plus a read-derived view mapping features to the spec sections they exercise (harvested from the superseded per-type-surfaces draft); deferred pending §9 Q4. This solution **amends the binding `SPEC.md` §22.4a contract** (§7 SLICE-0).
|
||||
|
||||
*(The Product and Engineering sections below — §§3–7 — elaborate this build. They would be replaced by an operational plan if the chosen solution were non-software.)*
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Personas
|
||||
|
||||
rfc-app's user types — each an embodiment of one or more Business Roles (§1.3). The Product Use Cases (§4) are about these personas.
|
||||
|
||||
| Product persona | In rfc-app | Maps to business role(s) |
|
||||
| --- | --- | --- |
|
||||
| Collection Owner | scope-role Owner; declares the collection's `fields:` schema (edits `.collection.yaml`) | Standards owner |
|
||||
| Contributor | scope-role contributor; sets metadata (single + bulk), proposes/curates entries | Requirements author; Release planner |
|
||||
| Reader | viewer; browses and filters the catalog | Reader |
|
||||
| Downstream consumer | an external system reading sidecars + `.collection.yaml` from git | Requirements consumer |
|
||||
|
||||
## 4. Product Use Cases
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-1 — Set priority/tags on a scenario (realizes BUC-1, BUC-4)
|
||||
Given I am a Contributor viewing a scenario whose collection defines priority and tags
|
||||
When I choose P0 in the priority control and add the tag "checkout"
|
||||
Then the metadata panel reflects P0 and the checkout tag
|
||||
And the change is committed directly to the scenario's sidecar
|
||||
|
||||
Scenario: PUC-2 — Bulk tag/untag from the catalog (realizes BUC-2)
|
||||
Given I have multi-selected several scenarios in the catalog
|
||||
When I choose "Set priority → P1" from the bulk action bar
|
||||
Then every selected scenario shows P1
|
||||
And the bulk change is one commit
|
||||
|
||||
Scenario: PUC-3 — Filter the catalog by facet (realizes BUC-5, BUC-1)
|
||||
Given the left pane shows faceted filters generated from the collection schema
|
||||
When I check Priority P0 and tag "checkout"
|
||||
Then the catalog shows only scenarios matching both
|
||||
And each facet value shows its result count
|
||||
|
||||
Scenario: PUC-4 — A Collection Owner declares fields (realizes BUC-4)
|
||||
Given a Collection Owner edits .collection.yaml to add a priority enum field
|
||||
When the collection is re-ingested
|
||||
Then the priority filter and the priority form control appear automatically
|
||||
|
||||
Scenario: PUC-5 — Migrate a collection to clean docs (product-only; enables BUC-3)
|
||||
Given a collection whose docs still carry top-of-doc frontmatter
|
||||
When the operator runs the frontmatter→sidecar migration
|
||||
Then each doc body becomes pure prose and a sidecar holds its metadata
|
||||
And rfc-app reads the collection identically before and after
|
||||
|
||||
Scenario: PUC-6 — A malformed entry is visibly fixable (realizes BUC-3)
|
||||
Given a stored entry whose metadata fails its collection's schema
|
||||
When the catalog renders
|
||||
Then the entry still loads (read never hard-fails)
|
||||
And it is flagged "malformed metadata" so a Contributor can fix it
|
||||
```
|
||||
|
||||
## 5. UX Layout
|
||||
|
||||
### 5.1 Screen: Catalog (left pane) (serves PUC-3, PUC-6)
|
||||
|
||||
- **Purpose:** browse and filter a collection's entries.
|
||||
- **Layout (top → bottom):** full-text search (existing); **faceted filter groups** (one per schema field + state): each a collapsible group with per-value **result counts** and multi-select checkboxes; `tags`-type fields include a "filter values…" search box to stay usable at 30+ values.
|
||||
- **States:** happy: facets with counts · empty: "no entries match" + clear-filters · loading: skeleton facets · error: retry · **malformed:** entries failing their schema carry a fixable marker (parallel to §22.4c `unreviewed`) and are filterable.
|
||||
|
||||
### 5.2 Screen: Scenario detail — metadata panel (serves PUC-1)
|
||||
|
||||
- **Purpose:** view/edit one entry's metadata.
|
||||
- **Layout:** one control per schema field — `enum` → single-select; `tags` → removable chips + add-tag input (with existing AI suggest); `text` → text input. The body renders below as pure prose; metadata never appears inline.
|
||||
- **States:** read (no edit role) shows values · edit (authorized) shows controls · saving: spinner · error: field-level validation message.
|
||||
|
||||
### 5.3 Screen: Catalog — bulk action bar (serves PUC-2)
|
||||
|
||||
- **Purpose:** apply a field value to many entries at once.
|
||||
- **Layout:** selecting ≥1 row reveals a sticky bar: "*N* selected · Set priority ▾ · Add tag ▾ · Remove tag ▾ · Clear". Applying commits once.
|
||||
- **States:** none selected: hidden · applying: progress · partial failure: toast naming entries that failed validation, others applied.
|
||||
|
||||
## 6. Technical Design
|
||||
|
||||
### 6.1 Invariants
|
||||
|
||||
- **INV-1:** The sidecar (`<slug>.meta.yaml`) is the source of truth for entry metadata; `cached_rfcs` is a derived index, fully rebuildable from git.
|
||||
- **INV-2:** A document body (`.md`) never contains rfc-app metadata once migrated; metadata lives only in the sidecar.
|
||||
- **INV-3:** Reading a collection never hard-fails on bad metadata — an invalid value surfaces as a warning, the entry still loads, and the catalog flags it (§5.1).
|
||||
- **INV-4:** Metadata writes are authorized by scope-role (contributor+ on the collection) and validated at the write boundary; content-body edits keep their existing PR-review path.
|
||||
- **INV-5:** A collection with no `fields:` block behaves exactly as today (free-form `tags` only). The §22.13 generated **default collection is `document`** with no fields → **N=1 deployments see zero change**.
|
||||
- **INV-6:** Dual-read: parser reads the sidecar if present, else legacy top-of-doc frontmatter, with identical resulting in-memory records.
|
||||
- **INV-7:** Unknown / forward-compat keys in a sidecar **ride along untouched** — never dropped on read or rewrite, never reported as malformed.
|
||||
- **INV-8:** **Engine unchanged** (§22.4a) — additive and read-mostly; never forks the content write path, the propose→branch→PR→graduate lifecycle, threads/flags/chat, or the storage model. Metadata edits reuse the existing `edit-meta` git write-through.
|
||||
|
||||
### 6.2 High-level architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Git[content repo]
|
||||
CY[.collection.yaml<br/>fields: schema]
|
||||
MD[slug.md<br/>prose body]
|
||||
SC[slug.meta.yaml<br/>values]
|
||||
end
|
||||
CY --> ING[ingest / parser<br/>lenient, type-agnostic]
|
||||
MD --> ING
|
||||
SC --> ING
|
||||
ING --> VAL[metadata_schema.validate<br/>advisory at read]
|
||||
VAL --> DB[(cached_rfcs<br/>values + facet counts + malformed)]
|
||||
DB --> API[API: schema · list+filter · facets · edit]
|
||||
API --> FILT[left-pane faceted filters]
|
||||
API --> PANEL[detail metadata panel]
|
||||
API --> BULK[bulk select bar]
|
||||
PANEL -->|validate + direct commit| SC
|
||||
BULK -->|validate + 1 commit| SC
|
||||
SC -.read from git.-> CONS[downstream consumers]
|
||||
```
|
||||
|
||||
- **ingest/parser** — reads `.collection.yaml` schema + sidecars (or legacy frontmatter), stays lenient/type-agnostic (INV-7); rebuilds `cached_rfcs`; never authoritative.
|
||||
- **`metadata_schema.validate(values, fields) → [problems]`** — the one place that knows a collection's required/forbidden fields and each field's shape (modeled on `registry.py`). Advisory at ingest (warn + malformed flag, INV-3); enforced at the write boundary (INV-4).
|
||||
- **API** — serves the schema, filtered lists with facet counts + malformed flag, and metadata edits; never writes metadata anywhere but the sidecar.
|
||||
|
||||
### 6.3 Data model & ownership
|
||||
|
||||
| Entity | Owned by | Key fields | System of record |
|
||||
| --- | --- | --- | --- |
|
||||
| Collection field schema | Collection Owner | `fields: {name → {type, values?, label}}` in `.collection.yaml` | git |
|
||||
| Entry metadata values | Contributor | sidecar `<slug>.meta.yaml`: lifecycle + schema fields + forward-compat keys (INV-7) | git (sidecar) |
|
||||
| Derived index | ingest | per-entry values + facet aggregations + `malformed` flag | `cached_rfcs` (SQLite, derived) |
|
||||
|
||||
**Field types (v1):** `enum` (single-select; controlled by required `values:`), `tags` (multi-value; free-form unless `values:` given), `text` (free string). **Future:** `ref` (a typed cross-entry link — basis for the deferred bdd `verifies`/coverage surface; §2, §9 Q4). Unknown types ignored with a warning.
|
||||
|
||||
**Sidecar example:**
|
||||
```yaml
|
||||
slug: 01-01-0001-view-today-s-key-performance-metrics-at-a-glance
|
||||
title: View today's key performance metrics at a glance
|
||||
state: active
|
||||
owners: [ben.stull]
|
||||
priority: P1
|
||||
tags: [dashboard, analytics]
|
||||
owner: hasan
|
||||
```
|
||||
|
||||
### 6.4 Interfaces & contracts
|
||||
|
||||
- **`GET …/collections/<c>`** — out: collection incl. `fields` schema.
|
||||
- **`GET …/collections/<c>/rfcs`** — in: filter params (`?priority=P0&tags=checkout&state=active`; OR within a field, AND across fields; `?malformed=true`) · out: entries with values + per-entry `malformed` + `facets: {field → {value → count}}`. Errors: 400 unknown field.
|
||||
- **`POST …/rfcs/<slug>/meta`** — in: `{field: value}` · effect: validate → write sidecar → direct commit → re-ingest. Errors: 403, 422.
|
||||
- **`POST …/collections/<c>/meta/bulk`** — in: `{slugs, op: set|add|remove, field, value}` · out: `{applied, rejected}` · effect: validate → write N sidecars → one commit → re-ingest. Errors: 403, 422.
|
||||
|
||||
### 6.5 Per–Product-Use-Case design
|
||||
|
||||
#### PUC-2 — Bulk tag/untag
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as Contributor
|
||||
participant C as Catalog UI
|
||||
participant A as API
|
||||
participant V as metadata_schema
|
||||
participant G as Git
|
||||
participant D as cached_rfcs
|
||||
U->>C: select rows, "Set priority P1"
|
||||
C->>A: POST /meta/bulk {slugs, set, priority, P1}
|
||||
A->>A: authz (scope-role)
|
||||
A->>V: validate values vs schema
|
||||
A->>G: write N sidecars, 1 commit
|
||||
A->>D: re-ingest affected entries
|
||||
A-->>C: {applied, rejected}
|
||||
C-->>U: rows show P1; toast on any rejected
|
||||
```
|
||||
|
||||
- **Implementation:** reuse the `edit-meta` git write-through, extended to target the sidecar and batch N files into one commit. Honors INV-1/INV-4/INV-8.
|
||||
|
||||
#### PUC-5 — Migration
|
||||
|
||||
- **Implementation:** a tool walks a collection; for each entry with legacy frontmatter it writes `<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per collection, idempotent, preserving unknown keys (INV-7). Dual-read (INV-6) lets it run anytime; lazy migration converts stragglers on first metadata edit.
|
||||
|
||||
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||
|
||||
- **Security & privacy:** edits gated by `auth.effective_scope_role`; no secrets in sidecars; git history records authorship.
|
||||
- **Performance & scale:** facet counts from the derived DB; responsive at ~1.2k entries with dozens of tag values.
|
||||
- **Availability & resilience:** bad metadata never blocks read (INV-3); failed re-ingest leaves git authoritative, recoverable by rebuild.
|
||||
- **Observability:** log each metadata commit; warn-log + count schema-validation failures on ingest.
|
||||
- **Accessibility:** facet groups and form controls keyboard-navigable; checkboxes labelled value + count.
|
||||
|
||||
### 6.7 Key decisions & alternatives considered
|
||||
|
||||
| Decision | Chosen | Alternatives | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Solution type | Build into rfc-app | Manual (shared spreadsheet) | Manual leaves corpus unfilterable, out of sync, no git-readable signal (§2) |
|
||||
| Release modeling | Metadata only; releases downstream | First-class release entity | Operator pulled ordering/ship-status out of rfc-app |
|
||||
| Tag system shape | One generic typed-field system | Releases first-class + simple tags; namespaced facets | Tags/priority/custom are all just fields |
|
||||
| Schema model (D9) | Pure collection-config | Type-driven hard-coded schemas (per-type-surfaces draft) | Flexible, data-driven |
|
||||
| Metadata storage | Sidecar per entry | Frontmatter; end-of-doc; index file; DB-only | Clean docs + git-visible + locality |
|
||||
| Left-pane filtering | Faceted groups with counts | Flat facet chips | Scales to ~1.2k-scenario, many-tag corpus |
|
||||
| Edit governance | Direct commit for authorized roles | PR per change | Bulk planning impractical via PR-per-toggle |
|
||||
| bdd coverage (D10) | Future per-type surface over a `ref` field | Build now; drop | Valuable but not v1; needs Q4 |
|
||||
|
||||
### 6.8 Testing strategy
|
||||
|
||||
Unit: schema parsing (all types, missing block); sidecar round-trip incl. unknown-key preservation (INV-7); dual-read equivalence (INV-6); validation; malformed-flag; facet aggregation; bulk op (single commit, partial-rejection). Two-tier local-Docker→PPE for API + git write-through. "Tested" = PUC acceptance scenarios pass + migration proven idempotent and reversible-on-read.
|
||||
|
||||
### 6.9 Failure modes, rollback & flags
|
||||
|
||||
- **Invalid value committed out-of-band** → ingest warns + loads with the value flagged malformed (INV-3).
|
||||
- **Re-ingest fails after commit** → git authoritative; full rebuild recovers.
|
||||
- **Migration rollback:** dual-read keeps an un-/partly-migrated corpus working; the migration commit is revertible.
|
||||
- **Feature flag:** inherently opt-in per collection (INV-5) — no global flag.
|
||||
|
||||
## 7. Delivery Plan
|
||||
|
||||
### 7.1 Approach / strategy
|
||||
|
||||
Amend the binding contract first, then build storage/compat, then schema, then read, then write. Each build slice is shippable and non-breaking.
|
||||
|
||||
**Execution convention.** Each slice is taken as **its own coding session** — `writing-plans → executing-plans → verify → ship/deploy → merge + version bump` — in dependency order, with the slice's implementation plan written **just-in-time** at the start of that session, not up front (later slices' plans depend on the code earlier slices land). `brainstorming` ran once to produce this spec and recurs only if a slice proves the spec wrong. A slice's **Definition of Done** (§7.2) is the signal to advance the `Next /goal:` cursor to the next slice. SLICE-0 is doc-only (no implementation plan).
|
||||
|
||||
### 7.2 Slicing plan
|
||||
|
||||
#### SLICE-0 — Amend `SPEC.md` §22.4a (contract) → unblocks the rest
|
||||
- **Depends on:** —
|
||||
- **Definition of done:** §22.4a reframed — item 1 (entry schema) is **collection-configured sidecar fields**, not type-driven frontmatter; item 3 (type surfaces) deferred to a future design (bdd coverage recorded); per-type-surfaces draft marked superseded; §20 changelog. *Doc-only; no code.*
|
||||
|
||||
#### SLICE-1 — Sidecar storage + dual-read + migration → completes PUC-5, PUC-6
|
||||
- **Depends on:** SLICE-0
|
||||
- **DoD:** parser reads sidecar-else-legacy (INV-6), preserves unknown keys (INV-7); migration tool idempotent; existing collections load byte-identically; malformed flag derived; tests green.
|
||||
|
||||
#### SLICE-2 — Collection field schema + central validation → completes PUC-4
|
||||
- **Depends on:** SLICE-1
|
||||
- **DoD:** `.collection.yaml fields:` parsed; `metadata_schema.validate` advisory at read / enforced at write; schema served via the collection API; no-`fields:` collections unchanged (INV-5).
|
||||
|
||||
#### SLICE-3 — Faceted left-pane filtering (read) → completes PUC-3
|
||||
- **Depends on:** SLICE-2
|
||||
- **DoD:** list endpoint returns facet counts + honors filter params (incl. `malformed`); left pane renders faceted groups with counts + tag-value search; filters compose.
|
||||
|
||||
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
|
||||
- **Depends on:** SLICE-2
|
||||
- **DoD:** detail panel renders schema controls; `POST …/meta` validates, direct-commits, re-ingests; scope-role gated (INV-4); lazy-migrates a legacy entry on first edit.
|
||||
|
||||
#### SLICE-5 — Bulk tag/untag → completes PUC-2
|
||||
- **Depends on:** SLICE-3, SLICE-4
|
||||
- **DoD:** multi-select + bulk bar; `POST …/meta/bulk` applies set/add/remove as one commit; partial-rejection reported.
|
||||
|
||||
### 7.3 Rollout / launch plan
|
||||
|
||||
Pre-v1, single production: ship slices in order; each minor bump carries §20 changelog + upgrade steps. Opt-in per collection (INV-5): a deployment adopts it only by declaring a `fields:` block and (optionally) running the migration.
|
||||
|
||||
### 7.4 Risks & mitigations
|
||||
|
||||
| Risk | L/I | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Amending binding §22.4a destabilises a shipped contract | M/M | SLICE-0 doc-only, reviewed; dual-read keeps runtime non-breaking; supersede note preserves rationale |
|
||||
| Frontmatter→sidecar migration corrupts content | L/H | Dual-read; idempotent, revertible migration; body-byte-identity + unknown-key tests |
|
||||
| Doubling file count (sidecars) clutters corpus | M/L | Docs stay clean; sidecars small/co-located |
|
||||
| Direct-commit metadata edits bypass review | M/M | Scope-role gate (INV-4); content-body edits still PR'd; git audit trail |
|
||||
| Facet aggregation slow at scale | L/M | Compute from indexed derived DB; measure at ~1.2k entries |
|
||||
|
||||
## 8. Traceability matrix
|
||||
|
||||
| Pain | Business UC | Product UC | Slice | Tests |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| — (contract) | — | — | SLICE-0 | (doc review) |
|
||||
| PP-5 | BUC-3 | PUC-5, PUC-6 | SLICE-1 | `test_dual_read_equiv`, `test_migration_idempotent`, `test_unknown_keys_preserved` |
|
||||
| PP-7 | BUC-4 | PUC-4 | SLICE-2 | `test_schema_parse`, `test_validate` |
|
||||
| PP-2 | BUC-5, BUC-1 | PUC-3 | SLICE-3 | `test_facet_counts`, `test_filter_compose` |
|
||||
| PP-1, PP-3 | BUC-1, BUC-4 | PUC-1 | SLICE-4 | `test_single_meta_commit`, `test_authz` |
|
||||
| PP-4 | BUC-2 | PUC-2 | SLICE-5 | `test_bulk_one_commit`, `test_partial_reject` |
|
||||
| PP-6 | BUC-3 | (consumer reads git) | — | `test_sidecar_schema_stable` |
|
||||
|
||||
## 9. Open Questions & Decisions log
|
||||
|
||||
**Open**
|
||||
|
||||
| # | Question | Owner | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| Q1 | Do downstream consumers read sidecars from git, via API, or both? (leaning git) | Ben | nothing v1 |
|
||||
| Q2 | Ship `multi-enum` (multi-select controlled) in v1 or later? | Ben | SLICE-2 scope |
|
||||
| Q3 | Exact §22.4a amendment wording + the future-surfaces home | Ben | SLICE-0 |
|
||||
| Q4 | bdd coverage: `ref` field grammar + a coverage view honoring §22's no-cross-collection-join rule as hyperlinks | Ben | future surface |
|
||||
|
||||
**Resolved**
|
||||
|
||||
| # | Decision | Resolution | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | Release behaviors | Out of rfc-app; downstream | 2026-06-06 |
|
||||
| D2 | Tag system shape | Approach A — one generic typed-field system | 2026-06-06 |
|
||||
| D3 | Metadata grain | Per entry (corpus already one file per scenario) | 2026-06-06 |
|
||||
| D4 | Schema location | `.collection.yaml` `fields:` block | 2026-06-06 |
|
||||
| D5 | Value storage | Sidecar `<slug>.meta.yaml`; doc body pure prose | 2026-06-06 |
|
||||
| D6 | Left-pane filtering | Faceted groups with counts | 2026-06-06 |
|
||||
| D7 | Edit governance | Direct commit for authorized roles; bulk = 1 commit | 2026-06-06 |
|
||||
| D8 | Management scope | Deferred; edit `.collection.yaml` in git for v1 | 2026-06-06 |
|
||||
| D9 | Schema model | Pure collection-config; not type-driven | 2026-06-06 |
|
||||
| D10 | bdd coverage | Future per-type surface over a `ref` field; not v1 | 2026-06-06 |
|
||||
| D11 | per-type-surfaces draft | Superseded; §22.4a to be amended (SLICE-0) | 2026-06-06 |
|
||||
|
||||
## 10. Glossary & References
|
||||
|
||||
- **Sidecar** — `<slug>.meta.yaml`, the per-entry metadata file that is the source of truth; keeps the `.md` body pure prose.
|
||||
- **Field schema** — the `fields:` block in `.collection.yaml` declaring a collection's typed metadata fields.
|
||||
- **Facet** — a schema field surfaced as a left-pane filter group with per-value counts.
|
||||
- **Malformed metadata** — stored values that fail their collection's schema; flagged in the catalog, never a hard read failure (INV-3).
|
||||
- **Downstream consumer** — an external tool that reads corpus metadata from git; rfc-app does not model releases.
|
||||
- **References:** retired BDD Release Planner; superseded per-type-surfaces draft (`2026-06-06-per-type-surfaces.md`); §22 three-tier design; `SPEC.md` §7.1 (left-pane filter), §9.5 (edit-meta), §20 (versioning), §22.4a (per-type contract — to be amended), §22 Part B / S3 (scope-role).
|
||||
```
|
||||
@@ -0,0 +1,366 @@
|
||||
# Draft spec — §22.4a per-type surfaces (the last S6 item)
|
||||
|
||||
> # ⛔ SUPERSEDED (2026-06-06)
|
||||
>
|
||||
> This draft is **superseded by**
|
||||
> [`2026-06-06-configurable-collection-metadata.md`](./2026-06-06-configurable-collection-metadata.md),
|
||||
> which reframes §22.4a item 1 as **collection-configured** metadata in
|
||||
> **sidecars** (not type-driven frontmatter) and defers item 3's surfaces.
|
||||
> Harvested into the successor: the validation seam (A.1), the malformed-metadata
|
||||
> catalog flag (A.5), unknown-fields-ride-along (C.1), the engine-unchanged rule
|
||||
> (§0), and the N=1 `document` backcompat anchor (A.2). The **bdd coverage**
|
||||
> capability (`feature`/`verifies` → coverage view, Part B.2) is preserved there
|
||||
> as a *future* per-type surface over a generic `ref` field. The binding
|
||||
> `SPEC.md` §22.4a contract is to be amended by the successor's SLICE-0. Kept for
|
||||
> historical rationale; do not build from this document.
|
||||
|
||||
> **Status:** discovery/spec pass — *not yet sliced into a shipped release.*
|
||||
> Author session: 0083 (2026-06-06). This document is the spec pass the §22 S6
|
||||
> remainder called for: it specifies **§22.4a item 1** (the per-type entry
|
||||
> **frontmatter schema**) and **§22.4a item 3** (the per-type **surfaces**) for
|
||||
> the `specification` and `bdd` collection types, with BDD-style acceptance
|
||||
> scenarios and a delivery slicing, so a later coding session can build them
|
||||
> against a written contract rather than improvising.
|
||||
>
|
||||
> It is the sibling of
|
||||
> [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md)
|
||||
> (the three-tier model, S1–S6 core, shipped through v0.46.0) and refines, in
|
||||
> implementable detail, what `SPEC.md` §22.4a states at the contract level.
|
||||
> §22.4a **item 2** (the type-driven entry noun / terminology) shipped in the S6
|
||||
> core (v0.45.0) and is out of scope here.
|
||||
|
||||
## 0. Why this is its own pass
|
||||
|
||||
`SPEC.md` §22.4a says a collection's immutable `type` selects exactly three
|
||||
things: (1) the entry **frontmatter schema**, (2) the **terminology**, and (3)
|
||||
the **type-specific surfaces**. The S6 core shipped (2) and merged the contract
|
||||
into `SPEC.md`; it also shipped the type *plumbing* — `collections.type` is
|
||||
immutable, validated (`registry.VALID_TYPES = {document, specification, bdd}`),
|
||||
and drives the entry noun. What it did **not** ship is any *behavior* keyed on
|
||||
type beyond the noun: every type today parses the same §2 baseline frontmatter
|
||||
(`backend/app/entry.py` is type-agnostic and lenient about unknown keys) and
|
||||
renders the same §7 catalog with no type-specific surface.
|
||||
|
||||
Items 1 and 3 were deliberately deferred at v0.45.0 (CHANGELOG: "they want a
|
||||
discovery/spec pass first, lacking BDD scenarios in Part C"). The three-tier
|
||||
design doc's Part C scenarios are all about **roles** (C.1–C.3); there are no
|
||||
scenarios describing what a `specification` release-planning view *does* or what
|
||||
a `bdd` coverage view *shows*. This document supplies them.
|
||||
|
||||
The governing constraint from §22.4a, which every proposal below honors:
|
||||
|
||||
> 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. … the engine itself treats every entry as
|
||||
> markdown + frontmatter regardless of type.
|
||||
|
||||
So a per-type surface is **additive and read-mostly**: it reads the (now
|
||||
type-aware) frontmatter and presents a derived view. It never forks the write
|
||||
path, the PR lifecycle, or the storage model.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Item 1: the per-type frontmatter schema
|
||||
|
||||
## A.1 Where validation lives today, and where it should land
|
||||
|
||||
`entry.py:parse()` reads a fixed set of §2 baseline fields and is **lenient**:
|
||||
unknown keys are ignored, future fields ride along untouched (its own docstring
|
||||
says so). That leniency is the seam. The per-type schema is layered as a
|
||||
**validator**, not a parser rewrite:
|
||||
|
||||
- `entry.py` keeps parsing the union of all known fields into the `Entry`
|
||||
dataclass (add the new optional fields below; absent → `None`/default, exactly
|
||||
as `models`/`funder`/`unreviewed` already do). The parser stays type-agnostic.
|
||||
- A new **`entry_schema.py`** module exposes `validate(entry, collection_type)
|
||||
-> list[str]` returning human-readable problems (empty = valid). It is the one
|
||||
place that knows which fields a type **requires**, which it **forbids**, and
|
||||
the **enum/shape** of each.
|
||||
- Validation is **advisory at parse, enforced at the write boundary.** The
|
||||
propose/edit/PR-merge paths (§9.1, §22.4b) call `entry_schema.validate` and
|
||||
surface problems the way the propose modal already surfaces field errors. A
|
||||
malformed historical file still *parses* (we never hard-fail a read — a
|
||||
deployment's existing corpus must keep loading), but the catalog flags it
|
||||
(§A.4) and the next write must fix it.
|
||||
|
||||
This mirrors how visibility/initial_state are validated centrally in
|
||||
`registry.py` rather than at each call site.
|
||||
|
||||
## A.2 `document` — unchanged (the §2 baseline)
|
||||
|
||||
`document` is the baseline: the §2 fields exactly as today
|
||||
(`slug, title, state, id, repo, proposed_by, proposed_at, owners, arbiters,
|
||||
tags`, plus the §6.6/§6.7 `models`/`funder` and §22.4c `unreviewed`/`reviewed_*`).
|
||||
No new fields, no type-specific surface. The §22.13 generated default collection
|
||||
is `document`, so **N=1 deployments see zero change** — the load-bearing
|
||||
backcompat guarantee.
|
||||
|
||||
## A.3 `specification` — versioned-spec metadata
|
||||
|
||||
A `specification` entry is a versioned technical spec (the archetype is this
|
||||
framework's own `SPEC.md`). Frontmatter **adds** (all optional at parse,
|
||||
required/validated per A.1 at write):
|
||||
|
||||
| Field | Shape | Meaning | Required when |
|
||||
|---|---|---|---|
|
||||
| `spec_version` | semver string (`MAJOR.MINOR.PATCH`) | the entry's own version | `state = active` |
|
||||
| `lifecycle` | enum `draft \| active \| superseded` | spec lifecycle, **orthogonal to** the §2.4 entry `state` | always (defaults `draft`) |
|
||||
| `supersedes` | list of slugs (in this collection) | specs this one replaces | optional |
|
||||
|
||||
Notes / decisions:
|
||||
|
||||
- **`lifecycle` ≠ `state`.** The §2.4 `state` (super-draft/active/withdrawn) is
|
||||
the *engine's* workflow position; `lifecycle` is the *spec's* editorial status.
|
||||
An `active` (graduated) entry can be `lifecycle: draft` (published but not yet
|
||||
ratified) or `superseded`. Keeping them orthogonal avoids overloading the
|
||||
shared state machine (the §22.4a "engine unchanged" rule).
|
||||
- **`supersedes` is validated as in-collection slugs** (§22.14 §2: slugs are
|
||||
unique *per collection*). A `superseded` lifecycle with no inbound
|
||||
`supersedes` from a newer entry is a soft warning in the surface, not a write
|
||||
error (the replacement may land later).
|
||||
- `spec_version` uses the same semver vocabulary as the framework `VERSION`/§20
|
||||
so the release surface (A.5 / Part B) can sort and group.
|
||||
|
||||
## A.4 `bdd` — feature/scenario metadata
|
||||
|
||||
A `bdd` entry states a feature as Given/When/Then scenarios. Frontmatter
|
||||
**adds**:
|
||||
|
||||
| Field | Shape | Meaning | Required when |
|
||||
|---|---|---|---|
|
||||
| `feature` | string | the feature's one-line statement (the "In order to / As a / I want" intent) | `state = active` |
|
||||
| `verifies` | list of refs | the `specification` entries/sections this feature exercises | optional |
|
||||
| `scenarios` | derived, **not** frontmatter | count/list parsed from the body's `Scenario:` blocks | n/a |
|
||||
|
||||
Notes / decisions:
|
||||
|
||||
- **`verifies` is a cross-collection ref.** A ref is `"<collection>/<slug>"` or
|
||||
`"<collection>/<slug>#<anchor>"`. The default `<collection>` is a sibling
|
||||
`specification` collection in the same project; an unqualified `<slug>` means
|
||||
"a spec slug in this project's specification collection" (resolved at render).
|
||||
This is the one place a `bdd` surface reaches across collections — and §22's
|
||||
"no app surface joins across collections" rule (`SPEC.md` line 5016) is
|
||||
**honored**: `verifies` is a *declared link rendered as a hyperlink*, not a
|
||||
query that fuses two corpora. The coverage view (B.2) aggregates these links
|
||||
but each entry still lives in exactly one collection.
|
||||
- **Scenarios are parsed from the body, not frontmatter.** Gherkin-style
|
||||
`Scenario:` / `Given`/`When`/`Then` lines in the markdown body are the source
|
||||
of truth; the surface counts and lists them. This keeps the authoring
|
||||
experience plain-markdown (the engine's invariant) — no structured
|
||||
scenario-editor write path.
|
||||
- `bdd` collections default `initial_state: active` (§22.4b) so a feature lands
|
||||
active-but-`unreviewed`; the schema validator therefore requires `feature` for
|
||||
active entries, which is every freshly-landed `bdd` entry.
|
||||
|
||||
## A.5 Schema surfacing in the existing chrome
|
||||
|
||||
Item-1 work is mostly invisible plumbing, but two small surfaces make it real
|
||||
without waiting for Part B:
|
||||
|
||||
1. **Propose/edit validation** — the propose modal and edit-branch flow run
|
||||
`entry_schema.validate` for the collection's type and block submit on errors
|
||||
(e.g. proposing into a `specification` collection without a `lifecycle`).
|
||||
2. **A "malformed frontmatter" catalog flag** — the §7 catalog marks entries
|
||||
whose stored frontmatter fails its type's schema (parallel to the §22.4c
|
||||
`unreviewed` filter), so a corpus migrated from `document`→… or hand-edited
|
||||
is visibly fixable.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Item 3: the type-specific surfaces
|
||||
|
||||
A surface is an **additional view** layered on the shared §7 catalog +
|
||||
§8 entry view, selected on `collection.type`. It is read-derived from
|
||||
frontmatter + body; it adds no write path the engine doesn't already have.
|
||||
|
||||
## B.1 `specification` → the release-planning surface
|
||||
|
||||
§22.4a: "group entries/changes into versioned releases with a changelog +
|
||||
§20-style upgrade-steps per release." Concretely, a per-collection
|
||||
**Releases** view at `/p/<project>/c/<collection>/releases`:
|
||||
|
||||
- **A release** is a named, ordered version (e.g. `0.46.0`) with: the set of
|
||||
spec entries at a given `spec_version`/`lifecycle`, a changelog body, and an
|
||||
optional upgrade-steps block (the §20.4 RFC-2119 convention reused verbatim).
|
||||
- **Source of truth = the content repo**, per §22.2/§22.3. A release is a file
|
||||
in the collection's subfolder (proposal: `releases/<version>.md`,
|
||||
frontmatter `version` + `released_at` + `entries: [slug@spec_version, …]`,
|
||||
body = changelog + upgrade-steps). The registry mirror caches a `releases`
|
||||
table the way it caches `collections` — git is truth, the table is a cache
|
||||
(§22.2 "never written except by the mirror").
|
||||
- **The view** lists releases newest-first; each expands to its changelog +
|
||||
upgrade-steps and the entries it cut. An Owner (scope-role, §22.6) can cut a
|
||||
new release (a bot-committed file, exactly like create-collection commits a
|
||||
manifest — §22 S5 pattern); contributors read.
|
||||
- **Reuse, don't reinvent:** the changelog + upgrade-steps renderer is the same
|
||||
markdown the framework's own `CHANGELOG.md`/§20.4 uses; the "cut a release"
|
||||
write is the §22 S5 bot-commit-then-mirror pattern.
|
||||
|
||||
Deliberately **out of this surface** (deferred): cross-release diffing, automated
|
||||
version bumping, dependency graphs between specs. The MVP is "see the releases,
|
||||
their changelog, their upgrade-steps, and what each contained."
|
||||
|
||||
## B.2 `bdd` → the scenario/acceptance + coverage surfaces
|
||||
|
||||
§22.4a: "a scenario/acceptance view and a coverage view mapping features to the
|
||||
spec sections they exercise." Two read-derived views:
|
||||
|
||||
1. **Scenario/acceptance view** (per entry, on the §8 entry page): renders the
|
||||
body's parsed `Scenario:` blocks as a structured checklist — each scenario's
|
||||
Given/When/Then, plus the entry's `feature` line as the header. No new
|
||||
storage; pure body parse. This is the `bdd` analogue of the `document`
|
||||
entry's prose view.
|
||||
2. **Coverage view** (per collection, at
|
||||
`/p/<project>/c/<collection>/coverage`): a matrix of **features → the spec
|
||||
entries/sections they `verifies`**. Rows are this collection's `bdd` entries;
|
||||
columns (or grouped rows) are the referenced `specification` entries. Cells
|
||||
show "covered / declared-but-spec-missing / spec-section-with-no-feature".
|
||||
The view aggregates the `verifies` links (A.4) across the collection but
|
||||
renders each as a hyperlink into the spec collection — it does not fuse the
|
||||
corpora (the §22 cross-collection rule, B/A.4).
|
||||
|
||||
Deliberately **out of this surface** (deferred): executing scenarios, CI/test
|
||||
result ingestion, auto-detecting coverage from code. The MVP maps *declared*
|
||||
coverage (`verifies`), surfacing gaps for humans to close.
|
||||
|
||||
## B.3 How a surface is selected and routed
|
||||
|
||||
- The collection payload already carries `type` and `entry_noun`
|
||||
(`GET /api/projects/:id/collections/:cid`). The frontend's `ProjectLayout` /
|
||||
collection chrome reads `type` and mounts the type's surface routes
|
||||
(`releases` for `specification`; `coverage` for `bdd`) alongside the shared
|
||||
catalog. `document` mounts none.
|
||||
- Backend: a per-type router group (`api_releases.py`, `api_coverage.py`)
|
||||
guarded by the same §22.5 read gates as the rest of the collection; the
|
||||
release-cut write reuses `auth.is_collection_superuser` / the S5 bot pattern.
|
||||
- The "per-type module the framework selects on `collection.type`" (§22.4a) is
|
||||
realized as: backend `entry_schema.py` (item 1) + the two router groups
|
||||
(item 3), and frontend a `typeModules[type]` map of `{ schema, surfaces }`.
|
||||
Adding a future type = a new map entry + enum value, no rebuild (§22.4a "open
|
||||
set").
|
||||
|
||||
---
|
||||
|
||||
# Part C — Behavioral scenarios (BDD)
|
||||
|
||||
> Tagged for the proposed slices in Part D (`@S7a` = item 1 schemas; `@S7b` =
|
||||
> specification releases; `@S7c` = bdd surfaces). These are the acceptance gate
|
||||
> the implementing session writes tests against, in the Part C style of the
|
||||
> three-tier doc.
|
||||
|
||||
## C.1 Per-type frontmatter schema (`@S7a`)
|
||||
|
||||
```gherkin
|
||||
Scenario: document collection is unchanged
|
||||
Given a "document" collection
|
||||
When a contributor proposes an entry with the §2 baseline frontmatter only
|
||||
Then the proposal is accepted with no schema error
|
||||
|
||||
Scenario: specification entry requires a lifecycle
|
||||
Given a "specification" collection
|
||||
When a contributor proposes an entry with no `lifecycle`
|
||||
Then it defaults to lifecycle "draft" and is accepted
|
||||
And when an Owner graduates it to active without a `spec_version`
|
||||
Then the write is blocked with "spec_version is required for an active specification"
|
||||
|
||||
Scenario: bdd entry requires a feature statement once active
|
||||
Given a "bdd" collection whose initial_state is "active"
|
||||
When a contributor proposes an entry with no `feature`
|
||||
Then the write is blocked with "feature is required for a bdd entry"
|
||||
|
||||
Scenario: unknown future field still rides along
|
||||
Given any collection
|
||||
When an entry carries a frontmatter key no schema names
|
||||
Then it parses unchanged and is not reported as malformed
|
||||
|
||||
Scenario: malformed existing entry loads but is flagged
|
||||
Given a stored specification entry missing a required field
|
||||
When the catalog renders
|
||||
Then the entry still loads (read never hard-fails)
|
||||
And the catalog marks it "malformed frontmatter"
|
||||
```
|
||||
|
||||
## C.2 specification release-planning surface (`@S7b`)
|
||||
|
||||
```gherkin
|
||||
Scenario: an Owner cuts a release
|
||||
Given a "specification" collection with two active entries
|
||||
When the collection Owner cuts release "1.0.0" with a changelog and upgrade-steps
|
||||
Then a releases/1.0.0.md file is committed to the content repo
|
||||
And the registry mirror caches the release
|
||||
And the Releases view lists "1.0.0" newest-first with its changelog + upgrade-steps
|
||||
|
||||
Scenario: a contributor reads releases but cannot cut one
|
||||
Given a contributor (not Owner) in the collection
|
||||
Then the Releases view is read-only (no "Cut release" control)
|
||||
|
||||
Scenario: upgrade-steps render with the §20.4 convention
|
||||
Given a release whose body uses MUST/SHOULD/MAY upgrade-steps
|
||||
Then they render with the same normative-language styling as CHANGELOG.md
|
||||
```
|
||||
|
||||
## C.3 bdd scenario + coverage surfaces (`@S7c`)
|
||||
|
||||
```gherkin
|
||||
Scenario: an entry's scenarios render as an acceptance checklist
|
||||
Given a "bdd" entry whose body has two Scenario: blocks
|
||||
When the entry page renders
|
||||
Then it shows the `feature` header and both scenarios' Given/When/Then
|
||||
|
||||
Scenario: coverage maps features to the specs they verify
|
||||
Given a "bdd" entry that `verifies: ["spec/auth#sessions"]`
|
||||
And a sibling "specification" collection "spec" containing entry "auth"
|
||||
When the coverage view renders
|
||||
Then a row links the feature to spec/auth#sessions as covered
|
||||
|
||||
Scenario: a declared ref to a missing spec is surfaced as a gap
|
||||
Given a "bdd" entry that `verifies: ["spec/ghost"]` where no such spec exists
|
||||
Then the coverage view marks that ref "declared but spec missing"
|
||||
|
||||
Scenario: coverage does not fuse corpora
|
||||
Then each cell is a hyperlink into the spec collection
|
||||
And no entry from the spec collection is listed as if it belonged to the bdd collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part D — Delivery slicing
|
||||
|
||||
Each slice lands a usable increment + a runnable acceptance gate (`--tags @S7x`),
|
||||
in the three-tier doc's slicing style. Suggested order (item 1 first — the
|
||||
surfaces read its fields):
|
||||
|
||||
- **S7a — per-type frontmatter schema (item 1).** `entry_schema.py` +
|
||||
the new optional `Entry` fields + write-boundary validation + the catalog
|
||||
"malformed" flag. **Usable:** proposing into a typed collection is validated;
|
||||
N=1 `document` unchanged. **Completes:** `@S7a` (C.1). *Non-breaking, additive.*
|
||||
- **S7b — specification release planning (item 3a).** `releases/<v>.md` storage
|
||||
+ registry mirror + `api_releases.py` + the Releases view + cut-release write.
|
||||
**Usable:** a spec collection has versioned releases with changelog +
|
||||
upgrade-steps. **Completes:** `@S7b` (C.2).
|
||||
- **S7c — bdd scenario + coverage surfaces (item 3b).** Body scenario parser +
|
||||
the per-entry acceptance view + the per-collection coverage view +
|
||||
`api_coverage.py`. **Usable:** a bdd collection shows scenarios and declared
|
||||
coverage. **Completes:** `@S7c` (C.3).
|
||||
|
||||
All three are **additive** (new optional frontmatter, new tables that are pure
|
||||
caches, new read views): each is a minor, non-breaking release, and a `document`
|
||||
N=1 deployment is unaffected by any of them. None touches the engine, the PR
|
||||
lifecycle, or the role model — they consume the §22 three-tier + §22.6 role
|
||||
work already shipped.
|
||||
|
||||
## D.1 Open questions for the implementing session
|
||||
|
||||
1. **Release identity vs. entry `spec_version`.** Should a release's `entries`
|
||||
pin exact `slug@spec_version` (immutable snapshot) or just slugs (live)? This
|
||||
doc proposes the pinned snapshot; confirm against a real spec-collection
|
||||
workflow before building S7b.
|
||||
2. **`verifies` ref grammar.** `"<collection>/<slug>#<anchor>"` is proposed;
|
||||
anchor resolution into a spec entry's section needs the spec body to carry
|
||||
stable anchors. May want a lightweight `## §n` anchor convention on
|
||||
`specification` entries first.
|
||||
3. **Whether `lifecycle` belongs in the shared state machine after all.** Kept
|
||||
orthogonal here; revisit if product wants `superseded` to gate the catalog.
|
||||
|
||||
These are genuine product decisions a discovery/spec session (or the operator)
|
||||
should settle before S7b/S7c code; S7a (schemas) is unblocked and buildable now.
|
||||
@@ -0,0 +1,583 @@
|
||||
# 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 entries under `rfcs/`, with
|
||||
its own per-project slug namespace (the slug is the identity — §22.4), a
|
||||
declared **type** (§22.4a), catalog, philosophy, branding,
|
||||
member roster, and model universe. The deployment is the substrate the
|
||||
projects share — one Gitea org, one bot, one account system, one inbox, one
|
||||
running process — and the surface a visitor first lands on.
|
||||
|
||||
Everything §§1–21 describe about *a corpus* is now *a project*. Everything
|
||||
they describe about *a deployment* that is not corpus-specific — accounts,
|
||||
the §6 admission gate, the §15 inbox, the §1 bot — stays at the deployment
|
||||
level and is shared across projects. The numbered sections that assume a
|
||||
single corpus are amended in Part B; §22 is the binding model they defer to.
|
||||
|
||||
> **Multi-project change (target: next minor — supersedes the original
|
||||
> single-corpus model).** §1 originally said "for a deployment, this single
|
||||
> repository is its content repository." A deployment now has a **registry**
|
||||
> (§22.2) naming N content repositories, one per project. The single-corpus
|
||||
> deployment is the **N=1 case** and continues to run after migration via a
|
||||
> generated default project (§22.13); no deployment is forced to adopt more
|
||||
> than one project. Where earlier sections say "the meta repo" or "the
|
||||
> corpus," read "the project's content repo" and "the project's corpus."
|
||||
|
||||
### 22.1 The deployment ⇄ project relation
|
||||
|
||||
One deployment, N projects (N ≥ 1). A project belongs to exactly one
|
||||
deployment and never moves between deployments. Projects within a deployment
|
||||
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
|
||||
belongs to exactly one project, and no app surface joins across projects
|
||||
except the per-account ones the deployment owns (the §15 inbox, the §6
|
||||
account roster, sign-in).
|
||||
|
||||
### 22.2 The registry — git is still truth
|
||||
|
||||
Which projects exist, and their configuration, is declared in git, mirrored
|
||||
into a `projects` cache table the same way content is mirrored into
|
||||
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
|
||||
at the root of a dedicated **registry repo** under the deployment's Gitea
|
||||
org. The framework learns the registry repo's location from a required env
|
||||
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
|
||||
*name* is the deployment's choice, not the framework's, per the
|
||||
separation-of-concerns rule, and the framework fails loudly at startup if the
|
||||
var is unset. Adding, reconfiguring, or archiving a project is a PR against
|
||||
that file; the §4 webhook + reconciler keep the `projects` table in sync,
|
||||
recording the merged `registry_sha` on each row for provenance.
|
||||
|
||||
The registry is a **deployment-side repo the framework reads**, in exactly
|
||||
the sense `META_REPO` is today — not operator-tooling config. Where a
|
||||
deployment is assembled by an external operator tool, that tool supplies the
|
||||
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
|
||||
now) and is otherwise unaffected: project definitions live in git, edited by
|
||||
PR, and the framework knows nothing about the tool that wrote the env var.
|
||||
|
||||
```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
|
||||
type: document # document | specification | bdd — immutable (§22.4a)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
|
||||
visibility: gated # gated | public | unlisted (§22.5)
|
||||
initial_state: super-draft # super-draft | active — landing state of a new
|
||||
# entry; defaults from type (§22.4b)
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
|
||||
```
|
||||
|
||||
Project **definition and configuration** live in the registry (git). Project
|
||||
**membership** lives in the app db (§22.6) — it churns at user speed and is
|
||||
app state, not document state, exactly as `rfc_collaborators` is (§5).
|
||||
`projects` rows are never written from user actions; they flow from the
|
||||
registry mirror only.
|
||||
|
||||
### 22.3 Content repositories — one per project
|
||||
|
||||
Each project names one content repo under the deployment's single Gitea org
|
||||
(naming convention `<project-id>-content`). The §1 bot service account
|
||||
operates org-wide across every content repo and the registry repo; nothing
|
||||
about the bot, the §6 app-owned authorization, or the "app is the only
|
||||
contribution surface" stance changes. There are no per-project Gitea orgs and
|
||||
no per-project bot accounts.
|
||||
|
||||
### 22.4 The slug namespace is per-project; the slug is the identity
|
||||
|
||||
An entry's slug (§2) is unique **within its project**, not across the
|
||||
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
|
||||
identity — a fully qualified reference is `(project_id, slug)`, and nothing
|
||||
more. There is no type prefix and **no per-project numeric ID**: within a
|
||||
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
|
||||
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
|
||||
|
||||
This retires the per-project numbering of earlier drafts. The §13 graduation
|
||||
flip still happens — it moves an entry from proposal to graduated state and
|
||||
into the content repo — but it no longer allocates a number; the
|
||||
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
|
||||
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
|
||||
presentation concern driven by the project's type (§22.4a), not part of the
|
||||
identity.
|
||||
|
||||
**Legacy numbers.** Entries graduated *before* this change keep their existing
|
||||
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label** —
|
||||
preserved and shown in the UI so external "RFC-0001"-style citations still
|
||||
resolve, but never used for routing or lookup (the slug is). New entries are
|
||||
never assigned one, and graduation does not write `id`. The field is read-only
|
||||
provenance from here on.
|
||||
|
||||
### 22.4a Project type
|
||||
|
||||
Every project declares a `type` in the registry (§22.2), chosen at creation
|
||||
and **immutable**: one of `document`, `specification`, or `bdd`. Type does not
|
||||
change the engine — every type uses the same content repo (§22.3), the same
|
||||
propose→branch→PR→discuss→graduate lifecycle (§§9–13), the same threads,
|
||||
flags, and chat. Type selects exactly three things:
|
||||
|
||||
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
|
||||
|
||||
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/<project>/…` carries
|
||||
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
|
||||
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
|
||||
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
|
||||
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
|
||||
routing stays a single type-agnostic path and avoids colliding with the
|
||||
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
|
||||
**deployment landing**: a directory of the projects the visitor can see (per
|
||||
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
|
||||
projects there. The §8.1 breadcrumb gains a leading project segment:
|
||||
`OHM / Human › main` (slug, with the type-driven noun as its label).
|
||||
|
||||
### 22.11 Notifications span projects, one inbox
|
||||
|
||||
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
|
||||
caller's projects. `notifications` and `watches` carry `project_id` (§5
|
||||
amendment) so the inbox filters by project and a user can mute an entire
|
||||
project. Quiet hours, digest cadence, and email preferences stay per-account
|
||||
at the deployment level (§5, §15).
|
||||
|
||||
### 22.12 Per-project model universe
|
||||
|
||||
A project's `enabled_models` (registry, §22.2) defines its operator universe,
|
||||
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
|
||||
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
|
||||
universe resolve *within* the project's universe — the resolution order
|
||||
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
|
||||
universe substituting for the deployment universe at the outermost step.
|
||||
|
||||
### 22.13 Migration — the default project (the N=1 case)
|
||||
|
||||
A deployment upgrading from a pre-multi-project version is migrated to a
|
||||
single **default project** so it keeps running unchanged:
|
||||
|
||||
1. The migration generates a `projects` row from current config:
|
||||
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
|
||||
(preserving the deployment's current open-by-default posture),
|
||||
`type = document` (every pre-multi-project corpus is a document corpus),
|
||||
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
|
||||
slug of the deployment name, falling back to the literal `default`. (M1's
|
||||
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
|
||||
to the config-derived slug before any `/p/<id>/` route is public, so the id
|
||||
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
|
||||
id stays framework-generic: the framework supplies no deployment name.)
|
||||
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
|
||||
`project_id`.
|
||||
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
|
||||
their `/p/<default-id>/…` equivalents — the entry view to
|
||||
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
|
||||
4. The operator creates the registry repo (§22.2) declaring the default
|
||||
project; until they add a second project, the deployment is functionally
|
||||
identical to before, with one extra path segment.
|
||||
|
||||
This is the §20.4 upgrade-steps content for the release.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Amendments to existing sections
|
||||
|
||||
Applied in place, in the established amendment-note style (cf. §1's
|
||||
"Topology change (v0.31.0)").
|
||||
|
||||
- **§1 Repository topology.** Add the §22 amendment note (above). "This
|
||||
single repository is its content repository" → "each *project* names one
|
||||
content repository; the deployment's registry (§22.2) lists them." The bot
|
||||
and app-owned-authorization paragraphs are unchanged and now read
|
||||
org-wide.
|
||||
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
|
||||
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
|
||||
allocation is **removed** — the slug is the identity (§22.4); there is no
|
||||
per-project number. Entries graduated before this change keep their `id`
|
||||
as a frozen, read-only legacy display label (§22.4), never used for lookup.
|
||||
The entry frontmatter schema becomes type-dependent
|
||||
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
|
||||
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
|
||||
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
|
||||
`graduated_at`/`graduated_by`.
|
||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
|
||||
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
|
||||
straight to `active` when the project (e.g. a `bdd` project) lands entries
|
||||
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
|
||||
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
|
||||
flag. The rest of the machine is unchanged.
|
||||
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
|
||||
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
|
||||
`notifications`, `rfc_invitations`, `rfc_collaborators`,
|
||||
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
|
||||
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
|
||||
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
|
||||
filter can query it without reading every entry file),
|
||||
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
|
||||
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
|
||||
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
|
||||
annotated as deployment-scope (§22.7).
|
||||
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
|
||||
one of three tiers; a plain `contributor` has no implicit access to a
|
||||
project until §22.6 grants a project role (subject to §22.5).
|
||||
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
|
||||
the same delegation idea at project scope, sitting above per-RFC authority.
|
||||
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
|
||||
deployment directory (§22.10) is a new surface above it. The catalog gains
|
||||
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
|
||||
that landed unreviewed.
|
||||
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
|
||||
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
|
||||
"Spec", "Feature") is the project type's label (§22.4a).
|
||||
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
|
||||
status and moves the entry, but **allocates no number** — the slug is the
|
||||
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
|
||||
gone. Graduation is also **conditional on the project's `initial_state`
|
||||
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
|
||||
so the graduate action is a no-op there and is not surfaced. For those
|
||||
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
|
||||
the owner/admin vetting step — it clears `unreviewed` instead of flipping
|
||||
state.
|
||||
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
|
||||
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
|
||||
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
|
||||
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
|
||||
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
|
||||
(returns the project's `type`, §22.4a), and the `project_members`
|
||||
management + request-to-join endpoints (§22.6, §22.8). Add a
|
||||
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
|
||||
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
|
||||
specific surfaces (§22.4a) add their own routes, mounted only for projects of
|
||||
the matching type — e.g. the `specification` release-planning endpoints and
|
||||
the `bdd` scenario/coverage endpoints.
|
||||
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
|
||||
`enabled_models` overrides it (§22.12).
|
||||
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
|
||||
registry + `GET /api/deployment` (§22.9). New required backend env var
|
||||
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
|
||||
consulted only by the §22.13 migration to seed the default project's
|
||||
`content_repo`, then unused. The registry file shape and the
|
||||
`projects`/`project_members` schema join the §20.3 versioned surface. The
|
||||
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
|
||||
changelog: a deployment assembled by an external operator tool upgrades
|
||||
through the same pinned-version path as any other — the only deploy-surface
|
||||
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
|
||||
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
|
||||
are unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Slicing plan
|
||||
|
||||
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
|
||||
DEV.md's original principle — foundations and the cache/permission spine
|
||||
first, the surfaces that consume them after, hardening last. Each slice is
|
||||
shippable: a deployment can stop at any slice boundary and still run (the
|
||||
default project keeps the N=1 case whole throughout). The project `type`
|
||||
(§22.4a) rides M3 (config) and M5 (its surfaces); M1–M4 are type-agnostic
|
||||
because the engine is.
|
||||
|
||||
**M1 — The project spine (schema + default-project migration).** *(landed)*
|
||||
The `projects` and `project_members` tables; `project_id` threaded
|
||||
additively onto every slug-bearing §5 table (migration 026); the §22.13
|
||||
default project generated and every existing row backfilled to it; the
|
||||
startup backfill that fills the default project's `content_repo` from
|
||||
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
|
||||
routing change, no registry mirror yet — the app runs exactly as before,
|
||||
single project, with the spine underneath. Additive only: no table rebuilds
|
||||
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
|
||||
project #2, enumerated in migration 026's header). This is the foundation
|
||||
everything after builds on.
|
||||
|
||||
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
|
||||
The three-tier composition: `project_members` roles, the most-permissive
|
||||
union with deployment role and per-RFC authority, the §22.5 visibility gate as
|
||||
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
|
||||
§6.1's audit re-checked under the project axis. Still single visible project;
|
||||
verifiable by granting/revoking roles on the default project and flipping its
|
||||
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
|
||||
app-layer — the resolver primitives live in `app/auth.py`
|
||||
(`project_visibility`, `project_member_role`, `is_project_superuser`,
|
||||
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
|
||||
`visible_project_ids`), composed into the existing per-RFC capability helpers
|
||||
and threaded into every RFC-resolution gate (`_require_rfc*`,
|
||||
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
|
||||
gates). No migration (M1 shipped the tables) and no behavior change on the
|
||||
public default project.
|
||||
|
||||
Two operator decisions pin how the implicit grant behaves on a `public`
|
||||
project: **implicit-on-public** — a granted deployment `contributor` keeps its
|
||||
pre-multi-project write baseline with no `project_members` row, so the N=1 case
|
||||
stays whole (no backfill); and **preserve curation** — that implicit baseline
|
||||
does *not* override per-RFC owner curation (only an explicit
|
||||
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
|
||||
per-RFC invite contract is unchanged on public. Explicit `project_members`
|
||||
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
|
||||
deliberate liberalization of §22.5's literal "contributing still requires a
|
||||
grant" for the public case; gated/unlisted honor the grant model exactly.
|
||||
|
||||
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
|
||||
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
|
||||
rows beyond the default); the **re-stamp of the default project's bootstrap
|
||||
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
|
||||
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
|
||||
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
|
||||
the old corpus-root URLs (`/rfc/<slug>` → `/p/<default-id>/e/<slug>`); `GET
|
||||
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
|
||||
per-project `theme` token overlay. The deployment directory at `/` and the
|
||||
project switcher in deployment chrome. This slice also adds the additive
|
||||
`type` and `initial_state` columns to `projects` (a small migration — M1
|
||||
shipped `projects` without them), mirrors both from the registry, returns
|
||||
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
|
||||
`type` (§22.4a). It also teaches the shared creation path to honor
|
||||
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
|
||||
stamping `unreviewed` and skipping the graduate gate when the project says so
|
||||
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
|
||||
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
|
||||
unreviewed filter that queries that cached column. But no type
|
||||
*surfaces* yet; beyond their label, landing state, and review flag, all three
|
||||
types still look the same here. After M3 a deployment with two registry projects is
|
||||
fully navigable — which makes this the slice that must also land the deferred
|
||||
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
|
||||
project can collide with the first.
|
||||
|
||||
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
|
||||
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
|
||||
philosophy all confirmed working under project scope with the per-project,
|
||||
slug-only namespace (§22.4). This is mostly *inherited* from M1–M3 — the work
|
||||
is an end-to-end pass that proves a second `document` project's full lifecycle
|
||||
(propose → super-draft → graduate, identified by slug *in that project*), not
|
||||
new build. Naming it explicitly as the acceptance slice keeps scope that
|
||||
belongs in M3 from leaking in.
|
||||
|
||||
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
|
||||
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
|
||||
the `specification` **release-planning** surface; the `bdd` scenario/coverage
|
||||
surface. Type-scoped routes mounted only for matching projects (§17). The
|
||||
shared engine is untouched — this slice only adds the layers on top, so a
|
||||
`document` project is unaffected and the M4 acceptance still holds. (The
|
||||
per-type schema/surface details are the §22.4a draft note's open work.)
|
||||
|
||||
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
|
||||
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
|
||||
§22.11 cross-project inbox with `project_id` filtering and project-level
|
||||
mute. The admin surface for managing a project's roster.
|
||||
|
||||
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
|
||||
(§22.12) including funder/§6.6 intersection; the registry's place in
|
||||
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
|
||||
spanning two projects with disjoint membership; the §20.4 changelog +
|
||||
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
|
||||
|
||||
## Open items folded into the slices
|
||||
|
||||
- **Registry repo vs. file-in-existing-repo** — *resolved:* a dedicated
|
||||
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
|
||||
Confirmed against the flotilla operator-tooling spec: the registry is
|
||||
deployment-side git content (like the corpus), not operator config, so the
|
||||
operator tool's only change is swapping the `META_REPO` overlay value for
|
||||
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
|
||||
contract. With OHM becoming one project among several, the registry sits
|
||||
*above* any single project's content repo, so a file inside one project's
|
||||
repo is wrong — a dedicated repo is the right home.
|
||||
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
|
||||
(membership lifecycle) may ship invite-only first and add request-to-join
|
||||
second if scope demands.
|
||||
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
|
||||
the `specification` release-planning data model and whether `bdd` scenarios
|
||||
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Design sketch — multi-project deployments
|
||||
|
||||
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
|
||||
> describes. Decisions captured here were made interactively; open questions
|
||||
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
|
||||
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
|
||||
> upgrade steps.
|
||||
|
||||
## The reframe
|
||||
|
||||
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
|
||||
is one Gitea content repo (`META_REPO`), one global slug namespace, one
|
||||
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
|
||||
plainly: "this single repository is its content repository."
|
||||
|
||||
This change makes it **deployment : project = 1 : N**, where *today's entire
|
||||
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
|
||||
content repo, its own slug namespace, a declared **type** (§ "Project types"
|
||||
below), its own catalog, philosophy, branding, member roster, and
|
||||
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
|
||||
becomes a thin shell hosting a directory of projects plus a shared
|
||||
account/notification layer.
|
||||
|
||||
OHM — a *document* project — becomes one project among several: specs
|
||||
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
|
||||
all under one deployment.
|
||||
|
||||
The value of this framing: the migration stays mechanical. Everything
|
||||
deployment-scoped today splits cleanly into:
|
||||
|
||||
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
|
||||
the bot service account, the Gitea org;
|
||||
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
|
||||
enabled models.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
|
||||
repo) declares which projects exist and their config; adding a project is a
|
||||
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
|
||||
2. **Projects are membership-gated by default** (private). A non-member does
|
||||
not see a private project exists. `visibility` is still a per-project field
|
||||
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
|
||||
not the only mode.
|
||||
3. **Entries are identified by slug, scoped to the project.** A fully
|
||||
qualified reference is `(project_id, slug)`. There is *no* type prefix and
|
||||
*no* per-project numeric ID: inside a project the slug alone is
|
||||
unambiguous, and the project (its URL prefix, its chrome) supplies all the
|
||||
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
|
||||
allocation idea — graduation (§13) still flips an entry's status, but no
|
||||
longer mints a number.
|
||||
4. **Each project declares a `type`** — `document`, `specification`, or `bdd`.
|
||||
Type is chosen when the project is created (in the registry), is immutable,
|
||||
and selects the project's entry frontmatter schema, its terminology/labels,
|
||||
and any type-specific surfaces (e.g. release planning for specifications).
|
||||
All types ride the *same* propose→branch→PR→graduate engine, threads,
|
||||
flags, and chat; type layers on top — it does not fork the lifecycle.
|
||||
|
||||
## Project types
|
||||
|
||||
A project's `type` is a registry field, fixed at creation. It does **not**
|
||||
change the engine — every type uses the same content repo, the same
|
||||
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
|
||||
selects three things: the **entry frontmatter schema** the project validates
|
||||
against, the **terminology** the chrome uses for an entry, and the set of
|
||||
**type-specific surfaces** the project exposes on top of the shared catalog.
|
||||
The engine treats every entry as markdown + frontmatter regardless of type;
|
||||
type-specific behavior is a layer, implemented as a per-type module the
|
||||
framework selects on `project.type`.
|
||||
|
||||
> These three definitions — especially the specification release-planning
|
||||
> surface and the BDD scenario model — are first drafts. The schema/surface
|
||||
> details below are proposals to refine, not yet locked.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM is the archetype: a model
|
||||
of principles and definitions). Frontmatter is today's entry schema
|
||||
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
|
||||
is the baseline, and the N=1 default project (§7) is a `document` project.
|
||||
- **`specification`** — a versioned technical specification (this app's own
|
||||
`SPEC.md`, with numbered normative sections and upgrade steps, is the
|
||||
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
|
||||
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
|
||||
**release planning** — group entries/changes into versioned releases, track
|
||||
the changelog + upgrade-steps for each, and show "what's in the next
|
||||
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
|
||||
§20 upgrade steps.)
|
||||
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
|
||||
as scenarios in Given/When/Then form with acceptance criteria. Frontmatter
|
||||
adds feature metadata and an optional link to the `specification` entries a
|
||||
feature verifies. Type-specific surface: a scenario/acceptance view, and
|
||||
(where a deployment pairs a BDD project with a specification project) a
|
||||
coverage view linking features back to the spec sections they exercise.
|
||||
|
||||
Types are an **open set** in shape — a new type is a new module plus a new
|
||||
allowed `type` value; it needs no schema migration beyond the enum. Document,
|
||||
specification, and BDD are the three defined now.
|
||||
|
||||
**Landing state (`initial_state`).** A project also sets what state a new
|
||||
entry lands in when its idea-PR merges — `super-draft` (the normal
|
||||
propose→review→graduate flow) or `active` (graduated on submission). The
|
||||
default comes from the type: `document` and `specification` default to
|
||||
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
|
||||
fact, not a proposal under deliberation, so it skips the review-then-promote
|
||||
gate. It's an independent registry knob, so a deployment can override the
|
||||
default per project. This changes only the landing state and whether
|
||||
graduation is required; the propose→branch→PR engine is unchanged.
|
||||
|
||||
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
|
||||
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
|
||||
`active` the normal way — super-draft → graduate — is never flagged, because
|
||||
graduation *is* the review. A project owner clears the flag with a
|
||||
**mark-reviewed** action (same authority as graduate), and the catalog has an
|
||||
**unreviewed filter** so owners can find the entries still awaiting review. The
|
||||
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
|
||||
the `active` state and only meaningful on `active` entries.
|
||||
|
||||
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
|
||||
and their behavior are framework concepts (like role names), not
|
||||
deployment-specific content. A deployment chooses *which* type each of its
|
||||
projects is; it does not define new types or rename them.
|
||||
|
||||
## 1. Git topology — one content repo per project
|
||||
|
||||
One Gitea org for the deployment, **N content repos** (`ohm-content`,
|
||||
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
|
||||
gains more repos and one registry repo. Slug uniqueness becomes naturally
|
||||
per-repo = per-project (and the slug is the whole identity — see Decision 3).
|
||||
|
||||
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
|
||||
churns every path / branch-name / graduation code path and can't carry
|
||||
git-layer access control per project; prefixed slugs pollute the namespace.
|
||||
|
||||
### The registry repo
|
||||
|
||||
A small repo (or a top-level file in a deployment repo) the bot reads and
|
||||
mirrors. Sketch shape:
|
||||
|
||||
```yaml
|
||||
# projects.yaml
|
||||
deployment:
|
||||
name: Wiggleverse
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm # url slug, stable, unique in deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd (immutable)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org
|
||||
visibility: gated # gated | public | unlisted
|
||||
initial_state: super-draft # super-draft | active — landing state of a
|
||||
# new entry; defaults from type
|
||||
philosophy_repo_path: PHILOSOPHY.md
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides
|
||||
```
|
||||
|
||||
Project **definition/config** is in git; project **membership** is in the DB
|
||||
(it churns like `rfc_collaborators` and is app-state, not document state). The
|
||||
registry gets the same webhook + reconciler treatment as content repos.
|
||||
|
||||
> Open: does the registry get its own repo, or is it a file in an existing
|
||||
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
|
||||
> which already owns deploy orchestration and could own registry edits.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
Introduce a **`projects`** cache table (mirrored from the registry, like
|
||||
`cached_rfcs` is mirrored from content). Then thread `project_id` through
|
||||
every RFC-scoped table.
|
||||
|
||||
Hard constraint: once slugs are unique only *within* a project, any table
|
||||
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
|
||||
of them:
|
||||
|
||||
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
|
||||
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
|
||||
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
|
||||
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
|
||||
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
|
||||
- `notifications`, `actions`
|
||||
|
||||
~15-table migration, all backfillable to a single default project (see §7).
|
||||
With slug-only identity (Decision 3) there is no per-project number to
|
||||
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
|
||||
graduation reduces to the status flip + content-repo move, keyed on
|
||||
`(project_id, slug)`.
|
||||
|
||||
New tables:
|
||||
|
||||
```
|
||||
projects(
|
||||
id TEXT PRIMARY KEY, -- 'ohm'
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL -- document | specification | bdd (immutable)
|
||||
CHECK (type IN ('document','specification','bdd')),
|
||||
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
|
||||
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
|
||||
content_repo TEXT NOT NULL,
|
||||
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
|
||||
config_json TEXT, -- theme, tagline, enabled_models, …
|
||||
registry_sha TEXT, -- provenance of the mirrored row
|
||||
updated_at TEXT
|
||||
)
|
||||
|
||||
project_members(
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
granted_at TEXT,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Roles — three tiers
|
||||
|
||||
A **middle tier** slots between today's deployment roles and per-RFC authority.
|
||||
|
||||
| Tier | Who | Powers |
|
||||
|---|---|---|
|
||||
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
|
||||
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
|
||||
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
|
||||
|
||||
This is the existing owner → admin → contributor delegation pattern with a
|
||||
project axis added. `users.role` reverts to meaning *deployment*-level only.
|
||||
|
||||
**Visibility interaction:**
|
||||
|
||||
- **gated** (default) — invisible to non-members; must be a member to see it
|
||||
exists. Read and write both require membership.
|
||||
- **public** — any authenticated user can read; contributing requires a
|
||||
`project_contributor` grant.
|
||||
- **unlisted** — readable by direct link, not shown in the directory.
|
||||
|
||||
> Philosophy tension to resolve in SPEC: today the app is open-by-default
|
||||
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
|
||||
> reverses that for the common case. The `public`/`unlisted` modes preserve the
|
||||
> old behavior for projects that want it, and the deployment can choose its own
|
||||
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
|
||||
> and anonymous read a per-project opt-in.
|
||||
|
||||
**Discovery for gated projects:** since a stranger sees an empty directory,
|
||||
there must be a join path — invite-only (a `project_admin` adds you), or a
|
||||
request-to-join surface analogous to the existing §28 contribution-request
|
||||
flow. (Open — pick one.)
|
||||
|
||||
## 4. Branding & frontend (forced change)
|
||||
|
||||
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
|
||||
bake N project names into one bundle. Branding moves to **runtime config
|
||||
served by the backend**:
|
||||
|
||||
- `GET /api/deployment` → deployment name/tagline + the list of projects the
|
||||
caller can see (gated ones filtered by membership).
|
||||
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
|
||||
tokens.
|
||||
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
|
||||
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
|
||||
|
||||
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
|
||||
is a documented config change with upgrade steps per CLAUDE.md.
|
||||
|
||||
Two chrome layers result:
|
||||
|
||||
- **Deployment chrome** — the Wiggleverse header, the project directory /
|
||||
landing, a project switcher.
|
||||
- **Project chrome** — the current header / catalog / philosophy, now per
|
||||
project; theme tokens (`tokens.css`) overridable per project at runtime.
|
||||
|
||||
## 5. Routing & UX
|
||||
|
||||
- Entry routes gain a project prefix with a **generic `/e/` segment**:
|
||||
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
|
||||
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
|
||||
noun shown around the slug is a type-driven label, not part of the path.
|
||||
- Root `/` becomes the **deployment landing = project directory** (the
|
||||
Wiggleverse home). For an anonymous or non-member visitor under gated
|
||||
default, that's only public/unlisted-by-link projects.
|
||||
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human › main` (the entry
|
||||
is named by its slug; the entry-noun the chrome uses around it is
|
||||
type-driven — "RFC", "Spec", "Feature").
|
||||
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
|
||||
deployment chrome.
|
||||
|
||||
## 6. Cross-cutting — notifications, inbox, watches
|
||||
|
||||
Accounts are deployment-wide, so there is **one inbox spanning projects**
|
||||
(§15). `notifications` and `watches` carry `project_id` so the inbox is
|
||||
filterable and a user can mute an entire project. Quiet hours / digest prefs
|
||||
stay per-account (deployment level).
|
||||
|
||||
## 7. Backward compatibility & migration
|
||||
|
||||
The N=1 path keeps existing single-project deployments working:
|
||||
|
||||
1. Migration creates one **default project** from current config (`META_REPO`
|
||||
→ `content_repo`, `VITE_APP_NAME` → `name`, visibility seeded to match the
|
||||
deployment's current open posture, likely `public`). Its `id` is a
|
||||
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
|
||||
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
|
||||
M3 before any `/p/` URL is public.
|
||||
2. Every existing row's `project_id` is stamped to that default project.
|
||||
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
|
||||
(308 → `/p/<default-id>/e/<slug>`).
|
||||
|
||||
This is the SPEC §20 upgrade-steps block.
|
||||
|
||||
## 8. SPEC & versioning impact
|
||||
|
||||
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
|
||||
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
|
||||
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
|
||||
`project_id` is just a column; no DB-per-project, no Postgres forced.
|
||||
|
||||
## Operator-tooling integration (flotilla)
|
||||
|
||||
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
|
||||
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
|
||||
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
|
||||
corpus lives in a deployment-side repo"), and **depends on the framework only
|
||||
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
|
||||
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
|
||||
flotilla.
|
||||
|
||||
This confirms the registry decision and fixes how it integrates:
|
||||
|
||||
- The registry is **deployment-side git content the framework reads** — same
|
||||
category as the corpus — *not* a flotilla-owned config blob. Putting
|
||||
project definitions in operator tooling would re-bake deployment specifics
|
||||
into the assembly layer and add a new framework⇄tool contract.
|
||||
- The framework reads the registry via a `REGISTRY_REPO` env var, the
|
||||
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
|
||||
ref; its four versioned contracts are untouched.
|
||||
- Multi-project therefore ships to OHM as an **ordinary pinned-version
|
||||
upgrade**: bump the pin, run the §22.13 default-project migration, change
|
||||
`META_REPO`→`REGISTRY_REPO` in the overlay, deploy, verify via
|
||||
`/api/health`. No flotilla architectural change.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
|
||||
both; M5 can ship invite-only first.)
|
||||
- Does the deployment landing itself need branding config, or is it derived
|
||||
entirely from the registry `deployment:` block?
|
||||
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
|
||||
§6.7 funder) — confirm fallback order.
|
||||
- Slicing plan for the build (mirrors DEV.md's original slice approach).
|
||||
- **Type surfaces — depth of each.** What concretely is in the
|
||||
`specification` *release-planning* surface (its own tables? a release =
|
||||
a tag + a changelog entry + a set of graduated entries?), and does the
|
||||
`bdd` scenario model stay free-form markdown or get a structured
|
||||
Given/When/Then schema the app parses? Drafted shallow; pin before the
|
||||
type-surface slice.
|
||||
- **Entry-noun in URLs/labels** — *resolved 2026-06-02:* generic route
|
||||
segment `/p/<project>/e/<slug>` for every type; the displayed noun
|
||||
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
|
||||
(§22.10, §22.4a).
|
||||
- **Existing graduated numbers** — *resolved 2026-06-02:* pre-change graduated
|
||||
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
|
||||
legacy display label (preserves citations); never used for lookup, never
|
||||
assigned to new entries (§22.4).
|
||||
- **Default project `id`** — *resolved 2026-06-02:* a config-derived slug
|
||||
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
|
||||
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
|
||||
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,823 @@
|
||||
# M3-0 — Test & Local-Env Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the handbook §10.3 **Tier-1 local-Docker test foundation** for rfc-app — a `docker compose` stack (backend + nginx-served SPA + seeded real Gitea + Mailpit), a Vitest frontend-unit setup, and an environment-agnostic Playwright e2e harness with one passing smoke spec — so every later M3 sub-plan can be verified at unit/integration/functional/e2e levels on localhost (and against PPE later by changing `BASE_URL`).
|
||||
|
||||
**Architecture:** A four-service compose stack. The backend (FastAPI, single uvicorn process, SQLite, migrations on startup) and an nginx container serving the built SPA + proxying `/api` and `/auth` — mirroring prod. A **real, disposable Gitea** container, seeded fresh each run by a one-shot seed service (admin + bot token + OAuth app + org + content repo + webhook), chosen so the SAME e2e suite behaves identically in Tier 1 and Tier 2/PPE (§10.3). **Mailpit** as the mail sink; e2e logs in via the email **OTC** flow (`/auth/otc/request` → read code from Mailpit's API → `/auth/otc/verify`), which needs no Gitea OAuth consent scripting. Playwright is parameterized by `BASE_URL` + `MAILSINK_URL` so the unchanged suite later targets PPE.
|
||||
|
||||
**Tech Stack:** Docker Compose, Gitea (pinned image), Mailpit, nginx, Python 3.11/uvicorn, Vite/React 19, Vitest + @testing-library/react, Playwright (@playwright/test).
|
||||
|
||||
**Conventions (Wiggleverse):** SSH git transport; no inline comments trailing CLI commands; commit messages end with the `Co-Authored-By` trailer. Branch off `main` — do **not** work on `main`. Suggested branch: `feat/m3-0-test-foundation`.
|
||||
|
||||
**Pre-req:** This plan creates a new directory `testing/` at repo root for harness assets and `frontend/src/**/*.test.jsx` for unit tests. It does not touch backend app code except adding a Dockerfile.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Files created/modified, by responsibility:
|
||||
|
||||
- `testing/docker-compose.yml` — the four-service Tier-1 stack (backend, web/nginx, gitea, mailpit) + the one-shot `gitea-seed` service.
|
||||
- `testing/backend.Dockerfile` — builds the backend image (Python 3.11 + requirements + app).
|
||||
- `testing/web.Dockerfile` — builds the SPA (node build stage) and serves it via nginx (runtime stage).
|
||||
- `testing/web.nginx.conf` — nginx config for the web container (SPA fallback + `/api` `/auth` proxy to backend). Adapted from `deploy/nginx/ohm.wiggleverse.org.conf`, TLS stripped.
|
||||
- `testing/seed-gitea.sh` — idempotent seed script: admin user, bot user + token, OAuth app, org, content repo (seeded with `rfcs/`), webhook.
|
||||
- `testing/.env.tier1` — the env values the compose stack injects into the backend.
|
||||
- `testing/README.md` — how to run Tier 1 locally and how to point the suite at PPE.
|
||||
- `frontend/vitest.config.js` — Vitest config (jsdom env).
|
||||
- `frontend/src/test/setup.js` — testing-library/jsdom setup.
|
||||
- `frontend/src/lib/brand.js` + `frontend/src/lib/brand.test.js` — a tiny first unit-tested module (proves Vitest wiring; reused by M3c).
|
||||
- `frontend/package.json` — add devDeps + `test`, `test:run` scripts (modify).
|
||||
- `e2e/playwright.config.js` — Playwright config; `baseURL` from `BASE_URL`, mail sink from `MAILSINK_URL`.
|
||||
- `e2e/lib/mailpit.js` — helper to read the latest OTC email from Mailpit's API.
|
||||
- `e2e/smoke.spec.js` — the one smoke spec (OTC login → landing renders).
|
||||
- `e2e/package.json` — Playwright dep + `e2e` script (kept separate from the app frontend deps).
|
||||
- `Makefile` (repo root) — `tier1-up`, `tier1-down`, `e2e`, `fe-unit` convenience targets (modify or create).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Frontend unit testing (Vitest) — independent quick win
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
- Create: `frontend/vitest.config.js`
|
||||
- Create: `frontend/src/test/setup.js`
|
||||
- Create: `frontend/src/lib/brand.js`
|
||||
- Test: `frontend/src/lib/brand.test.js`
|
||||
|
||||
- [ ] **Step 1: Add Vitest dev dependencies and scripts**
|
||||
|
||||
Modify `frontend/package.json` — add to `devDependencies`:
|
||||
|
||||
```json
|
||||
"vitest": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.0"
|
||||
```
|
||||
|
||||
Add to `scripts`:
|
||||
|
||||
```json
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install**
|
||||
|
||||
Run: `cd frontend && npm install`
|
||||
Expected: lockfile updates, `node_modules/.bin/vitest` exists.
|
||||
|
||||
- [ ] **Step 3: Create the Vitest config**
|
||||
|
||||
Create `frontend/vitest.config.js`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the test setup file**
|
||||
|
||||
Create `frontend/src/test/setup.js`:
|
||||
|
||||
```js
|
||||
import '@testing-library/jest-dom'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write the failing unit test**
|
||||
|
||||
Create `frontend/src/lib/brand.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { brandTitle } from './brand.js'
|
||||
|
||||
describe('brandTitle', () => {
|
||||
it('returns the deployment name when set', () => {
|
||||
expect(brandTitle('Wiggleverse')).toBe('Wiggleverse')
|
||||
})
|
||||
|
||||
it('falls back to a neutral placeholder when name is empty', () => {
|
||||
expect(brandTitle('')).toBe('RFC')
|
||||
expect(brandTitle(undefined)).toBe('RFC')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run it to verify it fails**
|
||||
|
||||
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./brand.js"` (module does not exist yet).
|
||||
|
||||
- [ ] **Step 7: Implement the minimal module**
|
||||
|
||||
Create `frontend/src/lib/brand.js`:
|
||||
|
||||
```js
|
||||
export function brandTitle(name) {
|
||||
const trimmed = (name || '').trim()
|
||||
return trimmed || 'RFC'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run it to verify it passes**
|
||||
|
||||
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
|
||||
Expected: PASS — 2 tests pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json frontend/vitest.config.js frontend/src/test/setup.js frontend/src/lib/brand.js frontend/src/lib/brand.test.js
|
||||
git commit -m "test(frontend): add Vitest unit-test harness with first brand helper
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend Docker image
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/backend.Dockerfile`
|
||||
- Create: `testing/.env.tier1`
|
||||
|
||||
- [ ] **Step 1: Write the backend Dockerfile**
|
||||
|
||||
Create `testing/backend.Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt uvicorn
|
||||
|
||||
COPY backend/ /app/
|
||||
|
||||
RUN mkdir -p /data
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Note: build context is the repo root (set in compose), so `COPY backend/...` resolves.
|
||||
|
||||
- [ ] **Step 2: Write the backend env file**
|
||||
|
||||
Create `testing/.env.tier1`:
|
||||
|
||||
```
|
||||
GITEA_URL=http://gitea:3000
|
||||
GITEA_BOT_USER=rfc-bot
|
||||
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
|
||||
GITEA_ORG=wiggleverse
|
||||
META_REPO=ohm-content
|
||||
REGISTRY_REPO=
|
||||
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
|
||||
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
|
||||
APP_URL=http://localhost:8080
|
||||
SECRET_KEY=tier1-not-secret
|
||||
DATABASE_PATH=/data/rfc-app.db
|
||||
OWNER_GITEA_LOGIN=owner
|
||||
GITEA_WEBHOOK_SECRET=tier1-webhook-secret
|
||||
ENABLED_MODELS=claude
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_STARTTLS=false
|
||||
EMAIL_FROM=rfc@example.test
|
||||
EMAIL_FROM_NAME=RFC Tier1
|
||||
EMAIL_ENABLED=true
|
||||
TURNSTILE_REQUIRED=false
|
||||
```
|
||||
|
||||
The `*-PLACEHOLDER` token/oauth values are overwritten at runtime by the seed step (Task 4) which writes the real values into `testing/.env.tier1.generated`; compose loads both files (Task 5), generated last so it wins. Leaving the placeholders here documents the full contract and lets the backend start to fail loudly if seeding was skipped.
|
||||
|
||||
- [ ] **Step 3: Verify the image builds**
|
||||
|
||||
Run: `docker build -f testing/backend.Dockerfile -t rfc-backend:tier1 .`
|
||||
Expected: image builds, no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/backend.Dockerfile testing/.env.tier1
|
||||
git commit -m "test(tier1): backend Docker image + env contract
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Web (nginx + built SPA) Docker image
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/web.Dockerfile`
|
||||
- Create: `testing/web.nginx.conf`
|
||||
|
||||
- [ ] **Step 1: Write the nginx config (adapted from prod, TLS stripped)**
|
||||
|
||||
Create `testing/web.nginx.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the web Dockerfile (multi-stage build → nginx)**
|
||||
|
||||
Create `testing/web.Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json /app/
|
||||
RUN npm ci
|
||||
COPY frontend/ /app/
|
||||
ENV VITE_APP_NAME="RFC Tier1"
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY testing/web.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
Note: `VITE_APP_NAME` is still build-required until M3c does the hard cut (`frontend/vite.config.js` throws without it). Supplying a test value keeps the build green now; M3c removes this line.
|
||||
|
||||
- [ ] **Step 3: Verify the image builds**
|
||||
|
||||
Run: `docker build -f testing/web.Dockerfile -t rfc-web:tier1 .`
|
||||
Expected: build succeeds; the SPA compiles with the test brand.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/web.Dockerfile testing/web.nginx.conf
|
||||
git commit -m "test(tier1): nginx web image serving the built SPA
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Gitea seed script
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/seed-gitea.sh`
|
||||
|
||||
This script runs inside a small `curl`+`git`-capable container (the `gitea-seed` service, Task 5). It assumes Gitea is reachable at `http://gitea:3000` with install-lock on and a known admin password from env. It is idempotent: every create tolerates "already exists".
|
||||
|
||||
- [ ] **Step 1: Write the seed script**
|
||||
|
||||
Create `testing/seed-gitea.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
GITEA="${GITEA_URL:-http://gitea:3000}"
|
||||
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
|
||||
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
|
||||
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}"
|
||||
ORG="${GITEA_ORG:-wiggleverse}"
|
||||
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
|
||||
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
|
||||
CONTENT_REPO="${META_REPO:-ohm-content}"
|
||||
APP_URL="${APP_URL:-http://localhost:8080}"
|
||||
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
|
||||
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
|
||||
|
||||
echo "seed: waiting for gitea at $GITEA"
|
||||
i=0
|
||||
while ! curl -sf "$GITEA/api/healthz" >/dev/null 2>&1; do
|
||||
i=$((i+1)); [ "$i" -gt 60 ] && echo "gitea never came up" && exit 1
|
||||
sleep 2
|
||||
done
|
||||
|
||||
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
|
||||
|
||||
echo "seed: ensuring bot user"
|
||||
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$BOT_USER\",\"email\":\"$BOT_USER@example.test\",\"password\":\"$BOT_PASS\",\"must_change_password\":false}" \
|
||||
|| echo "seed: bot user exists, continuing"
|
||||
|
||||
echo "seed: ensuring owner user (for OWNER_GITEA_LOGIN)"
|
||||
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|
||||
|| echo "seed: owner exists, continuing"
|
||||
|
||||
echo "seed: minting bot access token"
|
||||
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
|
||||
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
|
||||
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
|
||||
|
||||
echo "seed: ensuring org $ORG (owned by bot)"
|
||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing"
|
||||
|
||||
echo "seed: ensuring content repo $ORG/$CONTENT_REPO"
|
||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \
|
||||
|| echo "seed: content repo exists, continuing"
|
||||
|
||||
echo "seed: seeding one entry under rfcs/ so the catalog is non-empty"
|
||||
B64=$(printf '%s' '---
|
||||
title: Intro
|
||||
status: graduated
|
||||
id: RFC-0001
|
||||
owners: [owner]
|
||||
---
|
||||
|
||||
# Intro
|
||||
|
||||
Seed entry for Tier-1 e2e.
|
||||
' | base64 | tr -d '\n')
|
||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \
|
||||
|| echo "seed: intro.md exists, continuing"
|
||||
|
||||
echo "seed: registering OAuth application"
|
||||
OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
|
||||
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
|
||||
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
|
||||
|
||||
echo "seed: registering webhook on content repo -> backend"
|
||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \
|
||||
|| echo "seed: webhook exists, continuing"
|
||||
|
||||
echo "seed: writing generated env to $OUT"
|
||||
cat > "$OUT" <<EOF
|
||||
GITEA_BOT_TOKEN=$TOKEN
|
||||
OAUTH_CLIENT_ID=$CLIENT_ID
|
||||
OAUTH_CLIENT_SECRET=$CLIENT_SECRET
|
||||
EOF
|
||||
echo "seed: done"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make it executable**
|
||||
|
||||
Run: `chmod +x testing/seed-gitea.sh`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/seed-gitea.sh
|
||||
git commit -m "test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
> Verification of this script happens in Task 5 against the real Gitea image. The Gitea admin user is created by the gitea service's own init env (Task 5), so the script can authenticate as admin from its first call. If a Gitea-version API mismatch appears (e.g. the token `scopes` vocabulary, or the OAuth-app endpoint path), fix it against the pinned image `gitea/gitea:1.22` and keep the script idempotent.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Compose the stack and bring it up
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/docker-compose.yml`
|
||||
- Create/modify: `Makefile`
|
||||
|
||||
- [ ] **Step 1: Write the compose file**
|
||||
|
||||
Create `testing/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
name: rfc-tier1
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
environment:
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__server__ROOT_URL: "http://gitea:3000/"
|
||||
GITEA__server__HTTP_PORT: "3000"
|
||||
GITEA__database__DB_TYPE: "sqlite3"
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: "*"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
gitea-admin-init:
|
||||
image: gitea/gitea:1.22
|
||||
depends_on:
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
volumes_from:
|
||||
- gitea
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- >
|
||||
gitea admin user create --admin --username giteaadmin
|
||||
--password giteaadmin-pass --email admin@example.test
|
||||
--must-change-password=false || true
|
||||
restart: "no"
|
||||
|
||||
gitea-seed:
|
||||
image: alpine:3.20
|
||||
depends_on:
|
||||
gitea-admin-init:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- .env.tier1
|
||||
environment:
|
||||
GITEA_ADMIN_USER: giteaadmin
|
||||
GITEA_ADMIN_PASSWORD: giteaadmin-pass
|
||||
GITEA_BOT_PASSWORD: rfc-bot-pass
|
||||
SEED_OUT: /seed/.env.tier1.generated
|
||||
volumes:
|
||||
- ./seed-gitea.sh:/seed-gitea.sh:ro
|
||||
- ./generated:/seed
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- apk add --no-cache curl >/dev/null && sh /seed-gitea.sh
|
||||
restart: "no"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: testing/backend.Dockerfile
|
||||
depends_on:
|
||||
gitea-seed:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- .env.tier1
|
||||
- ./generated/.env.tier1.generated
|
||||
volumes:
|
||||
- backend-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else 1)"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: testing/web.Dockerfile
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
```
|
||||
|
||||
Notes: the backend reads `.env.tier1` then `./generated/.env.tier1.generated` (seed-written), so the real bot token / OAuth client overwrite the placeholders. `APP_URL=http://localhost:8080` matches the `web` published port and the OAuth redirect URI the seed registers. Mailpit API is on `8025`, SMTP on `1025`.
|
||||
|
||||
- [ ] **Step 2: Create the generated dir placeholder**
|
||||
|
||||
Run: `mkdir -p testing/generated && touch testing/generated/.gitkeep`
|
||||
|
||||
Create `testing/generated/.gitignore`:
|
||||
|
||||
```
|
||||
.env.tier1.generated
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add Makefile targets**
|
||||
|
||||
Create (or append to) `Makefile` at repo root:
|
||||
|
||||
```makefile
|
||||
tier1-up:
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
docker compose -f testing/docker-compose.yml logs -f
|
||||
|
||||
fe-unit:
|
||||
cd frontend && npm run test:run
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
```
|
||||
|
||||
(Use real tabs for Makefile recipes, not spaces.)
|
||||
|
||||
- [ ] **Step 4: Bring the stack up**
|
||||
|
||||
Run: `make tier1-up`
|
||||
Expected: gitea → admin-init → seed → backend (healthy) → web come up in order. `docker compose -f testing/docker-compose.yml ps` shows backend healthy.
|
||||
|
||||
- [ ] **Step 5: Verify the app is reachable through nginx**
|
||||
|
||||
Run: `curl -sf http://localhost:8080/api/health`
|
||||
Expected: HTTP 200 with the version JSON (proves web→backend proxy + migrations-on-startup worked).
|
||||
|
||||
Run: `curl -sf http://localhost:8080/ | grep -i "<title"`
|
||||
Expected: the SPA `index.html` is served (title present).
|
||||
|
||||
- [ ] **Step 6: Verify seeding produced real credentials**
|
||||
|
||||
Run: `cat testing/generated/.env.tier1.generated`
|
||||
Expected: non-placeholder `GITEA_BOT_TOKEN=`, `OAUTH_CLIENT_ID=`, `OAUTH_CLIENT_SECRET=` lines.
|
||||
|
||||
- [ ] **Step 7: Tear down and commit**
|
||||
|
||||
Run: `make tier1-down`
|
||||
|
||||
```bash
|
||||
git add testing/docker-compose.yml testing/generated/.gitignore Makefile
|
||||
git commit -m "test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Playwright harness + mail-sink helper
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/package.json`
|
||||
- Create: `e2e/playwright.config.js`
|
||||
- Create: `e2e/lib/mailpit.js`
|
||||
|
||||
- [ ] **Step 1: Create the e2e package**
|
||||
|
||||
Create `e2e/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rfc-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install Playwright + its browser**
|
||||
|
||||
Run: `cd e2e && npm install && npx playwright install chromium`
|
||||
Expected: `@playwright/test` installed; chromium downloaded.
|
||||
|
||||
- [ ] **Step 3: Write the Playwright config**
|
||||
|
||||
Create `e2e/playwright.config.js`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
reporter: [['list']],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the Mailpit helper**
|
||||
|
||||
Create `e2e/lib/mailpit.js`:
|
||||
|
||||
```js
|
||||
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
|
||||
|
||||
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const res = await fetch(`${MAILSINK}/api/v1/messages`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const msg = (data.messages || []).find(
|
||||
(m) => (m.To || []).some((t) => t.Address === toAddress),
|
||||
)
|
||||
if (msg) {
|
||||
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
|
||||
const body = await full.json()
|
||||
const text = `${body.Text || ''} ${body.HTML || ''}`
|
||||
const code = text.match(/\b(\d{6})\b/)
|
||||
if (code) return code[1]
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs))
|
||||
}
|
||||
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
|
||||
}
|
||||
|
||||
export async function clearMailpit() {
|
||||
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `\d{6}` pattern assumes the OTC code is a 6-digit number. Confirm against `app/otc.py` / the OTC email template; adjust the regex if the real code shape differs (this is the one detail to verify when the spec first runs).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/package.json e2e/package-lock.json e2e/playwright.config.js e2e/lib/mailpit.js
|
||||
git commit -m "test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: The smoke e2e spec (OTC login → landing renders)
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/smoke.spec.js`
|
||||
|
||||
- [ ] **Step 1: Write the smoke spec**
|
||||
|
||||
Create `e2e/smoke.spec.js`:
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
|
||||
|
||||
const EMAIL = 'e2e-user@example.test'
|
||||
|
||||
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
|
||||
await clearMailpit()
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveTitle(/.+/)
|
||||
|
||||
const reqRes = await request.post('/auth/otc/request', {
|
||||
data: { email: EMAIL },
|
||||
})
|
||||
expect(reqRes.ok()).toBeTruthy()
|
||||
|
||||
const code = await waitForLatestOtc(EMAIL)
|
||||
|
||||
const verifyRes = await request.post('/auth/otc/verify', {
|
||||
data: { email: EMAIL, code },
|
||||
})
|
||||
expect(verifyRes.ok()).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
Note: payload field names (`email`, `code`) must match `app/main.py`'s `/auth/otc/request` and `/auth/otc/verify` request models. Read those two handlers (around `app/main.py:259` and `:296`) and align field names before running. If OTC sign-in requires the account to be pre-provisioned or "granted", seed that state in the spec's setup (an admin call) or document the precondition; the e2e must end with an authenticated session cookie set on `page`'s context.
|
||||
|
||||
- [ ] **Step 2: Bring the stack up**
|
||||
|
||||
Run: `make tier1-up`
|
||||
Wait until `curl -sf http://localhost:8080/api/health` returns 200.
|
||||
|
||||
- [ ] **Step 3: Run the smoke spec — verify it passes**
|
||||
|
||||
Run: `make e2e`
|
||||
Expected: 1 passed. (If it fails on field names / OTC code shape / provisioning, fix per the notes in Step 1 and `e2e/lib/mailpit.js`, then re-run.)
|
||||
|
||||
- [ ] **Step 4: Tear down**
|
||||
|
||||
Run: `make tier1-down`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/smoke.spec.js
|
||||
git commit -m "test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation — running the tiers
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/README.md`
|
||||
|
||||
- [ ] **Step 1: Write the harness README**
|
||||
|
||||
Create `testing/README.md`:
|
||||
|
||||
```markdown
|
||||
# Test harness (handbook §10.3 two-tier testing)
|
||||
|
||||
One environment-agnostic suite, two targets.
|
||||
|
||||
## Tier 1 — local Docker (every PR)
|
||||
|
||||
```sh
|
||||
make tier1-up # build + start: gitea(seeded) + backend + web(nginx) + mailpit
|
||||
make e2e # run Playwright against http://localhost:8080
|
||||
make fe-unit # run Vitest frontend unit tests
|
||||
make tier1-down # stop + wipe volumes
|
||||
```
|
||||
|
||||
- App (SPA + API): http://localhost:8080
|
||||
- Mailpit UI / API: http://localhost:8025
|
||||
- Gitea (disposable): http://localhost:3001
|
||||
|
||||
The stack is hermetic and disposable — fresh SQLite + fresh seeded Gitea each
|
||||
`tier1-up`. e2e signs in via the email OTC flow, reading the code back from
|
||||
Mailpit, so no real OAuth provider is needed.
|
||||
|
||||
## Tier 2 — PPE (deploy gate)
|
||||
|
||||
The SAME suite, pointed at the PPE instance (once `rfc-app-ppe.<base>` is stood
|
||||
up via flotilla — see the engineering handbook §10.1/§10.3):
|
||||
|
||||
```sh
|
||||
cd e2e && BASE_URL=https://rfc-app-ppe.<base> MAILSINK_URL=<ppe-mailpit-api> npm run e2e
|
||||
```
|
||||
|
||||
PPE provides the real nginx/systemd/SQLite topology + its own isolated Gitea +
|
||||
always-pass Turnstile keys. Standing up the PPE VM is an operator task, not part
|
||||
of this repo.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/README.md
|
||||
git commit -m "docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Frontend unit:** `make fe-unit` → all pass.
|
||||
- [ ] **Stack health:** `make tier1-up` then `curl -sf http://localhost:8080/api/health` → 200.
|
||||
- [ ] **E2e:** `make e2e` → smoke spec passes.
|
||||
- [ ] **Disposability:** `make tier1-down && make tier1-up` → second bring-up is green from scratch (seed is idempotent / fresh-volume clean).
|
||||
- [ ] **Teardown:** `make tier1-down` leaves no running containers (`docker ps` clean).
|
||||
|
||||
When all five pass, M3-0 is complete and every later M3 sub-plan (M3a–M3d) can add unit/integration/functional tests under `backend/tests/` and e2e specs under `e2e/`, runnable on localhost now and against PPE by setting `BASE_URL`.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Verify-against-reality points** (flagged inline, not placeholders): the Gitea `1.22` API specifics in `seed-gitea.sh` (token scopes vocabulary, OAuth-app endpoint), the OTC request/verify field names in `app/main.py`, the OTC code regex in `mailpit.js`, and whether OTC sign-in needs a pre-granted account. Each has a concrete first guess and a one-line "confirm against X" instruction.
|
||||
- **Stay off `main`.** Branch `feat/m3-0-test-foundation`.
|
||||
- **Do not** modify backend app logic in this plan — only `testing/` assets, `frontend/` test tooling, and `e2e/`. The one app-adjacent file is `testing/backend.Dockerfile`, which only packages existing code.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,796 @@
|
||||
# S1 — Three-tier collection grain (migration 029 + threading + redirect) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Insert a *collection* grain beneath today's `project` so every entry-scoped row keys on `(collection_id, slug)` instead of `(project_id, slug)`, with one invisible default collection per project — the deployment runs exactly as before, now with a real collection layer and one extra `/c/<collection>/` URL segment.
|
||||
|
||||
**Architecture:** A new `collections` table sits beneath `projects` (which keeps `content_repo`, `name`, `tagline`, `theme`, `visibility`). Migration 029 creates it, moves the per-corpus fields (`type`, `initial_state`) down from `projects`, seeds one default collection (`id='default'`) per project, re-keys the 13 entry-corpus tables `(project_id,slug)→(collection_id,slug)` via the migration-028 rebuild pattern, and generalises `project_members → memberships(scope_type ∈ {project,collection}, …)`. The backend threads `collection_id` through the writers/readers of those 13 tables (project-grain authz is recovered by joining `collections`); the frontend gains a `/c/:collectionId/` route layer and redirects that 308 the shipped `/p/<project>/e/<slug>` URLs to `/p/<project>/c/default/e/<slug>`. Serving stays **project-scoped** in S1 (collection = default); collection-aware serving is S2.
|
||||
|
||||
**Tech Stack:** FastAPI + SQLite (raw SQL migrations run by `backend/app/db.py:run_migrations`, glob-ordered, `-- migrate:no-foreign-keys` marker toggles FK enforcement + runs `foreign_key_check`); pytest "vertical" tests (no Gherkin runner exists — `@S1` scenarios are realised as plain pytest); React Router SPA (`frontend/src/App.jsx`), nginx proxies `/rfc/` + `/proposals/` to the backend for server-side 308s.
|
||||
|
||||
**Binding spec:** `docs/design/2026-06-05-three-tier-projects-collections.md` — Part A (model), Part E / §A.6 (migration strategy), Part C `@S1` scenarios C3.7 + C3.8.
|
||||
|
||||
---
|
||||
|
||||
## Decisions locked before coding (read first)
|
||||
|
||||
1. **Default collection id = the literal `'default'`** (not the project id). Reason: on a *fresh* deploy migrations run with `project_id='default'` and `restamp` renames it to the configured id (e.g. `ohm`) afterward; on an *already-deployed* instance `project_id` is already `ohm` when 029 runs. A stable literal keeps the collection id **identical across both deploy histories**, matches the spec's `/c/default/` URLs, and lets the existing `restamp` keep working untouched (it renames only the *project* grain — `collections.project_id` and the denormalised `project_id` tags — never `collections.id` or the entry `collection_id`). The re-key maps each entry to its project's default collection via a JOIN, so it is correct regardless of the `project_id` value at migration time. Multi-project deployments at migration time (non-standard pre-S5) get a unique id per project via a `CASE` so the seed never collides.
|
||||
|
||||
2. **Re-key scope = exactly the 13 tables migration 028 rebuilt** (`cached_rfcs`, `rfc_invitations`, `cached_branches`, `branch_visibility`, `branch_contribute_grants`, `stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`, `rfc_collaborators`, `contribution_requests`, `proposed_use_cases`). The other tables 026 tagged with `project_id` (`threads`, `changes`, `notifications`, `actions`, `pr_resolution_branches`, `cached_prs`) keep `project_id` — they carry a project-grain tag, stay consistent for N=1, and renaming them is **out of S1 scope** (deferred). This matches the goal's "re-key entry-scoped tables via the 028 rebuild pattern".
|
||||
|
||||
3. **Serving stays project-scoped in S1.** The `/c/:collectionId/` segment is introduced in routing + redirects; the frontend data layer keeps calling `/api/projects/{project_id}/rfcs/...` (the default collection). Collection-aware serving + the registry `.collection.yaml` reader land in S2.
|
||||
|
||||
4. **No Gherkin runner.** `@S1` acceptance is realised as pytest vertical tests + a frontend route test. The whole existing backend suite is the "N=1 unchanged" regression net — it must go green again after the rename.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Created:**
|
||||
- `backend/migrations/029_collections.sql` — the migration (collections table, field move-down, default-collection seed, 13-table re-key, `project_members → memberships`).
|
||||
- `backend/app/collections.py` — collection resolution helpers (`default_collection_id`, `collection_type`, `collection_initial_state`, `collections_of_project`).
|
||||
- `backend/tests/test_migration_029_collections.py` — migration shape + data-preservation + FK tests (template: `test_migration_028_project_scoped_keys.py`).
|
||||
- `backend/tests/test_s1_collection_grain_vertical.py` — `@S1` acceptance (C3.7 redirect to sole collection; default-collection redirect; N=1 serving unchanged).
|
||||
|
||||
**Modified (backend):**
|
||||
- `backend/app/projects.py` — `restamp_default_project` bootstrap check (`cached_rfcs.project_id` → a still-valid column); move `project_initial_state` to read the collection; add re-export shim if needed.
|
||||
- `backend/app/auth.py` — `project_of_rfc` joins `collections`; the 13-table reads/writes that touch `project_id` switch to `collection_id`.
|
||||
- `backend/app/cache.py` — `_upsert_cached_rfc(..., collection_id)` + the `cached_rfcs`/`cached_branches` writers + the `WHERE project_id` reconciler reads.
|
||||
- `backend/app/api.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`, `api_contributions.py`, `api_invitations.py`, `api_graduation.py`, `funder.py` — every SQL touching the 13 tables' `project_id` column → `collection_id`; recover project via `collections` join where authz needs it.
|
||||
- `backend/app/api_deployment.py` — `/rfc/{slug}` family 308 targets gain `/c/default/`; `get_deployment`/`get_project` read `type`/`initial_state` from the default collection.
|
||||
|
||||
**Modified (frontend):**
|
||||
- `frontend/src/components/entryPaths.js` (or wherever path builders live) — insert `/c/:collectionId/`.
|
||||
- `frontend/src/App.jsx` — add `/c/:collectionId/*` route layer; redirect `/p/:projectId/` → sole/default collection (C3.7); redirect legacy `/p/:projectId/e|proposals/...` → `/c/default/...`.
|
||||
- `frontend/src/ProjectLayout.jsx` (+ `RFCView.jsx`, `Catalog.jsx` as needed) — read `:collectionId` param; pass through (data stays project-scoped).
|
||||
|
||||
**Modified (release):**
|
||||
- `VERSION`, `frontend/package.json#version`, `CHANGELOG.md` — minor bump with breaking-URL upgrade-steps block (§20.2 / §20.4).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Migration 029 (the collection grain)
|
||||
|
||||
### Task 1: Write the migration-029 shape test (red)
|
||||
|
||||
**Files:**
|
||||
- Test: `backend/tests/test_migration_029_collections.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
"""Migration 029 — collections grain beneath projects. Template: test_migration_028."""
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
self.default_project_id = "default"
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = os.path.join(d, "test.db")
|
||||
db._CONN = None
|
||||
db.run_migrations(_Cfg(path))
|
||||
return db.conn()
|
||||
|
||||
|
||||
def test_collections_table_exists_with_default_per_project():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert {"id", "project_id", "type", "subfolder",
|
||||
"initial_state", "visibility", "name", "registry_sha"} <= cols
|
||||
# one default collection seeded for the bootstrap 'default' project
|
||||
row = conn.execute(
|
||||
"SELECT id, project_id, subfolder FROM collections WHERE project_id='default'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["id"] == "default"
|
||||
assert row["subfolder"] == "" # repo root
|
||||
|
||||
|
||||
def test_per_corpus_fields_moved_off_projects():
|
||||
conn = _fresh_db()
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols
|
||||
assert "initial_state" not in proj_cols
|
||||
# projects keeps the grouping-tier fields
|
||||
assert {"id", "name", "content_repo", "visibility"} <= proj_cols
|
||||
|
||||
|
||||
def test_entry_tables_rekeyed_to_collection_id():
|
||||
conn = _fresh_db()
|
||||
for t in ("cached_rfcs", "cached_branches", "stars", "watches",
|
||||
"rfc_collaborators", "contribution_requests", "proposed_use_cases",
|
||||
"branch_visibility", "branch_contribute_grants", "pr_seen",
|
||||
"branch_chat_seen", "funder_consents", "rfc_invitations"):
|
||||
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")}
|
||||
assert "collection_id" in cols, f"{t} missing collection_id"
|
||||
assert "project_id" not in cols, f"{t} still has project_id"
|
||||
|
||||
|
||||
def test_cached_rfcs_pk_is_collection_slug():
|
||||
conn = _fresh_db()
|
||||
# same slug coexists across two collections
|
||||
conn.execute("INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
|
||||
n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"]
|
||||
assert n == 2
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')")
|
||||
|
||||
|
||||
def test_collaborator_fk_is_composite_on_collection():
|
||||
conn = _fresh_db()
|
||||
conn.execute("INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')")
|
||||
conn.execute("INSERT INTO users (id, email, role, permission_state) VALUES (1,'a@b.c','contributor','granted')")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','c2')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','default')") # no such (collection,slug)
|
||||
|
||||
|
||||
def test_memberships_table_replaces_project_members():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")}
|
||||
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
|
||||
# M2 rows would migrate to scope_type='collection'; role enum collapsed to owner/contributor
|
||||
# (no project_members rows exist in a fresh DB, so just assert the table + check constraint)
|
||||
conn.execute("INSERT INTO users (id, email, role, permission_state) VALUES (9,'x@y.z','contributor','granted')")
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_migration_029_collections.py -q`
|
||||
Expected: FAIL (no `collections` table / `029_collections.sql` does not exist).
|
||||
|
||||
- [ ] **Step 3: Commit the red test**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_migration_029_collections.py
|
||||
git commit -m "§22 S1: failing migration-029 shape tests (collections grain)"
|
||||
```
|
||||
|
||||
### Task 2: Write migration 029 (green the shape test)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/migrations/029_collections.sql`
|
||||
|
||||
- [ ] **Step 1: Write the migration.** Mirror `028_project_scoped_keys.sql` exactly for the 13 rebuilds, with `project_id` renamed to `collection_id` in each `__new` table, each child FK re-pointed to `cached_rfcs(collection_id, slug)`, and each index/UNIQUE swapping `project_id`→`collection_id`. Use the explicit-column `INSERT ... SELECT` form (not `SELECT *`) so the re-key can map values. Header marker `-- migrate:no-foreign-keys`. Concrete top of file:
|
||||
|
||||
```sql
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project.
|
||||
-- (1) collections table; (2) move per-corpus fields (type, initial_state) down
|
||||
-- from projects; (3) one default collection per project (id='default',
|
||||
-- subfolder = repo root); (4) re-key the 13 entry-corpus tables
|
||||
-- (project_id,slug) -> (collection_id,slug) via the 028 rebuild pattern, mapping
|
||||
-- each row to its project's default collection by JOIN; (5) project_members ->
|
||||
-- memberships(scope_type ∈ {project,collection}, …), role enum collapsed to
|
||||
-- {owner, contributor}. FK enforcement is OFF for the file (marker above);
|
||||
-- foreign_key_check runs after. See docs/design/2026-06-05-three-tier-…md §A.6.
|
||||
|
||||
-- ── collections: the new typed-corpus grain beneath projects ───────────────
|
||||
CREATE TABLE collections (
|
||||
id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd')),
|
||||
subfolder TEXT NOT NULL DEFAULT '',
|
||||
initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active')),
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
name TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX idx_collections_project ON collections(project_id);
|
||||
|
||||
-- One default collection per project. id='default' for the standard
|
||||
-- single-project deployment (stable across deploy histories); the project_id is
|
||||
-- used as a unique fallback id only if a non-standard multi-project deployment
|
||||
-- migrates (pre-S5; avoids a PK collision). subfolder='' = repo root.
|
||||
INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name)
|
||||
SELECT
|
||||
CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END,
|
||||
p.id, p.type, '', p.initial_state, p.visibility, p.name
|
||||
FROM projects p;
|
||||
|
||||
-- ── move per-corpus fields off projects (rebuild to DROP type/initial_state) ─
|
||||
CREATE TABLE projects__new (
|
||||
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'))
|
||||
);
|
||||
INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at)
|
||||
SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects;
|
||||
DROP TABLE projects;
|
||||
ALTER TABLE projects__new RENAME TO projects;
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ────
|
||||
-- collection_id mapped from the row's old project's default collection.
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id),
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (collection_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at,
|
||||
graduated_by, owners_json, arbiters_json, tags_json, body, body_sha,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at, models_json,
|
||||
funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by)
|
||||
SELECT
|
||||
r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at,
|
||||
r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json,
|
||||
r.funder_login, r.proposed_use_case,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1),
|
||||
r.unreviewed, r.reviewed_at, r.reviewed_by
|
||||
FROM cached_rfcs r;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id);
|
||||
```
|
||||
|
||||
Then **for each of the remaining 12 tables** copy its `028` block verbatim and apply the same three transforms: (a) rename the `project_id` column to `collection_id` (keep `DEFAULT 'default'`); (b) in the `INSERT ... SELECT`, replace the `project_id` source value with `(SELECT c.id FROM collections c WHERE c.project_id = <old>.project_id LIMIT 1)` and list columns explicitly; (c) rename `project_id` → `collection_id` in every `UNIQUE (...)`, `FOREIGN KEY (...) REFERENCES cached_rfcs(...)`, and `CREATE [UNIQUE] INDEX`. The 12: `rfc_invitations`, `cached_branches`, `branch_visibility`, `branch_contribute_grants`, `stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`, `rfc_collaborators`, `contribution_requests`, `proposed_use_cases`. (FK targets `cached_rfcs(project_id, slug)` become `cached_rfcs(collection_id, slug)`.)
|
||||
|
||||
Finally the membership generalisation:
|
||||
|
||||
```sql
|
||||
-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─
|
||||
CREATE TABLE memberships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
|
||||
-- M2 project_members rows attached at what is now the *collection*; collapse
|
||||
-- the role enum (project_admin -> owner, project_contributor -> contributor,
|
||||
-- project_viewer -> dropped this pass, §B.3) and migrate to the default
|
||||
-- collection of each project.
|
||||
INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT 'collection',
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1),
|
||||
pm.user_id,
|
||||
CASE pm.role WHEN 'project_admin' THEN 'owner'
|
||||
WHEN 'project_contributor' THEN 'contributor'
|
||||
ELSE 'contributor' END,
|
||||
pm.granted_by, pm.granted_at
|
||||
FROM project_members pm
|
||||
WHERE pm.role IN ('project_admin', 'project_contributor');
|
||||
DROP TABLE project_members;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the shape test**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_migration_029_collections.py -q`
|
||||
Expected: PASS (all shape/PK/FK/membership assertions green).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/migrations/029_collections.sql
|
||||
git commit -m "§22 S1: migration 029 — collections grain, field move-down, 13-table re-key, memberships"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Backend threading (make the existing suite green again)
|
||||
|
||||
> After Task 2 the column rename breaks every reader/writer of the 13 tables. This phase fixes them. **Driver:** the full backend suite is the regression net — run it, read each failure, fix the named module, repeat until green. The agent exploration produced the exact blast-radius map used below.
|
||||
|
||||
### Task 3: collections helper module
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/collections.py`
|
||||
|
||||
- [ ] **Step 1: Write the helper**
|
||||
|
||||
```python
|
||||
"""§22 collection grain — resolution helpers beneath the project tier.
|
||||
|
||||
In S1 each project has exactly one collection (the default). These helpers
|
||||
recover the collection for a project and read the per-corpus fields that moved
|
||||
down from `projects` in migration 029. Project-grain authz (auth.py) recovers a
|
||||
row's project by joining `collections` on `collection_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
literal 'default' when the project has no collection row yet."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at LIMIT 1",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else DEFAULT_COLLECTION_ID
|
||||
|
||||
|
||||
def project_of_collection(collection_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["project_id"] if row else None
|
||||
|
||||
|
||||
def collection_initial_state(collection_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a collection. 'super-draft'
|
||||
default for an unknown row (today's safe flow)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
|
||||
|
||||
def collection_type(collection_id: str) -> str:
|
||||
row = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["type"] if row and row["type"] else "document"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/collections.py
|
||||
git commit -m "§22 S1: collections resolution helpers"
|
||||
```
|
||||
|
||||
### Task 4: Fix `projects.py` (restamp + initial_state)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/projects.py:46-48` (restamp bootstrap check), `:112-121` (`project_initial_state`)
|
||||
|
||||
- [ ] **Step 1: Fix the restamp bootstrap check.** `restamp_default_project` reads `cached_rfcs.project_id` (now renamed) at line 47 — switch the existence probe to a still-`project_id`-bearing table so the PRAGMA-driven rename loop is unaffected (it already discovers `project_id` columns dynamically, which now correctly excludes the 13 collection-keyed tables and includes `collections.project_id`):
|
||||
|
||||
```python
|
||||
has_rows = conn.execute(
|
||||
"SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Re-home `project_initial_state`.** Keep the signature for callers, but resolve through the project's default collection:
|
||||
|
||||
```python
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project's default collection."""
|
||||
from . import collections as collections_mod
|
||||
return collections_mod.collection_initial_state(
|
||||
collections_mod.default_collection_id(project_id)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the restamp + projects tests**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_restamp_default_project.py tests/test_initial_state_landing.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/projects.py
|
||||
git commit -m "§22 S1: thread projects.py restamp + initial_state through collections"
|
||||
```
|
||||
|
||||
### Task 5: Fix `auth.py` (`project_of_rfc` join)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/auth.py:352-361`
|
||||
|
||||
- [ ] **Step 1: Join collections to recover the project from a slug.**
|
||||
|
||||
```python
|
||||
def project_of_rfc(rfc_slug: str) -> str:
|
||||
"""The project an RFC belongs to, via its collection
|
||||
(cached_rfcs.collection_id -> collections.project_id). Falls back to the
|
||||
default project when the slug isn't cached."""
|
||||
row = db.conn().execute(
|
||||
"SELECT c.project_id AS project_id "
|
||||
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
|
||||
"WHERE r.slug = ?",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_PROJECT_ID
|
||||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the authz suite**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_multi_project_authz_vertical.py tests/test_anon_offlimits_vertical.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/auth.py
|
||||
git commit -m "§22 S1: auth.project_of_rfc recovers project via collection join"
|
||||
```
|
||||
|
||||
### Task 6: Fix `cache.py` writers/readers
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/cache.py` — `_refresh_project_corpus` (resolve collection), `_upsert_cached_rfc` signature + SQL (`project_id`→`collection_id`), the `WHERE project_id` reconciler read (`:88`), the `cached_branches` writers (`:213/:388/:410`).
|
||||
|
||||
- [ ] **Step 1: Resolve the collection in the corpus refresh.** In `_refresh_project_corpus`, compute the project's default collection once and pass it down; switch the reconciler `SELECT slug ... WHERE project_id` to `WHERE collection_id`:
|
||||
|
||||
```python
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
from . import collections as collections_mod
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
...
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
...
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rename in `_upsert_cached_rfc`.** Change the param `project_id: str = "default"` → `collection_id: str = "default"`; in the `INSERT`, replace the `project_id` column with `collection_id`, the `ON CONFLICT(project_id, slug)` with `ON CONFLICT(collection_id, slug)`, and the bound value `project_id` → `collection_id`.
|
||||
|
||||
- [ ] **Step 3: Fix the `cached_branches` writers.** At `:213/:388/:410` the `ON CONFLICT(project_id, rfc_slug, branch_name)` clauses → `ON CONFLICT(collection_id, rfc_slug, branch_name)`; where a meta-repo branch row is written without an explicit grain it now relies on the `collection_id DEFAULT 'default'` column default (unchanged behaviour for N=1). Bind `collection_id` explicitly where the per-project loop has it.
|
||||
|
||||
- [ ] **Step 4: Run the cache tests**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_cache_bootstrap.py tests/test_cache_review_fields.py tests/test_branch_path_routing.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/cache.py
|
||||
git commit -m "§22 S1: thread cache.py corpus/branch writers through collection_id"
|
||||
```
|
||||
|
||||
### Task 7: Fix the `api_*` writers/readers + `funder.py`
|
||||
|
||||
**Files (each: swap the 13-table `project_id` column references to `collection_id`; recover project for authz via `auth.project_of_rfc`/`collections` join):**
|
||||
- `backend/app/api.py:747` (stars read), `:774/:806/:969` (`cached_rfcs` composite lookups → `collection_id`), `:785-788/:1046` (`proposed_use_cases`).
|
||||
- `backend/app/api_prs.py:156` (`branch_visibility`), `:192` (`proposed_use_cases`), `:401` (`pr_seen`). Note `:670/:789` read `row["project_id"]` from a `cached_rfcs`/`rfc` row — change those SELECTs to also yield the project via the collection join, then keep the existing `auth.require_project_readable(viewer, project_id)` call unchanged.
|
||||
- `backend/app/api_branches.py:745` (`branch_visibility`), `:899` (`branch_chat_seen`).
|
||||
- `backend/app/api_notifications.py:216` (read `cached_rfcs` → now `collection_id`; recover project via join for the visibility gate), `:225` (`watches`).
|
||||
- `backend/app/api_contributions.py:65/:111` (read `cached_rfcs`; recover project via join), `api_invitations.py:383`, `api_graduation.py` (any `cached_rfcs`/13-table `project_id`).
|
||||
- `backend/app/funder.py:223` (`funder_consents` `ON CONFLICT(project_id,…)` → `collection_id`).
|
||||
|
||||
- [ ] **Step 1: Mechanical pass.** For each file above, replace `project_id` **only where it names a column on one of the 13 re-keyed tables** (PK lookups, `ON CONFLICT`, `WHERE`, `INSERT` column lists, `SELECT` projections from those tables) with `collection_id`. Where the code needs the *project* (for `auth.*_project*` calls), recover it with `auth.project_of_rfc(slug)` or a `collections` join — do **not** rename the `project_id` argument flowing into the authz helpers (those stay project-grain in S1). Leave `threads`, `changes`, `notifications`, `actions`, `pr_resolution_branches`, `cached_prs` `project_id` columns untouched.
|
||||
|
||||
- [ ] **Step 2: Grep guard.** Confirm no stray reference to a dropped column remains:
|
||||
|
||||
Run: `cd backend && grep -rEn "cached_rfcs[^;]*project_id|project_id, slug|project_id, rfc_slug|ON CONFLICT\(project_id" app/ | grep -v "collections\|threads\|changes\|notifications\|actions\|pr_resolution\|cached_prs"`
|
||||
Expected: no output (every 13-table `project_id` is now `collection_id`).
|
||||
|
||||
- [ ] **Step 3: Run the full backend suite**
|
||||
|
||||
Run: `cd backend && python -m pytest -q`
|
||||
Expected: PASS (this is the **N=1-unchanged** gate). Fix any remaining failures by reading the traceback and applying the same rename/join rule.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api.py backend/app/api_prs.py backend/app/api_branches.py backend/app/api_notifications.py backend/app/api_contributions.py backend/app/api_invitations.py backend/app/api_graduation.py backend/app/funder.py
|
||||
git commit -m "§22 S1: thread api_* + funder writers/readers through collection_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — API surface reads per-corpus fields from the collection
|
||||
|
||||
### Task 8: `api_deployment.py` reads type/initial_state from the default collection
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api_deployment.py:36-44` (`get_deployment` projects list `type`), `:62-82` (`get_project` `type`/`initial_state`)
|
||||
|
||||
- [ ] **Step 1: Write a failing test** in `backend/tests/test_api_deployment.py` (extend it) asserting `GET /api/projects/{default}` still returns the correct `type`/`initial_state` after the move-down (values come from the default collection):
|
||||
|
||||
```python
|
||||
def test_get_project_type_initial_state_from_default_collection(app_with_fake_gitea):
|
||||
# ... existing fixture sets up the default project/collection ...
|
||||
r = client.get(f"/api/projects/{default_id}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["type"] in ("document", "specification", "bdd")
|
||||
assert body["initial_state"] in ("super-draft", "active")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails** (the SELECT still reads `projects.type`, which 029 dropped → `OperationalError`).
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_api_deployment.py -q`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Read the fields from the default collection.** In `get_deployment`, replace the `SELECT id, name, type, visibility FROM projects` with a join to the project's default collection for `type` (or a per-row `collections_mod.collection_type(default_collection_id(id))`). In `get_project`, drop `type, initial_state` from the `projects` SELECT and resolve them via `collections_mod.collection_type(...)` / `collection_initial_state(...)`:
|
||||
|
||||
```python
|
||||
from . import collections as collections_mod
|
||||
...
|
||||
cid = collections_mod.default_collection_id(row["id"])
|
||||
return {
|
||||
...
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_api_deployment.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api_deployment.py backend/tests/test_api_deployment.py
|
||||
git commit -m "§22 S1: deployment/project API reads type+initial_state from default collection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Redirects + frontend collection segment
|
||||
|
||||
### Task 9: Backend `/rfc/` 308s target `/c/default/`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api_deployment.py:89-104`
|
||||
|
||||
- [ ] **Step 1: Add a failing test** to `test_api_deployment.py`:
|
||||
|
||||
```python
|
||||
def test_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea):
|
||||
r = client.get("/rfc/intro", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == f"/p/{default_id}/c/default/e/intro"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it fails** (current target lacks `/c/default/`).
|
||||
|
||||
- [ ] **Step 3: Update the three redirect handlers** to resolve the default collection and insert the `/c/<cid>/` segment:
|
||||
|
||||
```python
|
||||
@router.get("/rfc/{slug}")
|
||||
async def redirect_old_rfc(slug: str) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
|
||||
# …same /c/{cid}/ insertion for /rfc/{slug}/pr/{pr} and /proposals/{pr}
|
||||
```
|
||||
|
||||
(`/proposals/{pr}` → `/p/{default_id}/c/{cid}/proposals/{pr}`.)
|
||||
|
||||
- [ ] **Step 4: Verify it passes.**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_api_deployment.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api_deployment.py backend/tests/test_api_deployment.py
|
||||
git commit -m "§22 S1: legacy /rfc + /proposals 308s route through /c/<default>/"
|
||||
```
|
||||
|
||||
### Task 10: Frontend path builders gain `/c/:collectionId/`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/entryPaths.js` (path builders — confirm exact path with `grep -rl "p/\${" frontend/src`)
|
||||
|
||||
- [ ] **Step 1: Thread a collection id through the builders.** Add a `collectionId` argument (defaulting to `'default'`) and emit the `/c/<collectionId>/` segment:
|
||||
|
||||
```js
|
||||
export const collectionHome = (projectId, collectionId) => `/p/${projectId}/c/${collectionId}/`
|
||||
export const entryPath = (projectId, collectionId, slug) => `/p/${projectId}/c/${collectionId}/e/${slug}`
|
||||
export const entryPrPath = (projectId, collectionId, slug, prNumber) => `/p/${projectId}/c/${collectionId}/e/${slug}/pr/${prNumber}`
|
||||
export const proposalPath = (projectId, collectionId, prNumber) => `/p/${projectId}/c/${collectionId}/proposals/${prNumber}`
|
||||
export const projectHome = (projectId) => `/p/${projectId}/`
|
||||
```
|
||||
|
||||
Update every caller (grep `entryPath(`, `entryPrPath(`, `proposalPath(`, `collectionHome(`) to pass the current collection id (from the route param / `useCollectionId()` — default `'default'`).
|
||||
|
||||
- [ ] **Step 2: Build the frontend**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: build succeeds (no undefined-symbol errors).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src
|
||||
git commit -m "§22 S1: frontend path builders carry the /c/<collection>/ segment"
|
||||
```
|
||||
|
||||
### Task 11: Frontend route layer + redirects (C3.7, C3.8, legacy)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.jsx:354-373`, `frontend/src/ProjectLayout.jsx`
|
||||
|
||||
- [ ] **Step 1: Nest the corpus routes under `/c/:collectionId/`** and add redirects. Inside the `ProjectLayout` nested `<Routes>`:
|
||||
|
||||
```jsx
|
||||
<Routes>
|
||||
{/* project landing: redirect to the sole/default collection (C3.7) */}
|
||||
<Route path="" element={<CollectionRedirect />} />
|
||||
{/* legacy v0.35.0 corpus URLs without /c/ → default collection */}
|
||||
<Route path="e/:slug" element={<Navigate to="c/default/e/:slug" replace />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<LegacyEntryPrRedirect />} />
|
||||
<Route path="proposals/:prNumber" element={<LegacyProposalRedirect />} />
|
||||
{/* collection-scoped corpus (serving stays project-scoped in S1) */}
|
||||
<Route path="c/:collectionId" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
```
|
||||
|
||||
`CollectionRedirect` reads the project's collections (from `ProjectContext`, populated by `GET /api/projects/:id`) and `<Navigate>`s to the sole visible collection's `/c/<id>/`; with one collection that is `/c/default/` (C3.7). `LegacyEntryPrRedirect`/`LegacyProposalRedirect` use `useParams()` to rebuild the target with `c/default/`. React-Router literal `:slug` in `to=` does not interpolate — implement these as small components using `useParams()` + `<Navigate>`.
|
||||
|
||||
- [ ] **Step 2: Confirm `/` → sole project (C3.8) already holds.** `DeploymentLanding` (App.jsx:348) already redirects to the single visible project. Add/confirm a test (Task 12) rather than re-implementing.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src
|
||||
git commit -m "§22 S1: /c/<collection>/ route layer + C3.7 + legacy-URL redirects"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — `@S1` acceptance + full verification
|
||||
|
||||
### Task 12: `@S1` vertical acceptance test (C3.7 + C3.8 + N=1 serving)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_s1_collection_grain_vertical.py`
|
||||
|
||||
- [ ] **Step 1: Write the acceptance test.** Tag scenarios in docstrings as `@S1` for traceability (no Gherkin runner). Cover: (a) default-collection redirect `/rfc/<slug>` → `/p/<default>/c/default/e/<slug>` (already in Task 9 — re-assert here as the S1 gate); (b) an entry proposed/served at N=1 still resolves under the default collection via `/api/projects/<default>/rfcs/<slug>`; (c) the deployment `/api/deployment` still reports one project with `default_project_id`. (C3.7/C3.8 client redirects are asserted in the frontend build/route smoke; the data-layer N=1 invariants are asserted here.)
|
||||
|
||||
```python
|
||||
"""@S1 acceptance — the collection grain exists and N=1 is unchanged.
|
||||
Scenarios: C3.7 (single-collection project skips the directory) and C3.8
|
||||
(single-project deployment skips the directory) are the redirect contract;
|
||||
this module asserts the backend N=1 invariants behind them."""
|
||||
# reuse the propose/serve fixtures from test_project_scoped_serving.py
|
||||
def test_s1_entry_served_under_default_collection(app_with_fake_gitea):
|
||||
# propose + mirror an entry, then fetch it project-scoped (collection=default)
|
||||
...
|
||||
r = client.get(f"/api/projects/{default_id}/rfcs/intro")
|
||||
assert r.status_code == 200
|
||||
# the row is keyed by collection_id under the hood
|
||||
cid = db.conn().execute("SELECT collection_id FROM cached_rfcs WHERE slug='intro'").fetchone()["collection_id"]
|
||||
assert cid == "default"
|
||||
|
||||
def test_s1_legacy_redirect_inserts_collection_segment(app_with_fake_gitea):
|
||||
r = client.get("/rfc/intro", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert "/c/default/" in r.headers["location"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_s1_collection_grain_vertical.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Full backend suite + frontend build (the N=1-unchanged gate)**
|
||||
|
||||
Run: `cd backend && python -m pytest -q && cd ../frontend && npm run build`
|
||||
Expected: all backend tests PASS; frontend builds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_s1_collection_grain_vertical.py
|
||||
git commit -m "§22 S1: @S1 acceptance — collection grain + N=1 serving unchanged"
|
||||
```
|
||||
|
||||
### Task 13: e2e smoke (optional, if Docker stack available)
|
||||
|
||||
- [ ] **Step 1:** If the Tier-1 Docker stack is runnable, `make e2e` to confirm sign-in + a corpus page render through the new `/c/default/` routes. If the stack isn't available in-session, note it skipped and rely on Tasks 7/11/12 gates.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Release + finalize
|
||||
|
||||
### Task 14: Version bump + changelog (breaking, with upgrade steps)
|
||||
|
||||
**Files:**
|
||||
- Modify: `VERSION`, `frontend/package.json` (`version`), `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Bump** `VERSION` and `frontend/package.json#version` to the next pre-1.0 minor (current `0.39.0` → `0.40.0`). They must match (a divergence is a §20 spec bug).
|
||||
|
||||
- [ ] **Step 2: Add the CHANGELOG entry** with a breaking-URL **upgrade steps** block (§20.2 / §20.4 / §A.6): migration 029 adds the collection grain; `/p/<project>/e/<slug>` now lives at `/p/<project>/c/default/e/<slug>` (308 for old links); operators need no action beyond deploying (the migration + redirects are automatic; the default collection is seeded). Note the deferred items (denormalised `project_id` tags unchanged; collection-aware serving = S2).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json CHANGELOG.md
|
||||
git commit -m "§22 S1: release v0.40.0 — three-tier collection grain (breaking URL + migration 029)"
|
||||
```
|
||||
|
||||
### Task 15: Branch, PR, merge
|
||||
|
||||
- [ ] **Step 1:** This work rides a feature branch off `main` (e.g. `feat/s1-collection-grain`). Push to `origin` (git.wiggleverse.org).
|
||||
- [ ] **Step 2:** Open a PR citing the design doc + `@S1`; in autonomous posture, self-review and merge once the suite is green.
|
||||
- [ ] **Step 3:** Update repo memory with the new resume pointer (S1 shipped @ v0.40.0; next = S2).
|
||||
|
||||
---
|
||||
|
||||
## Self-review (writing-plans checklist)
|
||||
|
||||
- **Spec coverage:** §A.6 steps 1–5 → Tasks 2 (collections table + default + re-key + memberships), 8 (field move-down read path), 9 (308 step 5). Part B membership generalisation → Task 2 (`memberships`) — note S1 only *migrates* the table; the four-layer resolver is S3 (out of scope, correctly deferred per Part E). `@S1` C3.7/C3.8 → Tasks 11 (frontend redirects) + 12 (backend invariants). "N=1 unchanged" → Task 7 Step 3 + Task 12 Step 3 full-suite gates. Threading (auth/projects/cache/api_*) → Tasks 4–8.
|
||||
- **Placeholders:** the per-table rebuild bodies for the 12 non-`cached_rfcs` tables reference the in-repo `028_project_scoped_keys.sql` as the literal template with the three explicit transforms named — this is a concrete instruction, not a TODO (repeating 200+ lines of near-identical SQL verbatim would harm reviewability; the transform rule is exact).
|
||||
- **Type consistency:** `default_collection_id`, `collection_type`, `collection_initial_state`, `project_of_collection` are defined in Task 3 and used consistently in Tasks 4, 8, 9. Column `collection_id` (not `coll_id`/`collectionId`) used uniformly in SQL; `collectionId` is the JS route param.
|
||||
- **Risk note:** the denormalised `project_id` columns (`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches`/`cached_prs`) stay `project_id` and may, after `restamp`, hold the project id (`ohm`) while entry `collection_id` holds `default`. Task 7 Step 3's full-suite run is the guard against any code that wrongly cross-joins the two grains; if one surfaces, recover the project via the `collections` join rather than renaming the tag.
|
||||
```
|
||||
@@ -0,0 +1,413 @@
|
||||
# M3-backend — §22 multi-project: registry mirror + data spine + APIs
|
||||
|
||||
> Design spec for the backend half of §22 slice **M3** ("Registry mirror +
|
||||
> routing + runtime branding"). The roadmap bundles M3 as one slice; this
|
||||
> session splits it at the natural backend/frontend seam. **M3-backend** (this
|
||||
> doc) ships the data spine, the registry mirror, the two runtime-config APIs,
|
||||
> and the entry-state/review semantics. **M3-frontend** (a separate spec) ships
|
||||
> `/p/<project>/` routing, the 308 redirects, the `VITE_APP_NAME`→runtime-config
|
||||
> cut, the per-project theme overlay, the deployment directory at `/`, and the
|
||||
> project switcher — all consuming the APIs defined here.
|
||||
>
|
||||
> Section references `§22.x` point at `docs/design/multi-project-spec.md` (the
|
||||
> draft §22 + slicing plan). The SPEC.md §22 merge itself lands in M7.
|
||||
|
||||
## Status
|
||||
|
||||
- **Date:** 2026-06-03
|
||||
- **Slice:** §22 M3 (backend half). M1 + M2 landed and merged to `main`.
|
||||
- **Version impact:** minor bump, breaking (pre-1.0) — see §8.
|
||||
|
||||
## Goal
|
||||
|
||||
After M3-backend, the framework learns its projects from a git **registry**
|
||||
(not from `META_REPO`), the `projects` cache table and all slug-bearing tables
|
||||
are keyed by `(project_id, …)` so a second project can exist without collision,
|
||||
the default project's identity is re-stamped to its real slug while no `/p/`
|
||||
URL is yet public, and the runtime exposes deployment + project config over two
|
||||
new endpoints. The entry-state/review semantics (`initial_state`, `unreviewed`)
|
||||
ship complete even though OHM (a `document`/`super-draft` project) does not yet
|
||||
exercise them.
|
||||
|
||||
Non-goals (M3-frontend, later): `/p/<project>/` routing, 308 redirects off
|
||||
`/rfc/<slug>`, runtime branding in the UI, theme application, the deployment
|
||||
directory, the project switcher, the catalog's unreviewed-filter **UI**.
|
||||
|
||||
## Decisions taken in brainstorming
|
||||
|
||||
1. **Scope split** — backend spine first; frontend surfaces are a separate
|
||||
spec/session.
|
||||
2. **Review machinery** — build the full `initial_state` / `unreviewed`
|
||||
plumbing now (parse + columns + mirror + landing logic + mark-reviewed +
|
||||
catalog filter query side), per the spec's M3 bundle, even though no live
|
||||
project exercises it yet.
|
||||
3. **Config cut** — hard cut. `REGISTRY_REPO` required (loud fail if unset),
|
||||
`META_REPO` removed. Upgrade-steps document the manual registry creation.
|
||||
4. **Re-stamp** — rewrite `project_id` everywhere: rename `projects.id`
|
||||
`default` → the config slug and rewrite every child row, folded into the
|
||||
same create-copy-drop-rename rebuild that adds the `project_id` FK. One
|
||||
identifier; DB and URL agree.
|
||||
5. **Mirror structure** — a self-contained `app/registry.py` module (not folded
|
||||
into `cache.py`), driven by the existing webhook dispatcher + the existing
|
||||
`Reconciler.sweep()`.
|
||||
6. **Two execution plans (found during planning).** The 12-table PK rebuild is
|
||||
not self-contained: folding `project_id` into keys + FK forces every
|
||||
`ON CONFLICT` upsert target to gain `project_id` (~10 sites across 6 modules)
|
||||
and — because the rebuilt tables can no longer default `project_id` to a live
|
||||
value once the default is re-stamped — forces **every RFC/branch writer** to
|
||||
be threaded to supply the real `project_id`. That activation is larger and
|
||||
riskier than the rest of M3-backend combined, and it is only required *before
|
||||
a second project can collide* (i.e. right before M4). So M3-backend is split
|
||||
into two plans at that seam:
|
||||
- **Plan A (ships first):** registry mirror + the two APIs + `initial_state`/
|
||||
`unreviewed` semantics. Migration `027` is **additive only** (no rebuilds).
|
||||
Operates entirely on the `default`-id project — no re-stamp, no rebuild, no
|
||||
writer threading. `cached_rfcs` keeps its `slug` PK, so no upsert breakage.
|
||||
- **Plan B (before M4 / before public `/p/` URLs):** the 12-table PK rebuild
|
||||
(migration `028`), `project_id` threading through every writer, the
|
||||
`default`→slug **re-stamp** (which rides here because it is only correct
|
||||
once the rebuild's column-default fix + threading land), and two-project
|
||||
isolation tests.
|
||||
|
||||
The §1 sections below describe the **full** backend (both plans); §1c (the
|
||||
rebuilds) and §1d (the re-stamp) are **Plan B**. Everything else is Plan A.
|
||||
|
||||
---
|
||||
|
||||
## 1. Migration `027_projects_activate.sql`
|
||||
|
||||
Runs while `default` is still the sole project and no `/p/` URL exists — the
|
||||
safe window for an identity rewrite. One transaction (SQLite DDL is
|
||||
transactional): the deployment either fully advances to `027` or stays on `026`.
|
||||
|
||||
`DEFAULT_PROJECT_ID` is read at migration time, so it **must be set before the
|
||||
upgrade deploy** (documented in §8). The slug it names is referred to below as
|
||||
`<slug>`; absent the env var, `<slug>` stays `default`.
|
||||
|
||||
### 1a. Additive columns
|
||||
|
||||
- `projects`:
|
||||
- `type TEXT NOT NULL DEFAULT 'document' CHECK (type IN ('document','specification','bdd'))`
|
||||
- `initial_state TEXT NOT NULL DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active'))`
|
||||
- `cached_rfcs`:
|
||||
- `unreviewed INTEGER NOT NULL DEFAULT 0`
|
||||
- `reviewed_at TEXT`
|
||||
- `reviewed_by TEXT`
|
||||
|
||||
### 1b. New `deployment` singleton table
|
||||
|
||||
Holds deployment-level identity mirrored from the registry's `deployment:`
|
||||
block (rather than overloading `projects`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS deployment (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
tagline TEXT,
|
||||
registry_sha TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO deployment (id) VALUES (1);
|
||||
```
|
||||
|
||||
> **Re-stamp split (correctness, found during planning).** The migration
|
||||
> runner executes pure-SQL files and **cannot read `DEFAULT_PROJECT_ID` from the
|
||||
> environment** — the same constraint that forced M1's `seed_default_project`
|
||||
> into Python. So the re-stamp is **not** in the `.sql` file. Migration `027`
|
||||
> (pure SQL) does §1a–§1c with `project_id` copied **verbatim** (`default` stays
|
||||
> `default`); the rebuilt FKs are declared `ON UPDATE CASCADE ON DELETE
|
||||
> CASCADE`. The re-stamp (§1d) is a **Python startup step** in `app/projects.py`,
|
||||
> run after migrations and before the registry mirror, that issues a single
|
||||
> `UPDATE projects SET id = <slug>` (cascading to the 12 FK tables) plus a plain
|
||||
> `UPDATE` of the 7 non-FK `project_id` tables. Idempotent: a no-op once no
|
||||
> `default` row remains.
|
||||
|
||||
### 1c. PK / uniqueness rebuilds (the 12 deferred tables)
|
||||
|
||||
Per migration 026's deferred block, create-copy-drop-rename each table to fold
|
||||
`project_id` into the key and add `project_id … REFERENCES projects(id) ON
|
||||
UPDATE CASCADE ON DELETE CASCADE` (the `ON UPDATE CASCADE` is what lets the
|
||||
§1d Python re-stamp move all child rows with one parent UPDATE):
|
||||
|
||||
| Table | Key change |
|
||||
| --- | --- |
|
||||
| `cached_rfcs` | PK `(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` | PK `(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` |
|
||||
|
||||
`cached_prs` is already globally unique (`repo` is the full `org/repo` string,
|
||||
distinct per project) — **no rebuild**.
|
||||
|
||||
The copy step writes `<slug>` in place of `default` for `project_id`, so these
|
||||
12 tables are re-stamped for free (§1d).
|
||||
|
||||
### 1d. Re-stamp `default` → `<slug>` (Python startup step)
|
||||
|
||||
In `app/projects.py`, run at startup after `db.init` and before
|
||||
`refresh_registry`. When `DEFAULT_PROJECT_ID` is set and a `default` project row
|
||||
still exists, in one `db.tx()`:
|
||||
|
||||
- `UPDATE projects SET id = <slug>, updated_at = datetime('now') WHERE id =
|
||||
'default'` — cascades `project_id` across the 12 FK tables via `ON UPDATE
|
||||
CASCADE`.
|
||||
- For the 7 `project_id`-bearing tables with **no** FK (`threads`, `changes`,
|
||||
`notifications`, `actions`, `pr_resolution_branches`, `rfc_invitations`,
|
||||
`cached_prs`): `UPDATE <t> SET project_id = <slug> WHERE project_id =
|
||||
'default'`.
|
||||
|
||||
End state: a single `project_id` value DB-wide. Idempotent — a no-op once no
|
||||
`default` row remains, so it is safe on every boot.
|
||||
|
||||
### 1e. Notes
|
||||
|
||||
- Migration `016` is absent from the on-disk sequence (`015 → 017`); the runner
|
||||
already tolerates the gap (the app runs today). **Do not renumber.** New file
|
||||
is `027`.
|
||||
- `PRAGMA foreign_keys` is honored going forward; the FK lands on the 12 rebuilt
|
||||
tables. The other 7 keep app-layer integrity (matching today's posture for
|
||||
non-rebuilt tables).
|
||||
|
||||
---
|
||||
|
||||
## 2. Registry format + `app/registry.py`
|
||||
|
||||
### 2a. `projects.yaml` (root of `REGISTRY_REPO`)
|
||||
|
||||
```yaml
|
||||
deployment:
|
||||
name: Open Human Model # replaces VITE_APP_NAME (M3-frontend consumes)
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique in the deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd — immutable
|
||||
content_repo: ohm # repo under the deployment's Gitea org
|
||||
visibility: public # gated | public | unlisted
|
||||
initial_state: super-draft # optional; defaults from type
|
||||
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional; M3-frontend consumes
|
||||
```
|
||||
|
||||
### 2b. `refresh_registry(config, gitea) -> RegistryResult`
|
||||
|
||||
The config-side analogue of `cache.refresh_meta_repo`. Fetches `projects.yaml`
|
||||
from `REGISTRY_REPO` at HEAD, parses, **validates**, and upserts:
|
||||
|
||||
- Each project → `projects` row: `id, name, type, content_repo, visibility,
|
||||
initial_state`, plus `config_json` (JSON blob for `theme`, `enabled_models`),
|
||||
plus `registry_sha` (commit SHA, provenance).
|
||||
- The `deployment:` block → the `deployment` singleton (`name`, `tagline`,
|
||||
`registry_sha`).
|
||||
|
||||
Projects present in the table but absent from the registry are **not** deleted
|
||||
in M3-backend (archival semantics are out of scope; a removed entry simply
|
||||
stops being refreshed). This is noted as a known limitation; revisit if/when
|
||||
project archival is specced.
|
||||
|
||||
### 2c. Validation (loud)
|
||||
|
||||
Per the framework's separation-of-concerns rule, malformed config fails
|
||||
visibly rather than shipping wrong content silently:
|
||||
|
||||
- Each project requires `id`, `name`, `type`, `content_repo`.
|
||||
- `type` ∈ {`document`,`specification`,`bdd`}; `visibility` ∈
|
||||
{`gated`,`public`,`unlisted`}.
|
||||
- `initial_state` defaults from `type` when omitted: `document`/`specification`
|
||||
→ `super-draft`, `bdd` → `active`. When present it must be a valid §2.4
|
||||
super-draft entry-state value.
|
||||
- `id` values unique and slug-shaped (`^[a-z0-9][a-z0-9-]*$`).
|
||||
- **`type` is immutable:** an incoming `type` differing from the stored row's is
|
||||
rejected (the entry is skipped, the rest proceed; logged loudly).
|
||||
- **Default-id consistency:** the re-stamped default id (`DEFAULT_PROJECT_ID`,
|
||||
else `default`) MUST appear as an `id` in the registry, or the registry is
|
||||
inconsistent with config → surfaced loudly.
|
||||
|
||||
### 2d. Wiring (Option A)
|
||||
|
||||
- **Webhook** (`app/webhooks.py`): add a branch — if the pushed repo
|
||||
`full_name` matches `REGISTRY_REPO`, call `registry.refresh_registry(...)`.
|
||||
Same HMAC-verified `/api/webhooks/gitea` dispatcher; no new endpoint. Add
|
||||
`REGISTRY_REPO` to the set of repos the dispatcher recognizes.
|
||||
- **Sweep** (`cache.Reconciler.sweep()`): add one `await
|
||||
registry.refresh_registry(...)` at the top of each pass, so the safety-net
|
||||
loop keeps `projects` in sync if a webhook is missed.
|
||||
- **Startup** (`main.py` lifespan): run `refresh_registry` once after
|
||||
migrations. This **replaces** M1's `seed_default_project` (which is removed).
|
||||
|
||||
### 2e. Failure posture
|
||||
|
||||
- **Startup / first boot:** if `REGISTRY_REPO` is unset/unreachable, or
|
||||
`projects.yaml` is missing or fails validation, the app **fails loudly**
|
||||
(refuses to start) — there is no last-known-good to serve.
|
||||
- **Running deployment:** a malformed `projects.yaml` pushed in a later PR is
|
||||
logged and **skipped**, leaving the last-good `projects` rows intact — a bad
|
||||
config PR must not take the deployment down. This mirrors how the corpus
|
||||
reconciler tolerates a bad content push today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Config (`app/config.py`)
|
||||
|
||||
- `registry_repo`: **required** — construction fails loudly if unset (matching
|
||||
the other required vars). Add `registry_repo_full` → `{gitea_org}/{registry_repo}`.
|
||||
- `meta_repo` and `meta_repo_full`: **removed**.
|
||||
- `default_project_id`: optional; consumed by migration `027` (re-stamp) and by
|
||||
`refresh_registry` (the §2c consistency gate).
|
||||
- `enabled_models`: unchanged — the deployment-level fallback for a project's
|
||||
optional `enabled_models`.
|
||||
- `app/projects.py`: `seed_default_project` retired (superseded by the mirror).
|
||||
`DEFAULT_PROJECT_ID` constant and resolution helpers retained as needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. APIs — `app/api_deployment.py`
|
||||
|
||||
A new sub-router mounted in `app/api.py`.
|
||||
|
||||
### `GET /api/deployment`
|
||||
|
||||
Returns `{ name, tagline, projects: [...] }`. The deployment `name`/`tagline`
|
||||
come from the `deployment` singleton. The project list is filtered by caller
|
||||
visibility (§22.5):
|
||||
|
||||
- `public` projects → visible to everyone (incl. anonymous).
|
||||
- `gated` projects → only when the caller is a member (`visible_project_ids`
|
||||
from M2's resolver).
|
||||
- `unlisted` projects → **omitted entirely** (reachable only by direct id).
|
||||
|
||||
Each item: `{ id, name, type, visibility }` — enough for the M3-frontend
|
||||
directory + switcher. (Theme is fetched per project.)
|
||||
|
||||
### `GET /api/projects/:id`
|
||||
|
||||
Returns `{ id, name, tagline, type, visibility, initial_state, theme }`.
|
||||
Guarded by `require_project_readable(user, id)` — 404 for a non-member of a
|
||||
gated project, reusing the M2 resolver. `unlisted` is readable here by direct
|
||||
id (it is hidden only from enumeration).
|
||||
|
||||
---
|
||||
|
||||
## 5. Entry-state & review semantics
|
||||
|
||||
### 5a. Frontmatter (`app/entry.py`)
|
||||
|
||||
Parse three new fields, lenient (default `unreviewed=false`, nulls):
|
||||
`unreviewed: bool`, `reviewed_at`, `reviewed_by`. Add to the `Entry`
|
||||
dataclass. These are git-truth (§2 frontmatter) so they survive a cache
|
||||
rebuild, exactly like `state`.
|
||||
|
||||
### 5b. Cache mirror (`app/cache.py`)
|
||||
|
||||
`_upsert_cached_rfc` writes the three fields into `cached_rfcs`
|
||||
(`unreviewed`, `reviewed_at`, `reviewed_by`).
|
||||
|
||||
### 5c. Entry-landing path
|
||||
|
||||
When a creating idea-PR merges (§2.4), resolve the project's `initial_state`:
|
||||
|
||||
- `super-draft` → today's behavior unchanged (propose → super-draft → graduate).
|
||||
- `active` → land the entry `active` with `unreviewed = true`, skipping the §13
|
||||
graduate gate. The exact merge-handling site (in the PR-merge reconcile path)
|
||||
is located during implementation.
|
||||
|
||||
### 5d. Mark-reviewed
|
||||
|
||||
`POST /api/projects/:pid/rfcs/:slug/mark-reviewed`. Authority:
|
||||
`is_project_superuser` (project_admin or deployment owner/admin) — the same tier
|
||||
that graduates an entry. Effect: the bot writes `unreviewed: false` +
|
||||
`reviewed_at`/`reviewed_by` into the entry frontmatter (a git commit, paralleling
|
||||
graduate), which the mirror then reflects into `cached_rfcs`. Stamps provenance
|
||||
paralleling `graduated_at`/`graduated_by`.
|
||||
|
||||
### 5e. Catalog filter (query side)
|
||||
|
||||
`GET /api/rfcs` (and the project-scoped form) gains an `unreviewed=true` query
|
||||
param that filters on the cached column — the owner's worklist. The UI for it
|
||||
is M3-frontend; only the query side ships here. `unreviewed` applies to
|
||||
`active` entries only.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Migration `027`:** seed a `026`-shaped DB with rows under
|
||||
`project_id='default'`; run `027`; assert: new columns present; the 12 tables
|
||||
rebuilt with composite keys + FK; all 19 `project_id`-bearing tables
|
||||
re-stamped to `<slug>`; row counts preserved; FK integrity on; idempotent
|
||||
re-run is a no-op.
|
||||
- **`registry.py`:** valid `projects.yaml` upserts all fields + `registry_sha`;
|
||||
each validation failure rejected (bad enum, missing field, dup id, `type`
|
||||
mutation, missing default id); `initial_state` type-default applied;
|
||||
startup-strict vs running-tolerant posture.
|
||||
- **APIs:** `/api/deployment` visibility filtering across
|
||||
gated/public/unlisted × member/non-member/anonymous; `/api/projects/:id` 404
|
||||
gate for gated non-member, 200 for unlisted-by-id.
|
||||
- **Review flow:** `initial_state: active` lands `unreviewed=true`;
|
||||
mark-reviewed authority (allow superuser, deny others) + frontmatter write +
|
||||
mirror reflection; catalog `unreviewed` filter returns the worklist.
|
||||
- **Two-project isolation:** extend `test_multi_project_authz_vertical.py` with
|
||||
a genuine **second** registry project sharing a slug with the first, proving
|
||||
the PK rebuilds isolate them (the core point of M3's activation).
|
||||
|
||||
---
|
||||
|
||||
## 7. File-touch summary
|
||||
|
||||
**New**
|
||||
- `backend/migrations/027_projects_activate.sql`
|
||||
- `backend/app/registry.py`
|
||||
- `backend/app/api_deployment.py`
|
||||
- tests: `backend/tests/test_migration_027.py`, `test_registry.py`,
|
||||
`test_api_deployment.py`, `test_review_flow.py`; extend
|
||||
`test_multi_project_authz_vertical.py`
|
||||
|
||||
**Modified**
|
||||
- `backend/app/config.py` (registry_repo required; meta_repo removed;
|
||||
default_project_id)
|
||||
- `backend/app/webhooks.py` (registry-repo branch)
|
||||
- `backend/app/cache.py` (`Reconciler.sweep` registry call;
|
||||
`_upsert_cached_rfc` review fields)
|
||||
- `backend/app/entry.py` (frontmatter fields)
|
||||
- `backend/app/api.py` (mount `api_deployment`; `unreviewed` filter on
|
||||
`/api/rfcs`)
|
||||
- `backend/app/main.py` (startup `refresh_registry`; drop `seed_default_project`)
|
||||
- `backend/app/projects.py` (retire `seed_default_project`)
|
||||
- `backend/.env.example` (`REGISTRY_REPO`, `DEFAULT_PROJECT_ID`; remove
|
||||
`META_REPO`)
|
||||
|
||||
---
|
||||
|
||||
## 8. Versioning & upgrade
|
||||
|
||||
Minor bump, breaking (pre-1.0). `VERSION` + `frontend/package.json#version` move
|
||||
together (§20). `CHANGELOG.md` gets a breaking entry with an **upgrade-steps**
|
||||
block:
|
||||
|
||||
1. Create a registry repo under the deployment's Gitea org.
|
||||
2. Author `projects.yaml`: a `deployment:` block (`name`, `tagline`) and one
|
||||
`projects:` entry for the existing corpus — `id: <slug>`, `name`, `type:
|
||||
document`, `content_repo: <old META_REPO value>`, `visibility: public`.
|
||||
3. Set env: `REGISTRY_REPO=<registry repo name>`, `DEFAULT_PROJECT_ID=<slug>`
|
||||
(must equal the entry's `id`); remove `META_REPO`.
|
||||
4. Deploy. Migration `027` runs the rebuilds + re-stamp; `refresh_registry`
|
||||
reconciles the registry into `projects`. Verify `/api/deployment` returns the
|
||||
project and `/api/health` is green.
|
||||
|
||||
The SPEC.md §22 merge stays in M7 per the slicing plan; this slice references the
|
||||
draft at `docs/design/multi-project-spec.md`.
|
||||
|
||||
## Known limitations / deferred
|
||||
|
||||
- **Project archival/deletion** from the registry is not handled (a removed
|
||||
entry stops refreshing but its rows persist). Defer to a future archival spec.
|
||||
- All routing, redirects, runtime branding, theme application, directory, and
|
||||
switcher are **M3-frontend**.
|
||||
@@ -0,0 +1,147 @@
|
||||
# M3 — Registry mirror + routing + runtime branding — design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Spec basis:** `docs/design/multi-project-spec.md` §22, Part C slice **M3**
|
||||
**Status:** design, pending plan
|
||||
|
||||
## Goal
|
||||
|
||||
Carry §22 slice **M3** end-to-end: turn the framework from single-project
|
||||
(the N=1 default at the legacy corpus root) into genuinely multi-project —
|
||||
projects declared in a registry, reachable at `/p/<project>/`, branded at
|
||||
runtime. M3 is the slice that makes a *second, named* project reachable by
|
||||
URL at all.
|
||||
|
||||
**Driving objective:** stand up the deployment's `bdd` "planner" project,
|
||||
reachable at `https://rfc.wiggleverse.org/p/bdd/` as an **`unlisted`**
|
||||
project (readable by direct link; not in the directory). After **M3c** the
|
||||
planner is navigable; after **M3d** its entries land `active` (the behavior a
|
||||
planner wants). The planner's `bdd`-specific scenario/coverage *surface* is
|
||||
**M5**, out of scope here.
|
||||
|
||||
> Note on the original URL ask `/bdd/{PLANNER_TOKEN}/`: resolved to the
|
||||
> existing §22 model — `bdd` is an `unlisted` project at `/p/bdd/`; the
|
||||
> "token" is just the shareable link (the unguessable slug). No token-gate
|
||||
> feature is built; `unlisted` visibility already exists (gated in M2).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Already resolved upstream (commit `ad2ece1`, §22.13/§22.10/§22.4)
|
||||
1. **Default project `id`** = config-derived slug
|
||||
(`DEFAULT_PROJECT_ID` > slug(deployment name) > `default`). M1's `default`
|
||||
bootstrap id is **re-stamped** to it in M3 before any `/p/` URL is public.
|
||||
2. **Entry segment** = generic `/p/<project>/e/<slug>` for every type; the
|
||||
noun (RFC/Spec/Feature) is a type-driven UI label, not in the path.
|
||||
3. **Legacy `RFC-NNNN`** = frozen, read-only display label in frontmatter;
|
||||
never used for routing/lookup; never assigned to new entries.
|
||||
|
||||
### Resolved in this brainstorm
|
||||
4. **Plan slicing** — M3 is delivered as a **foundation plan (M3-0) + four
|
||||
sequential feature sub-plans (M3a–M3d)**, each its own branch off `main`,
|
||||
its own review checkpoint, its own merge. Lower risk for the live OHM
|
||||
deployment than one long-running branch.
|
||||
5. **Branding cutover** — **hard cut + loud failure.** Frontend reads the
|
||||
deployment/project name only from `GET /api/deployment`; `VITE_APP_NAME`
|
||||
is removed from the frontend; `REGISTRY_REPO` becomes **required** at
|
||||
startup (per CLAUDE.md's loud-failure rule).
|
||||
6. **Registry mirror mechanism** — follow the **existing §4.1 cache pattern**
|
||||
(`Reconciler.sweep()` + webhook writer, reading via the Gitea API), not a
|
||||
new mechanism (no local clone, no separate service).
|
||||
7. **Test strategy** — adopt the handbook's **§10.3 two-tier testing**
|
||||
standard. Build the **Tier-1 local-Docker suite first** as M3-0; each
|
||||
feature sub-plan adds unit/integration/functional/e2e coverage on top.
|
||||
Tier-2 (PPE) is a separate flotilla/Stage-2 task (below).
|
||||
8. **Deployment stage** — OHM is **Stage 1** (pre-v1, single prod VM,
|
||||
forward-only migrations). So M3a's table-rebuild migration is allowed as
|
||||
**one forward-only migration**. The §10.2 expand/contract rule is recorded
|
||||
as a **future Stage-2 obligation**, not a constraint on M3a today.
|
||||
|
||||
## Decomposition
|
||||
|
||||
Order is dependency-driven: **M3-0 → M3a → M3b → M3c → M3d.**
|
||||
|
||||
### M3-0 — Test & local-env foundation (the §10.3 Tier-1 suite)
|
||||
Greenfield; lands before feature work so every sub-plan can be verified at
|
||||
all levels. Reuses the existing in-process `app_with_fake_gitea` fixture seam
|
||||
where possible.
|
||||
|
||||
- **Docker local stack** (`docker compose`): backend (FastAPI) + built
|
||||
frontend + **fresh SQLite** + **Mailpit** (mail sink for OTC/invite flows)
|
||||
+ a disposable/stub Gitea for content **and** registry repos.
|
||||
- *Open design point for the M3-0 plan:* promote the in-process
|
||||
`fake_gitea` double into a small standalone HTTP stub vs. run real Gitea
|
||||
in a container. Decide in M3-0's own plan.
|
||||
- **Frontend unit:** add **Vitest** + testing-library to
|
||||
`frontend/package.json` (none today).
|
||||
- **E2E:** add **Playwright**, suite **environment-agnostic** —
|
||||
parameterized by `BASE_URL` + the **mail-sink API URL** (§10.3). Default
|
||||
target = localhost Docker; PPE host when `BASE_URL` is set to `ppe.<host>`.
|
||||
One smoke spec: load `/` directory → open a project → view an entry.
|
||||
- **Tier-2 / PPE is NOT built here.** Standing up `rfc-app-ppe.<base>` (its
|
||||
own micro VM, own gcloud project, own isolated Gitea, always-pass Turnstile
|
||||
keys) is a **flotilla operator task** in the deployment tooling, tracked
|
||||
separately. The suite is written to point at it once it exists.
|
||||
|
||||
### M3a — Schema migration + default-id restamp *(foundation; runs against live OHM data)*
|
||||
- **Migration `027`** (next number after `026`): create-copy-drop-rename for
|
||||
the 12 tables enumerated in the `026_projects.sql` header, folding
|
||||
`project_id` into each PK/UNIQUE key and adding the `project_id` FK →
|
||||
`projects(id)`. (`cached_prs` needs no rebuild — already globally unique.)
|
||||
- Additive `type` + `initial_state` columns on `projects`.
|
||||
- **Restamp** (§22.13 step 1): default project `id` `default` →
|
||||
config-derived slug, cascaded across every `project_id` FK. Idempotent.
|
||||
- **Live-data safety (Stage 1):** back up prod SQLite; rehearse on a copy of
|
||||
prod; assert per-table row-count parity; FK enforcement on. Forward-only;
|
||||
brief restart blip acceptable per §9.
|
||||
- *Gate: until this lands, a second project can collide with the first.*
|
||||
|
||||
### M3b — Registry mirror
|
||||
- `REGISTRY_REPO` **required** at startup (loud failure if unset).
|
||||
- `refresh_registry()` in `cache.py`: read `projects.yaml` from
|
||||
`REGISTRY_REPO` via the Gitea API; upsert `projects` rows (`id`, `name`,
|
||||
`content_repo`, `type`, `visibility`, `initial_state`,
|
||||
`enabled_models`/`theme` → `config_json`, `registry_sha`); cache deployment
|
||||
`name`/`tagline`. Rows never written from user actions.
|
||||
- Webhook branch in `webhooks.py` on push to `REGISTRY_REPO`;
|
||||
`Reconciler.sweep()` calls `refresh_registry()`.
|
||||
|
||||
### M3c — Routing + redirects + runtime branding *(planner becomes navigable)*
|
||||
- Backend: `GET /api/deployment` (name, tagline, visible projects per §22.5),
|
||||
`GET /api/projects/:id` (name, tagline, type, philosophy pointer, theme);
|
||||
`/p/<project>` scoping on RFC routes.
|
||||
- Frontend: `/p/<project>/` prefix + `/e/<slug>` segment; **308 redirects**
|
||||
off old corpus-root URLs (`/rfc/<slug>` → `/p/<default-id>/e/<slug>`, etc.);
|
||||
**hard cut** off `VITE_APP_NAME` to runtime branding; per-project `theme`
|
||||
token overlay; deployment **directory** at `/`; project **switcher**.
|
||||
|
||||
### M3d — Landing-state + review behavior
|
||||
- `initial_state=active` creation path: land a new entry `active`, stamp
|
||||
`unreviewed`, skip the graduate gate when the project says so.
|
||||
- `unreviewed` frontmatter fields mirrored into `cached_rfcs`; owner/admin
|
||||
**mark-reviewed** action; §7 catalog **unreviewed filter** querying the
|
||||
cached column; type-driven entry noun in chrome.
|
||||
|
||||
## Test coverage per sub-plan (all four levels)
|
||||
|
||||
- **M3a** — migration unit/integration: fresh DB **and** copy-of-prod
|
||||
fixture; row-count parity; FK enforcement; restamp idempotence.
|
||||
- **M3b** — reconciler unit (yaml parse, upsert, `registry_sha`) +
|
||||
integration (webhook → `projects` rows) on the Docker stub Gitea.
|
||||
- **M3c** — backend functional (`/api/deployment`, `/api/projects/:id`, 308
|
||||
redirects, `/p` scoping) + frontend unit (branding/theme from API) + **e2e**
|
||||
(directory → project → entry nav; old-URL redirect resolves).
|
||||
- **M3d** — backend functional (active landing, `unreviewed`, mark-reviewed,
|
||||
filter) + **e2e** (unreviewed filter, mark-reviewed action).
|
||||
|
||||
## Out of scope / dependencies
|
||||
|
||||
- **M4** (second-project acceptance pass), **M5** (`bdd` scenario/coverage
|
||||
surface — the planner's type-specific UI), **M6/M7**.
|
||||
- **PPE standup** (`rfc-app-ppe.<base>`) — flotilla operator task; prerequisite
|
||||
to running the suite's Tier-2 gate, not framework code.
|
||||
- **Org-wide testing-standard activation** — §10.3 already *is* the canonical
|
||||
standard; it should not be duplicated into per-repo memory. Recommended
|
||||
follow-up (separate, in the engineering repo + dev plugin): have
|
||||
`wgl-coding-session-init` read app.json and, for apps that deploy the §8
|
||||
standard stack, surface §10.3 + check whether the Tier-1 suite exists. Not
|
||||
part of M3.
|
||||
@@ -0,0 +1,85 @@
|
||||
# M3-frontend — §22 multi-project: routing, runtime branding, directory, switcher — design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Slice:** §22 M3 (frontend half). Pairs with **M3-backend** (`2026-06-03-m3-backend-design.md`) which ships the data spine, registry mirror, and the two runtime-config APIs this slice consumes.
|
||||
**Status:** design, pending plan. *Implementation is gated on M3-backend Plan A's APIs (see §7).*
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the §22.9/§22.10 frontend: `/p/<project>/` routing, the deployment **directory** + **project switcher**, the `VITE_APP_NAME`→runtime-config **hard cut**, per-project **theme** overlay, and real **308 redirects** off the old corpus-root URLs. After this slice the framework presents deployment chrome (directory/switcher/brand) over project chrome (catalog/entry view), branded entirely at runtime.
|
||||
|
||||
**Honest scope boundary.** The RFC *data* calls stay **unscoped** (`/api/rfcs/...`) this slice, so a project's actual corpus renders only for the backend's **corpus-served (default) project**. A *second* project's entries (the `bdd` "planner") need the backend to serve per-project RFCs — that is **M3-backend Plan B**, not this slice. M3-frontend therefore delivers the **shell**: directory, switcher, runtime branding, theme, `/p/<default>/` fully working, and the 308s. `projectId` is threaded through context so a later slice swaps unscoped calls for scoped ones with minimal churn.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **Routing architecture** — keep `<BrowserRouter>` + nested `<Routes>` (no migration to a data-router). Add a `DeploymentProvider` context (boots `/api/deployment`) and a `ProjectLayout`/`ProjectProvider` for the `/p/:projectId/*` subtree (`/api/projects/:id`). Extract the catalog+main-pane composition out of `App.jsx` (480 lines) into `ProjectLayout`.
|
||||
2. **N=1 landing** — when exactly **one** project is visible to the caller, `/` redirects to `/p/<that-id>/`; the `<Directory>` renders only when 2+ are visible. Preserves OHM's "land in the corpus" UX; the directory appears when a second *public* project exists. (Visibility is per-caller, §22.5; the `unlisted` planner never counts toward the directory.)
|
||||
3. **Redirects** — **real HTTP 308**, server-side (§5), not client-side `<Navigate>`.
|
||||
4. **Runtime branding** — **hard cut**: remove `VITE_APP_NAME` from the build; name/tagline come from `/api/deployment` at runtime; `brandTitle()` (from M3-0) is the neutral `'RFC'` fallback during the pre-fetch paint.
|
||||
5. **The guard** — a non-corpus-served `projectId` renders a deliberate "content not yet served" placeholder, never mislabeled default-project content (decouples this slice from Plan B without a wrong-content footgun).
|
||||
6. **Philosophy stays deployment-level** this slice — `/api/projects/:id` (per the M3-backend spec) returns no philosophy pointer, so the per-project philosophy split (§14.1) is deferred until the backend serves one.
|
||||
|
||||
## 1. Routing & layout
|
||||
|
||||
`main.jsx` keeps `<BrowserRouter>`. New route table in `App.jsx`:
|
||||
|
||||
| Path | Renders |
|
||||
| --- | --- |
|
||||
| `/` | `<DeploymentLanding>` — if exactly one visible project → `<Navigate replace to="/p/<id>/">`; else `<Directory>` (cards from `/api/deployment`) |
|
||||
| `/p/:projectId/*` | `<ProjectLayout>` (fetch `/api/projects/:id`, apply theme, provide `ProjectContext`) wrapping the Catalog left pane + nested routes below |
|
||||
| `/p/:projectId/` | catalog home (today's `<Welcome>`) |
|
||||
| `/p/:projectId/e/:slug` | `<RFCView>` — generic `/e/` segment; noun ("RFC/Spec/Feature") from `project.type` |
|
||||
| `/p/:projectId/e/:slug/pr/:prNumber` | `<PRView>` |
|
||||
| `/p/:projectId/proposals/:prNumber` | `<ProposalView>` |
|
||||
| `/login`, `/docs/*`, `/admin/*`, `/privacy`, `/cookies`, `/settings/*`, `/invitations/accept`, `/invites/claim` | unchanged (top-level, full-width chrome) |
|
||||
|
||||
- Old `/rfc/:slug`, `/proposals/:n` are **removed from the SPA** — served as 308s (§5), so they never reach the router.
|
||||
- `<ProjectLayout>` owns the per-project chrome and the **guard** (§4): corpus routes render only when the project is corpus-served; otherwise a placeholder.
|
||||
|
||||
## 2. Runtime branding (the hard cut)
|
||||
|
||||
- **`DeploymentProvider`** fetches `GET /api/deployment` once on boot (alongside `getMe()`); provides `{ name, tagline, projects[] }` via context.
|
||||
- Replace the **6** `import.meta.env.VITE_APP_NAME` reads with the context value wrapped in `brandTitle(deployment?.name)` (neutral `'RFC'` fallback during pre-fetch):
|
||||
- `App.jsx:208` (header brand), `Landing.jsx:16`, `BetaPending.jsx:25`, `Login.jsx:594`, `pages/Cookies.jsx:32`, `pages/Privacy.jsx:25`.
|
||||
- **Tab title:** drop the build-time `%VITE_APP_NAME%` token; `index.html` ships static `<title>RFC</title>`; JS sets `document.title` after config loads (`ProjectLayout` → project name; deployment chrome → deployment name).
|
||||
- **`vite.config.js`:** remove the `VITE_APP_NAME` build-time `throw` and the `inject-app-name` plugin. A build no longer needs the env var (the actual hard cut).
|
||||
|
||||
## 3. Theme overlay
|
||||
|
||||
`ProjectLayout` applies `project.theme` by setting CSS custom properties on `document.documentElement` (e.g. `style.setProperty('--c-accent', theme.accent)`), riding the existing `tokens.css` `:root` variable system. Properties are reapplied on project switch and **reset on unmount** so one project's accent never bleeds into deployment chrome or another project.
|
||||
|
||||
## 4. Chrome split + the guard
|
||||
|
||||
- **Deployment chrome:** header brand (→ deployment name), a **project switcher** dropdown (visible projects from `/api/deployment`), and `<Directory>` at `/`.
|
||||
- **Project chrome:** catalog, entry view — under `ProjectLayout`, scoped to one project.
|
||||
- **Breadcrumb (§8.1):** gains a leading project segment with the type-driven noun (e.g. `OHM / Human › main`); threaded in from `ProjectContext` into the existing inline breadcrumb markup in `RFCView.jsx`/`PRView.jsx` (no component extraction — `RFCView` is 1260 lines; full extraction is out of scope).
|
||||
- **The guard:** `ProjectLayout` renders the catalog/entry routes only for the corpus-served (default) project; any other `projectId` renders a "content not yet served" placeholder. **Contract detail to settle at implementation time** (against Plan A/B): how the frontend learns which project is corpus-served — a flag on `/api/projects/:id`, or matching the deployment's default id. Deliberately not over-specified now.
|
||||
|
||||
## 5. 308 redirects (server-side — coordination with M3-backend)
|
||||
|
||||
- **FastAPI:** `GET /rfc/{slug}` → 308 `/p/{default_id}/e/{slug}`; `GET /proposals/{n}` → 308 `/p/{default_id}/proposals/{n}`. `default_id` from config (`DEFAULT_PROJECT_ID`; post-restamp = the project's real id).
|
||||
- **nginx** (`testing/web.nginx.conf` for Tier-1, and the prod `deploy/nginx/*.conf`): route `/rfc/` and `/proposals/` to the backend (`proxy_pass`) instead of `try_files → index.html`.
|
||||
- This is the backend/ops layer (it needs `DEFAULT_PROJECT_ID`, backend-owned). **Coordination point:** either the parallel M3-backend session adds it, or this slice contributes the small backend route + nginx rule. Owner to be assigned during planning.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Vitest unit:** `DeploymentProvider` fallback via `brandTitle`; theme `setProperty` apply/reset; N=1 redirect logic; the guard placeholder; the directory render with 0/1/2+ visible projects.
|
||||
- **Playwright e2e (M3-0 Tier-1 harness):** N=1 `/`→`/p/<id>/` redirect; directory with 2+ public projects; **308** from `/rfc/<slug>` → `/p/<id>/e/<slug>`; branding from `/api/deployment`; theme accent applied; switcher navigation; non-served project placeholder. **e2e lands once M3-backend Plan A (registry + the two APIs) is in the Tier-1 stack** (the seeded Gitea needs a `REGISTRY_REPO` + `projects.yaml`).
|
||||
|
||||
## 7. Dependencies & sequencing
|
||||
|
||||
- **Design:** now (unblocked).
|
||||
- **Implementation gated on:** M3-backend **Plan A** — `GET /api/deployment`, `GET /api/projects/:id` (`api_deployment.py`, not yet built).
|
||||
- **Planner's actual content gated on:** M3-backend **Plan B** — per-project RFC serving + scoped frontend calls (a follow-on slice that relaxes the §4 guard).
|
||||
- **308 piece:** needs the backend route + nginx rule (§5).
|
||||
- **Tier-1 e2e:** needs Plan A's registry + APIs wired into the M3-0 Docker stack.
|
||||
|
||||
## 8. File-touch summary
|
||||
|
||||
**New (frontend):** `src/context/DeploymentProvider.jsx`, `src/components/ProjectLayout.jsx` (+ `ProjectContext`), `src/components/Directory.jsx`, `src/components/ProjectSwitcher.jsx`, `src/components/NotServedPlaceholder.jsx`; `src/api.js` additions (`getDeployment`, `getProject`).
|
||||
**Modified (frontend):** `App.jsx` (route table, brand), `main.jsx` (provider wrap), `index.html` (static title), `vite.config.js` (drop `VITE_APP_NAME`), the 6 brand-read files, `RFCView.jsx`/`PRView.jsx` (breadcrumb project segment), `tokens.css` (no change expected; theme is applied via JS).
|
||||
**Server (coordination):** a FastAPI redirect route + nginx `/rfc/` `/proposals/` rule (§5).
|
||||
|
||||
## 9. Versioning
|
||||
|
||||
Per the 2026-06-03 decision (bump per breaking slice), M3-frontend's `VITE_APP_NAME` removal is a build-surface change deployments must act on (set up the registry / runtime config). It rides the same pre-1.0 minor + CHANGELOG upgrade-steps as the M3-backend cut if they land together, or carries its own upgrade-steps block if separate. `VERSION` + `frontend/package.json#version` move together (§20).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user