Compare commits
43 Commits
508a8cb6d0
...
v0.46.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -26,3 +26,4 @@ data/
|
||||
|
||||
# Claude Code (per-machine settings only; shared config under .claude/ is committed)
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
|
||||
+472
@@ -23,6 +23,478 @@ 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.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
|
||||
|
||||
@@ -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
|
||||
@@ -298,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
|
||||
@@ -435,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:
|
||||
|
||||
@@ -4912,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.
|
||||
|
||||
|
||||
+132
-49
@@ -21,14 +21,18 @@ 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,
|
||||
@@ -150,7 +154,15 @@ def make_router(
|
||||
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))
|
||||
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.
|
||||
@@ -637,13 +649,13 @@ def make_router(
|
||||
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 project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
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()
|
||||
@@ -685,8 +697,8 @@ def make_router(
|
||||
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.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
# 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.
|
||||
@@ -716,13 +728,15 @@ def make_router(
|
||||
# second project's corpus renders under /p/<id>/.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
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 = 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)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
@@ -734,18 +748,18 @@ def make_router(
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND project_id = ?{unreviewed_clause}
|
||||
AND collection_id = ?{unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
""",
|
||||
(project_id,),
|
||||
(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 project_id = ?",
|
||||
(viewer_id, project_id),
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
|
||||
(viewer_id, collection_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
@@ -766,13 +780,10 @@ def make_router(
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
@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)
|
||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
|
||||
(project_id, slug),
|
||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||
(collection_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
@@ -782,14 +793,57 @@ def make_router(
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, project_id),
|
||||
(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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -797,14 +851,16 @@ def make_router(
|
||||
@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
|
||||
§22.7 project superuser (project_admin or deployment owner/admin)."""
|
||||
§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)
|
||||
if not auth.is_project_superuser(viewer, project_id):
|
||||
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
|
||||
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 project_id = ?",
|
||||
(slug, project_id),
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
@@ -952,11 +1008,20 @@ def make_router(
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
||||
# §22.6/§22.7: proposing a new entry requires project-level contribute
|
||||
# standing in the *target* project. On the public default project the
|
||||
# implicit-public baseline preserves the pre-multi-project flow.
|
||||
if not auth.can_contribute_in_project(user, project_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this project")
|
||||
# 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")
|
||||
@@ -966,7 +1031,7 @@ 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 = ? AND project_id = ?", (slug, project_id)
|
||||
"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")
|
||||
@@ -978,11 +1043,12 @@ def make_router(
|
||||
if idea_clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
||||
|
||||
# §22.4b: the target project's landing state. Through Plan A every
|
||||
# entry lands in the default project; M3-frontend routing carries a
|
||||
# non-default target later.
|
||||
target_project = project_id
|
||||
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
|
||||
# §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,
|
||||
@@ -1013,6 +1079,9 @@ 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(),
|
||||
@@ -1022,6 +1091,7 @@ def make_router(
|
||||
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}")
|
||||
@@ -1041,11 +1111,11 @@ def make_router(
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id)
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
|
||||
VALUES ('rfc', ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, 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, project_id),
|
||||
(slug, pr["number"], use_case, collection_id),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
||||
@@ -1070,6 +1140,19 @@ def make_router(
|
||||
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
|
||||
@@ -1205,12 +1288,12 @@ def make_router(
|
||||
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(user, rfc["project_id"])
|
||||
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.
|
||||
|
||||
+21
-21
@@ -742,7 +742,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, 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
|
||||
""",
|
||||
@@ -896,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(project_id, 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
|
||||
""",
|
||||
@@ -1092,7 +1092,7 @@ def make_router(
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
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
|
||||
@@ -1264,11 +1264,11 @@ def make_router(
|
||||
return row["on_behalf_of"] if row else None
|
||||
|
||||
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
|
||||
# §22.5 visibility gate first (subtractive, §22.7): in a gated project
|
||||
# nothing — not even main or a read_public branch — is readable by a
|
||||
# non-member.
|
||||
pid = auth.project_of_rfc(slug)
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
# §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
|
||||
@@ -1277,7 +1277,7 @@ def make_router(
|
||||
return True
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
creator = _branch_creator(slug, branch)
|
||||
if creator and viewer.gitea_login == creator:
|
||||
@@ -1310,12 +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
|
||||
pid = auth.project_of_rfc(slug)
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
# §22.5 visibility gate (subtractive): no contribute in an unreadable
|
||||
# project.
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
# collection.
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1326,10 +1326,10 @@ def make_router(
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
if vis["contribute_mode"] == "any-contributor":
|
||||
# "any contributor" means anyone with project-level write standing
|
||||
# (§22.6/§22.7) — the implicit-public baseline on a public project,
|
||||
# or an explicit project_contributor/admin elsewhere.
|
||||
return auth.can_contribute_in_project(viewer, pid)
|
||||
# "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(
|
||||
"""
|
||||
@@ -1352,7 +1352,7 @@ def make_router(
|
||||
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
|
||||
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
|
||||
# to project scope, so it (and a deployment owner/admin) clears here.
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1368,7 +1368,7 @@ def make_router(
|
||||
has no owners, so the set collapses to the superuser tier only —
|
||||
sensible because admin oversight is the only path to canonicalizing
|
||||
edits on an unclaimed entry."""
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
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 "[]")
|
||||
@@ -1381,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 (
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
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)
|
||||
),
|
||||
@@ -1427,7 +1427,7 @@ def make_router(
|
||||
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
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
|
||||
@@ -62,7 +62,7 @@ def _require_super_draft(slug: str, viewer):
|
||||
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, project_id 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:
|
||||
@@ -91,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 auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
|
||||
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."
|
||||
@@ -108,7 +108,7 @@ 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, project_id 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:
|
||||
|
||||
+174
-17
@@ -1,28 +1,60 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects.
|
||||
old-URL 308 redirects + §22 S5 in-app create-project.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
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.
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
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
|
||||
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, db, projects as projects_mod
|
||||
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._-]*$")
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
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")
|
||||
@@ -33,15 +65,39 @@ def make_router(config: Config) -> APIRouter:
|
||||
).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, type, visibility FROM projects "
|
||||
"SELECT id, name, visibility FROM projects "
|
||||
"WHERE visibility != 'unlisted' ORDER BY name"
|
||||
).fetchall()
|
||||
projects = [
|
||||
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]}
|
||||
{
|
||||
"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 "",
|
||||
@@ -49,8 +105,100 @@ def make_router(config: Config) -> APIRouter:
|
||||
# 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": projects_mod.resolved_default_id(config),
|
||||
"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}")
|
||||
@@ -60,8 +208,7 @@ def make_router(config: Config) -> APIRouter:
|
||||
# an unknown id). unlisted is readable by direct id.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, type, visibility, initial_state, config_json "
|
||||
"FROM projects WHERE id = ?",
|
||||
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -71,13 +218,17 @@ def make_router(config: Config) -> APIRouter:
|
||||
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": row["type"],
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": row["initial_state"],
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
"theme": cfg.get("theme") or {},
|
||||
}
|
||||
|
||||
@@ -86,21 +237,27 @@ def make_router(config: Config) -> APIRouter:
|
||||
# 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)
|
||||
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
|
||||
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}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
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)
|
||||
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
|
||||
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
|
||||
|
||||
@@ -252,7 +252,7 @@ def _require_rfc_readable(slug: str, viewer):
|
||||
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")
|
||||
@@ -312,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 auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
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 "[]")
|
||||
|
||||
@@ -218,7 +218,7 @@ def make_router(
|
||||
can_merge = (
|
||||
viewer is not None
|
||||
and (
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
auth.is_collection_superuser(viewer, rfc["collection_id"])
|
||||
or viewer.gitea_login in owners
|
||||
or viewer.gitea_login in arbiters
|
||||
)
|
||||
@@ -574,7 +574,7 @@ def make_router(
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
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.
|
||||
@@ -584,7 +584,7 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_retirable(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
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"):
|
||||
@@ -592,7 +592,7 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_retired(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
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":
|
||||
@@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
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 "[]")
|
||||
|
||||
@@ -380,7 +380,7 @@ def _require_rfc(slug: str, viewer):
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, project_id 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")
|
||||
|
||||
@@ -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,7 +213,7 @@ 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 project_id 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.
|
||||
@@ -222,7 +222,7 @@ def make_router(config: Config) -> APIRouter:
|
||||
"""
|
||||
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
|
||||
ON CONFLICT(project_id, 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
|
||||
|
||||
@@ -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(project_id, 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(project_id, 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),
|
||||
)
|
||||
@@ -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(project_id, 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
|
||||
@@ -662,7 +662,7 @@ def make_router(
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
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
|
||||
@@ -786,7 +786,7 @@ def _can_merge(rfc, viewer) -> bool:
|
||||
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
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 "[]")
|
||||
|
||||
+352
-46
@@ -16,6 +16,7 @@ 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
|
||||
@@ -336,31 +337,73 @@ def project_visibility(project_id: str) -> str:
|
||||
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 *explicit* §22.6 project_members role in this project, or
|
||||
None. This is the stored row only — it does not fold in the deployment tier
|
||||
or the implicit-on-public baseline (those live in the helpers below)."""
|
||||
"""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 project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user.user_id),
|
||||
"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()
|
||||
return row["role"] if row else None
|
||||
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 (`cached_rfcs.project_id`). Falls back to
|
||||
the default project when the slug isn't cached or the column is unset — the
|
||||
same N=1 default migration 026 backfills."""
|
||||
"""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 project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
"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
|
||||
@@ -373,20 +416,30 @@ def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
|
||||
|
||||
|
||||
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone
|
||||
(anonymous included — `unlisted` is link-only but the link still reads);
|
||||
`gated` is readable only by a deployment owner/admin or a granted project
|
||||
member of any role. Used as the subtractive read gate (a gated project's
|
||||
entries 404 to non-members)."""
|
||||
"""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 — members + superusers only, subject to the §6 admission floor.
|
||||
# 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
|
||||
return project_member_role(user, project_id) is not None
|
||||
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:
|
||||
@@ -448,6 +501,262 @@ def visible_project_ids(user: SessionUser | None) -> list[str]:
|
||||
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
|
||||
@@ -544,16 +853,14 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project
|
||||
# the viewer cannot even read.
|
||||
if not can_read_project(user, pid):
|
||||
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
|
||||
# §22.7 union, override grants first — these bypass per-RFC curation
|
||||
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
|
||||
# §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)
|
||||
@@ -561,11 +868,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): a granted deployment
|
||||
# contributor on a public project may discuss only while the RFC is
|
||||
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
|
||||
# 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_write_baseline(user, pid):
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -589,14 +896,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §22.7 union, override grants first (project_contributor ⊇
|
||||
# rfc_collaborators(contributor); project_admin / superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) == "project_contributor":
|
||||
# §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.
|
||||
@@ -605,9 +910,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): until an owner exists, a
|
||||
# granted deployment contributor on a public project may contribute.
|
||||
if not owners and _has_write_baseline(user, pid):
|
||||
# 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
|
||||
|
||||
@@ -620,13 +926,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise
|
||||
# only the RFC's frontmatter owner. Per-RFC collaborators and the
|
||||
# implicit-public baseline do not get the invite-others power.
|
||||
if is_project_superuser(user, pid):
|
||||
# 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)
|
||||
|
||||
|
||||
+118
-3
@@ -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,
|
||||
|
||||
+36
-16
@@ -54,10 +54,27 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
|
||||
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: project %s: cannot list rfcs/: %s", project_id, 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()
|
||||
@@ -71,28 +88,31 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, 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: %s: skipping %s: missing slug", project_id, 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, project_id=project_id)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
|
||||
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
||||
# 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 project, for reconciler attention.
|
||||
# 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 project_id = ?", (project_id,)
|
||||
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, 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, project_id: str = "default") -> 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
|
||||
@@ -105,10 +125,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
|
||||
(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,
|
||||
unreviewed, reviewed_at, reviewed_by, project_id,
|
||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(project_id, slug) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
rfc_id = excluded.rfc_id,
|
||||
@@ -150,7 +170,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
|
||||
1 if entry.unreviewed else 0,
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
project_id,
|
||||
collection_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -210,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(project_id, 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
|
||||
@@ -385,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(project_id, 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
|
||||
@@ -407,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(project_id, 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
|
||||
""",
|
||||
|
||||
@@ -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
|
||||
@@ -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(project_id, user_id, rfc_slug) DO NOTHING
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
@@ -112,6 +112,11 @@ async def lifespan(app: FastAPI):
|
||||
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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
+79
-8
@@ -7,9 +7,13 @@ 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"
|
||||
|
||||
|
||||
@@ -20,6 +24,74 @@ def resolved_default_id(config: Config) -> str:
|
||||
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
|
||||
@@ -41,11 +113,10 @@ def content_repo(project_id: str) -> str | None:
|
||||
|
||||
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project. Defaults to
|
||||
'super-draft' for an unknown/unset row (the safe, today's-flow default)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
"""§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)
|
||||
)
|
||||
|
||||
+185
-20
@@ -55,6 +55,18 @@ class ProjectEntry:
|
||||
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
|
||||
@@ -114,44 +126,110 @@ def parse_registry(text: str) -> RegistryDoc:
|
||||
)
|
||||
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
||||
"""Upsert the parsed registry into projects + deployment. Idempotent.
|
||||
def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
|
||||
|
||||
§22.4a: `type` is immutable — a change against an existing row is rejected
|
||||
(skip + log), never applied. Projects absent from the registry are left in
|
||||
place (archival is out of scope for M3; they simply stop refreshing).
|
||||
`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 projects WHERE id = ?", (e.id,)
|
||||
"SELECT type FROM collections WHERE id = ?", (cid,)
|
||||
).fetchone()
|
||||
if existing is not None and existing["type"] != e.type:
|
||||
type_locked = existing is not None and existing["type"] != e.type
|
||||
if type_locked:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on project %s (%s -> %s)",
|
||||
e.id, existing["type"], e.type,
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
cid, existing["type"], e.type,
|
||||
)
|
||||
continue
|
||||
# The project (grouping tier) always refreshes.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(id, name, type, content_repo, visibility, initial_state,
|
||||
config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
(id, name, content_repo, visibility, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
content_repo = excluded.content_repo,
|
||||
visibility = excluded.visibility,
|
||||
initial_state = excluded.initial_state,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(
|
||||
e.id, e.name, e.type, e.content_repo, e.visibility,
|
||||
e.initial_state, json.dumps(e.config), registry_sha,
|
||||
),
|
||||
(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(
|
||||
"""
|
||||
@@ -163,6 +241,90 @@ def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -181,5 +343,8 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
# 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)
|
||||
apply_registry(doc, sha)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
-- 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.
|
||||
|
||||
-- ── 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';
|
||||
@@ -9,11 +9,18 @@ from test_propose_vertical import ( # noqa: F401
|
||||
|
||||
|
||||
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, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
|
||||
(pid, name, typ, pid, vis),
|
||||
"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),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +100,7 @@ def test_rfc_root_url_redirects_308_to_project_scoped(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/e/human"
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
@@ -102,7 +109,7 @@ def test_rfc_pr_url_redirects_308_to_project_scoped(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/e/human/pr/7"
|
||||
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):
|
||||
@@ -110,7 +117,7 @@ def test_proposals_root_url_redirects_308_to_project_scoped(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/proposals/42"
|
||||
assert r.headers["location"] == "/p/default/c/default/proposals/42"
|
||||
|
||||
|
||||
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
@@ -120,7 +127,7 @@ def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
_add_project("teamx", "Team X", "gated")
|
||||
provision_user_row(user_id=5, login="mia", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
@@ -33,7 +33,7 @@ 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 projects SET initial_state='active' WHERE id='default'")
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
@@ -21,12 +21,17 @@ def _fresh_config() -> Config:
|
||||
|
||||
|
||||
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)
|
||||
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" in cols and cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
|
||||
project #2. Proves two projects can hold the same slug, that (project_id, slug)
|
||||
is still unique within a project, that the rebuilt FK is composite + enforced,
|
||||
and that the no-foreign-keys migration runner left no dangling references."""
|
||||
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 _seed_two_projects(conn):
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
|
||||
(pid, pid.title(), pid + "-content"),
|
||||
)
|
||||
|
||||
|
||||
def test_same_slug_coexists_across_projects():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', ?)",
|
||||
(pid,),
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
|
||||
).fetchall()
|
||||
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
|
||||
|
||||
|
||||
def test_slug_still_unique_within_a_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'default')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Dup', 'active', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_rfc_collaborators_composite_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
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, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
|
||||
)
|
||||
# Matching (project_id, slug) — FK holds.
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'ecomm')"
|
||||
)
|
||||
# Same slug but a project with no such entry — composite FK must reject.
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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')")
|
||||
@@ -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
|
||||
@@ -64,39 +64,55 @@ def _set_visibility(project_id: str, visibility: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
# §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 project_members (project_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(project_id, user_id, role),
|
||||
"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 db
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user_id),
|
||||
"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,
|
||||
project_id). project_id defaults to 'default' via migration 026 but we set
|
||||
it explicitly for clarity."""
|
||||
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 db
|
||||
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, project_id)
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
|
||||
VALUES (?, ?, ?, ?, '[]', '[]', ?)
|
||||
""",
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,18 +170,16 @@ def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
|
||||
assert auth.can_read_project(owner, "default") is True
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# project_viewer → read + discuss, but not contribute.
|
||||
_add_member("default", 1, "project_viewer")
|
||||
# §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 False
|
||||
|
||||
# project_contributor → contribute.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
|
||||
# project_admin → superuser within the project.
|
||||
# project_admin → owner → superuser within the project.
|
||||
_add_member("default", 1, "project_admin")
|
||||
assert auth.is_project_superuser(contributor, "default") is True
|
||||
|
||||
@@ -270,7 +284,7 @@ def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code != 403
|
||||
|
||||
|
||||
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
|
||||
def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
@@ -285,14 +299,11 @@ def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
|
||||
|
||||
# project_viewer: can discuss (200) but cannot contribute (resolver).
|
||||
_add_member("default", 2, "project_viewer")
|
||||
# §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 False
|
||||
|
||||
# project_contributor: can contribute.
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is True
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +16,18 @@ from test_propose_vertical import ( # noqa: F401
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
# The 19 tables migration 026 threads project_id onto (docs/design/
|
||||
# multi-project-spec.md §5 amendment list).
|
||||
SLUG_TABLES = [
|
||||
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
|
||||
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
|
||||
"branch_chat_seen", "watches", "notifications", "actions",
|
||||
"pr_resolution_branches", "funder_consents", "rfc_invitations",
|
||||
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
|
||||
# §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",
|
||||
]
|
||||
|
||||
|
||||
@@ -45,25 +49,28 @@ def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_project_id_on_every_slug_table(app_with_fake_gitea):
|
||||
def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
for table in SLUG_TABLES:
|
||||
cols = {r["name"]: r for r in db.conn().execute(
|
||||
f"PRAGMA table_info({table})"
|
||||
)}
|
||||
assert "project_id" in cols, f"{table} missing project_id"
|
||||
col = cols["project_id"]
|
||||
# NOT NULL with the constant 'default' backfill default.
|
||||
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
|
||||
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
|
||||
# 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 project_id) lands in the default
|
||||
project — the trick that keeps every pre-multi-project INSERT working."""
|
||||
"""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
|
||||
@@ -73,35 +80,36 @@ def test_existing_row_backfills_to_default(app_with_fake_gitea):
|
||||
("human", "Human", "active"),
|
||||
)
|
||||
got = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["project_id"]
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert got == "default"
|
||||
|
||||
|
||||
def test_project_members_table_shape(app_with_fake_gitea):
|
||||
def test_memberships_table_shape(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
cols = {r["name"] for r in db.conn().execute(
|
||||
"PRAGMA table_info(project_members)"
|
||||
)}
|
||||
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
|
||||
# The role CHECK rejects an unknown role.
|
||||
# §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 project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'project_admin')"
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'owner')"
|
||||
)
|
||||
import sqlite3
|
||||
try:
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'nonsense')"
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'nonsense')"
|
||||
)
|
||||
assert False, "CHECK should reject an unknown project role"
|
||||
assert False, "CHECK should reject an unknown role"
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ from test_propose_vertical import ( # noqa: F401
|
||||
def _register_ecomm(fake):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')"
|
||||
"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")
|
||||
|
||||
@@ -24,6 +28,13 @@ def test_propose_into_second_project_lands_scoped(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={
|
||||
@@ -52,8 +63,12 @@ def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
|
||||
from app import db
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')"
|
||||
"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",
|
||||
|
||||
@@ -11,18 +11,25 @@ from test_propose_vertical import ( # noqa: F401
|
||||
|
||||
|
||||
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, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
|
||||
"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, project_id) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, pid),
|
||||
)
|
||||
|
||||
|
||||
@@ -90,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.
|
||||
|
||||
@@ -227,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:
|
||||
@@ -242,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"})
|
||||
|
||||
@@ -74,13 +74,20 @@ def test_parse_rejects_invalid(bad, msg):
|
||||
def test_apply_upserts_projects_and_deployment():
|
||||
_db()
|
||||
doc = registry.parse_registry(VALID)
|
||||
registry.apply_registry(doc, registry_sha="regsha1")
|
||||
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, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'"
|
||||
"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"
|
||||
@@ -88,11 +95,12 @@ def test_apply_upserts_projects_and_deployment():
|
||||
|
||||
def test_apply_rejects_type_change_on_existing_project():
|
||||
_db()
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1")
|
||||
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") # logged + skipped, no raise
|
||||
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
|
||||
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 project upsert was skipped.
|
||||
# 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"
|
||||
|
||||
@@ -12,10 +12,14 @@ def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_git
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'"
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
|
||||
assert prow["type"] == "document"
|
||||
# §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
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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,359 @@
|
||||
# Solution Design: Configurable Collection Metadata (clean-doc tagging)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Author(s)** | Ben Stull |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `draft` |
|
||||
| **Version** | v0.1.0 |
|
||||
| **Source artifacts** | Reference modeled: retired **BDD Release Planner** (`wiggleverse/wiggleverse-ecomm-bdd-release-planner-app`, RETIRED 2026-06-04) · Related spec: [`docs/design/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: — |
|
||||
|
||||
**Change log**
|
||||
|
||||
| Date | Version | Change | By |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-06 | v0.1.0 | Initial draft from discovery session OHM-0079.0 | Ben Stull |
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
rfc-app gains a generic, **per-collection-configurable metadata system**. A collection declares its fields — `priority`, `tags`, and arbitrary custom fields — in its `.collection.yaml`; each entry's *values* live in a **sidecar** (`<slug>.meta.yaml`) so the document body stays pure prose. rfc-app renders schema-driven forms and **faceted left-pane filters**, and supports fast **single + bulk tag/untag** via direct commits for authorized roles. This restores the *corpus-annotation* capability of the retired BDD Release Planner inside the framework, while keeping release *planning* (ordering, ship status, roadmap emission) a downstream concern that reads the sidecars straight from git.
|
||||
|
||||
## 2. Business Context
|
||||
|
||||
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. Operators planning work, and downstream tools consuming the corpus, currently have no structured, framework-native way to set or read that metadata.
|
||||
|
||||
## 3. Problem Statement
|
||||
|
||||
Today rfc-app metadata is weak and intrusive:
|
||||
|
||||
- **Tags are free-form strings** with **no filtering** — §7.1 specifies a `Tag:` filter chip that was never implemented.
|
||||
- **No priority**, and no way for a collection to declare any other structured field.
|
||||
- **No per-collection schema** — every collection gets the same fixed frontmatter shape; a `bdd` collection cannot say "scenarios have a P0–P3 priority."
|
||||
- **Metadata pollutes the document body** — a large YAML frontmatter block sits at the top of every doc, mixing rfc-app lifecycle bookkeeping with content metadata.
|
||||
- **No bulk workflow** — annotating hundreds of scenarios one PR at a time is impractical, so the planner's core gesture has no analog.
|
||||
|
||||
Result: corpus authors can't meaningfully prioritise/tag, downstream consumers have nothing structured to read, and documents aren't clean.
|
||||
|
||||
## 4. Stakeholders / Personas / Actors
|
||||
|
||||
| Actor | Type | Goal in this design |
|
||||
| --- | --- | --- |
|
||||
| Corpus contributor | persona | Set priority and tags on a scenario when proposing/curating it |
|
||||
| Release planner (operator) | persona | Bulk-prioritise/tag many scenarios quickly to shape a plan |
|
||||
| Collection owner | operator | Declare the metadata fields a collection supports |
|
||||
| Downstream consumer | system | Read structured per-scenario metadata from the git corpus (e.g. an external release planner) |
|
||||
| Reader | persona | Read clean scenario docs; filter the catalog by priority/tag |
|
||||
|
||||
## 5. Scope
|
||||
|
||||
- **In scope:** per-collection field schema in `.collection.yaml` (`enum`, `tags`, `text`); per-entry sidecar (`<slug>.meta.yaml`) as source of truth with pure-prose doc bodies; schema-derived faceted left-pane filtering with counts; schema-derived metadata form (detail panel); single + bulk tag/untag with direct commit for authorized roles; dual-read compatibility + a one-shot frontmatter→sidecar migration tool + lazy migration on write.
|
||||
- **Out of scope:** release ordering, ship status, roadmap emission, `RELEASE-PLAN.md` generation — these stay downstream, consuming sidecars from git. In-app management of field definitions / controlled vocabularies; corpus-wide tag rename/merge/delete. Sub-document (per-scenario-within-a-file) grain. A whole-corpus metadata export endpoint.
|
||||
- **Non-goals:** rebuilding a bespoke "release" entity in rfc-app — releases are not modeled here at all; metadata is the only primitive.
|
||||
|
||||
## 6. 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), so no sub-document parsing is needed.
|
||||
- **Constraints:** rfc-app is a framework hosting multiple deployments — the upgrade must be **mechanical and non-breaking**, with §20 changelog/upgrade-steps; the hard secrets rule (§6.3) holds; metadata edits must respect scope-role authorization (§22 Part B / S3).
|
||||
- **Dependencies:** the S3 scope-role resolver (`auth.effective_scope_role`) for edit authorization; the existing git write-through path used by `edit-meta` (§9.5); the §22 collection model (`.collection.yaml`, `cached_rfcs` keyed by `(collection_id, slug)`).
|
||||
|
||||
## 7. Targeted Business Outcomes
|
||||
|
||||
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Authors can prioritise/tag scenarios | scenarios carrying a priority | 0 → corpus-wide | doc bodies stay prose-clean | corpus inspection after rollout |
|
||||
| Corpus is filterable | left-pane facet filters available | none → priority+tags+state | filter latency acceptable at ~1.2k entries | manual + perf check |
|
||||
| Downstream tools can consume metadata | sidecars readable from git | none → all migrated entries | sidecar schema stable | consumer integration |
|
||||
| Clean docs | docs with no rfc-app frontmatter | 0% → 100% (post-migration) | dual-read keeps legacy working | post-migration inspection |
|
||||
|
||||
## 8. Business Use Cases
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-1 — A contributor prioritises a scenario
|
||||
Given a bdd collection whose schema defines a priority field
|
||||
When a contributor marks a scenario as P0
|
||||
Then the scenario's metadata records priority P0
|
||||
And the document body is unchanged prose
|
||||
|
||||
Scenario: BUC-2 — A planner batch-prioritises a set of scenarios
|
||||
Given a contributor has selected 40 scenarios
|
||||
When they set priority P1 on the selection
|
||||
Then all 40 carry priority P1
|
||||
And the change lands as a single auditable commit
|
||||
|
||||
Scenario: BUC-3 — A downstream tool consumes priorities
|
||||
Given scenarios carry priority and tags in their sidecars
|
||||
When an external release planner reads the corpus from git
|
||||
Then it can group and order scenarios using that metadata
|
||||
And rfc-app did not need to model releases at all
|
||||
|
||||
Scenario: BUC-4 — Documents stay clean
|
||||
Given a migrated collection
|
||||
When a reader opens a scenario document
|
||||
Then they see only prose, with no rfc-app metadata block
|
||||
```
|
||||
|
||||
- **BUC-1 acceptance:** the scenario's `priority` value is `P0` and its `.md` body is byte-identical to before.
|
||||
- **BUC-2 acceptance:** 40 sidecars updated; exactly one commit; authorship recorded.
|
||||
- **BUC-3 acceptance:** sidecars + `.collection.yaml` are sufficient for a consumer to read priority/tags without rfc-app's API.
|
||||
- **BUC-4 acceptance:** no `---` frontmatter remains in migrated docs.
|
||||
|
||||
## 9. Product Use Cases
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-1 — Set priority/tags on a scenario (realizes BUC-1)
|
||||
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-1/BUC-3)
|
||||
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 declares its fields (product-only)
|
||||
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)
|
||||
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
|
||||
```
|
||||
|
||||
## 10. UX Layout
|
||||
|
||||
### 10.1 Screen: Catalog (left pane) (serves PUC-3)
|
||||
|
||||
- **Purpose:** browse and filter a collection's entries.
|
||||
- **Layout (top → bottom):**
|
||||
- **Search:** full-text box (existing).
|
||||
- **Faceted filter groups** (one per schema field + state): each is a collapsible group showing per-value **result counts** and multi-select checkboxes; `tags`-type fields include a "filter values…" search box to stay usable at 30+ values. (Chosen layout: faceted groups with counts — validated in brainstorming over flat chips.)
|
||||
- **States:** happy: facets with counts · empty: "no entries match these filters" with a clear-filters action · loading: skeleton facets · error: "couldn't load facets" with retry.
|
||||
|
||||
### 10.2 Screen: Scenario detail — metadata panel (serves PUC-1)
|
||||
|
||||
- **Purpose:** view/edit one entry's metadata.
|
||||
- **Layout:** a panel rendering one control per schema field — `enum` → single-select; `tags` → removable chips + add-tag input (with existing AI suggest where applicable); `text` → text input. The document body renders below as pure prose; metadata never appears inline in the body.
|
||||
- **States:** read (role without edit) shows values, no controls · edit (authorized) shows controls · saving: inline spinner · error: field-level validation message (e.g. "P5 is not an allowed priority").
|
||||
|
||||
### 10.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 action bar: "*N* selected · Set priority ▾ · Add tag ▾ · Remove tag ▾ · Clear". Each action targets one field; applying commits once.
|
||||
- **States:** none selected: bar hidden · applying: bar shows progress · partial failure: toast naming entries that failed validation, others applied.
|
||||
|
||||
## 11. Technical Design
|
||||
|
||||
### 11.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 against the schema surfaces as a warning and the entry still loads.
|
||||
- **INV-4:** Metadata writes are authorized by scope-role (contributor+ on the collection); 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 feature is additive and opt-in per collection.
|
||||
- **INV-6:** Dual-read holds throughout: parser reads the sidecar if present, else legacy top-of-doc frontmatter, with identical resulting in-memory records.
|
||||
|
||||
### 11.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/>validate vs schema]
|
||||
MD --> ING
|
||||
SC --> ING
|
||||
ING --> DB[(cached_rfcs<br/>values + facet counts)]
|
||||
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 -->|direct commit| SC
|
||||
BULK -->|1 commit| SC
|
||||
SC -.read from git.-> CONS[downstream consumers]
|
||||
```
|
||||
|
||||
- **ingest/parser** — owns reading `.collection.yaml` schema + sidecars (or legacy frontmatter), validating values, and rebuilding `cached_rfcs`; must never treat the DB as authoritative.
|
||||
- **API** — owns serving the schema, filtered lists with facet counts, and metadata edits; must never write metadata anywhere but the sidecar in git.
|
||||
|
||||
### 11.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 (`slug,title,state,owners,…`) + schema fields (`priority,tags,…`) | git (sidecar) |
|
||||
| Derived index | ingest | per-entry values + facet aggregations | `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). Unknown types are ignored with a warning (forward-compat).
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
### 11.4 Interfaces & contracts
|
||||
|
||||
- **`GET /api/projects/<p>/collections/<c>`** — out: collection incl. `fields` schema. Errors: 404.
|
||||
- **`GET /api/projects/<p>/collections/<c>/rfcs`** — in: filter params (`?priority=P0&tags=checkout&state=active`, repeatable for multi-value/OR-within-field, AND across fields) · out: entries with metadata values **+ `facets: {field → {value → count}}`**. Errors: 400 on unknown field.
|
||||
- **`POST /api/projects/<p>/collections/<c>/rfcs/<slug>/meta`** — in: `{field: value, …}` · out: updated values · effect: validate vs schema → write sidecar → commit directly → re-ingest entry. Errors: 403 (role), 422 (invalid value).
|
||||
- **`POST /api/projects/<p>/collections/<c>/meta/bulk`** — in: `{slugs: […], op: set|add|remove, field, value}` · out: `{applied: […], rejected: [{slug, reason}]}` · effect: validate → write N sidecars → **one** commit → re-ingest. Errors: 403, 422.
|
||||
|
||||
### 11.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 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) + validate 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 (a) target the sidecar rather than frontmatter and (b) batch N files into one commit. Honors INV-1/INV-4.
|
||||
|
||||
#### PUC-5 — Migration
|
||||
|
||||
- **Implementation:** a tool (framework CLI verb / `tools/` script) walks a collection, and for each entry with legacy frontmatter, writes `<slug>.meta.yaml` from the frontmatter and rewrites `<slug>.md` to the body only — one commit per collection, idempotent. Dual-read (INV-6) means this can run anytime; lazy migration converts stragglers on their first metadata edit.
|
||||
|
||||
### 11.6 Non-functional requirements & cross-cutting concerns
|
||||
|
||||
- **Security & privacy:** metadata edits gated by `auth.effective_scope_role` (contributor+ on the collection); no secrets in sidecars; git history records authorship.
|
||||
- **Performance & scale:** facet counts computed from the derived DB; must stay responsive at ~1.2k entries with dozens of tag values (indexed value columns / aggregation query).
|
||||
- **Availability & resilience:** bad metadata never blocks read (INV-3); a failed re-ingest leaves git authoritative and is recoverable by full rebuild.
|
||||
- **Observability:** log each metadata commit (actor, field, entry count); warn-log schema validation failures encountered on ingest.
|
||||
- **Accessibility:** facet groups and form controls keyboard-navigable; checkboxes labelled with value + count.
|
||||
|
||||
### 11.7 Key decisions & alternatives considered
|
||||
|
||||
| Decision | Chosen | Alternatives | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Release modeling | Metadata only; releases downstream | First-class release entity in rfc-app; release-typed tags with behavior | Operator pulled ordering/ship-status out of rfc-app; metadata is the only needed primitive |
|
||||
| Tag system shape | One generic typed-field system (Approach A) | Releases first-class + simple tags; namespaced facets | Tags/priority/custom are all just fields; one mechanism |
|
||||
| Metadata storage | Sidecar per entry | Top-of-doc frontmatter (today); end-of-doc block; collection index file; DB-only | Clean docs + git-visible to consumers + locality per scenario |
|
||||
| Left-pane filtering | Faceted groups with counts | Flat facet chips | Scales to the ~1.2k-scenario, many-tag ecomm corpus |
|
||||
| Edit governance | Direct commit for authorized roles (bulk = 1 commit) | PR per change | Bulk planning is impractical via PR-per-toggle |
|
||||
| Mgmt UI | Deferred; edit `.collection.yaml` in git | In-app field/vocab management in v1 | Smallest coherent v1 |
|
||||
|
||||
### 11.8 Testing strategy
|
||||
|
||||
Unit tests for: schema parsing (`fields:` block, all types, missing block); sidecar read/write round-trip; dual-read equivalence (frontmatter vs sidecar produce identical records); schema validation (reject bad enum, accept free-form tag); facet aggregation; bulk op (set/add/remove, single commit, partial-rejection). Two-tier local-Docker→PPE for the API + git write-through. "Tested" = the PUC acceptance scenarios pass plus the migration is proven idempotent and reversible-on-read.
|
||||
|
||||
### 11.9 Failure modes, rollback & flags
|
||||
|
||||
- **Failure mode:** invalid value committed out-of-band → on ingest, warn + load entry with the raw value flagged (INV-3); not surfaced as a filter facet count error.
|
||||
- **Failure mode:** re-ingest fails after commit → git is authoritative; full rebuild recovers.
|
||||
- **Migration rollback:** dual-read means an un-migrated or partially-migrated corpus still works; the migration commit is revertible.
|
||||
- **Feature flag:** the feature is inherently opt-in per collection (INV-5) — no global flag needed; absent a `fields:` block, behavior is unchanged.
|
||||
|
||||
## 12. Delivery Plan
|
||||
|
||||
### 12.1 Approach / strategy
|
||||
|
||||
Build the storage/compat foundation first (sidecars + dual-read + migration) so nothing breaks, then the schema, then read (filtering), then write (single, bulk). Each slice is shippable and non-breaking.
|
||||
|
||||
### 12.2 Slicing plan
|
||||
|
||||
#### SLICE-1 — Sidecar storage + dual-read + migration → completes PUC-5
|
||||
- **Depends on:** —
|
||||
- **DoD:** parser reads sidecar-if-present else legacy frontmatter (INV-6); migration tool splits frontmatter→sidecar idempotently; existing collections load byte-identically; tests green.
|
||||
|
||||
#### SLICE-2 — Collection field schema + validation → completes PUC-4
|
||||
- **Depends on:** SLICE-1
|
||||
- **DoD:** `.collection.yaml fields:` parsed (`enum`/`tags`/`text`); values validated on read (warn) and on write (reject); 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; left pane renders faceted groups with counts and tag-value search; filters compose (AND across fields).
|
||||
|
||||
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
|
||||
- **Depends on:** SLICE-2
|
||||
- **DoD:** detail metadata panel renders schema controls; `POST …/meta` validates, direct-commits the sidecar, re-ingests; authorized by scope-role (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 action bar; `POST …/meta/bulk` applies set/add/remove as one commit; partial-rejection reported.
|
||||
|
||||
### 12.3 Rollout / launch plan
|
||||
|
||||
Pre-v1, single production: ship slices in order to the RFC deployment; each minor-version bump carries §20 changelog + upgrade steps. The opt-in-per-collection nature (INV-5) means a deployment adopts it only when it declares a `fields:` block and (optionally) runs the migration.
|
||||
|
||||
### 12.4 Risks & mitigations
|
||||
|
||||
| Risk | L/I | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Frontmatter→sidecar migration corrupts content | L/H | Dual-read; idempotent, revertible migration; body-byte-identity test |
|
||||
| Doubling file count (sidecars) clutters corpus | M/L | Docs stay clean; sidecars are small/co-located; acceptable for one-file-per-scenario corpora |
|
||||
| Direct-commit metadata edits bypass review | M/M | Scope-role gate (INV-4); content-body edits still PR'd; full git audit trail |
|
||||
| Overlap/conflict with §22 S6 "type modules" | M/M | Position as §23, generalizing S6's per-type frontmatter; reconcile at the S6 SPEC merge |
|
||||
| Facet aggregation slow at scale | L/M | Compute from indexed derived DB; measure at ~1.2k entries |
|
||||
|
||||
## 13. Traceability matrix
|
||||
|
||||
| Business UC | Product UC | Slice | Tests |
|
||||
| --- | --- | --- | --- |
|
||||
| BUC-4 | PUC-5 | SLICE-1 | `test_dual_read_equiv`, `test_migration_idempotent` |
|
||||
| — (product-only) | PUC-4 | SLICE-2 | `test_schema_parse`, `test_validation` |
|
||||
| BUC-1/BUC-3 | PUC-3 | SLICE-3 | `test_facet_counts`, `test_filter_compose` |
|
||||
| BUC-1 | PUC-1 | SLICE-4 | `test_single_meta_commit`, `test_authz` |
|
||||
| BUC-2 | PUC-2 | SLICE-5 | `test_bulk_one_commit`, `test_partial_reject` |
|
||||
| BUC-3 | (consumer reads git) | — | `test_sidecar_schema_stable` |
|
||||
|
||||
## 14. Open Questions & Decisions log
|
||||
|
||||
**Open**
|
||||
|
||||
| # | Question | Owner | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| Q1 | Do downstream consumers read sidecars from git, via rfc-app API, or both? (leaning git) | Ben | nothing v1 |
|
||||
| Q2 | Should a `multi-enum` type (multi-select controlled) ship in v1 or later? | Ben | SLICE-2 scope |
|
||||
| Q3 | Exact §23 placement / reconciliation with §22 S6 type-modules | Ben | SPEC merge |
|
||||
|
||||
**Resolved**
|
||||
|
||||
| # | Decision | Resolution | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | Release behaviors (ordering, ship status, roadmap emit) | 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 |
|
||||
|
||||
## 15. 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.
|
||||
- **Downstream consumer** — an external tool (e.g. a release planner) that reads corpus metadata from git; rfc-app does not model releases.
|
||||
- **References:** retired BDD Release Planner (`wiggleverse-ecomm-bdd-release-planner-app`); §22 three-tier design (`docs/design/2026-06-05-three-tier-projects-collections.md`); SPEC §7.1 (left-pane filter), §9.5 (edit-meta), §20 (versioning), §22 Part B / S3 (scope-role).
|
||||
@@ -0,0 +1,352 @@
|
||||
# Draft spec — §22.4a per-type surfaces (the last S6 item)
|
||||
|
||||
> **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.
|
||||
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.
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.38.0",
|
||||
"version": "0.46.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2615,3 +2615,25 @@ select:focus-visible,
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* §22 S4 — the collection directory head (title + owner controls) and the
|
||||
role-aware empty state. The S2 directory shipped with semantic classnames
|
||||
and default styling; S4 adds the action row and the create CTA. */
|
||||
.directory-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.directory-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.directory-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
+54
-11
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import { brandTitle } from './lib/brand'
|
||||
import { entryPath, proposalPath } from './lib/entryPaths'
|
||||
import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
|
||||
import { useDeployment } from './context/DeploymentProvider'
|
||||
import ProjectLayout from './components/ProjectLayout.jsx'
|
||||
import Directory from './components/Directory.jsx'
|
||||
@@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import CollectionDirectory from './components/CollectionDirectory.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
@@ -66,6 +67,10 @@ export default function App() {
|
||||
// right project. Falls back to the deployment default off a project route.
|
||||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||||
// §22 S2 — the collection the viewer is currently in (from the /c/<cid>/ URL
|
||||
// segment), so a propose targets that collection. Falls back to the default.
|
||||
const _colMatch = location.pathname.match(/^\/p\/[^/]+\/c\/([^/]+)/)
|
||||
const currentCollectionId = (_colMatch && _colMatch[1]) || DEFAULT_COLLECTION
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
@@ -360,10 +365,22 @@ export default function App() {
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="e/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
{/* §22 S2: the project landing is the collection directory —
|
||||
it lists collections, or (C3.7/C3.8) redirects into the
|
||||
sole visible collection when there is exactly one. */}
|
||||
<Route path="" element={<CollectionDirectoryRoute />} />
|
||||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||||
/c/<collection>/ segment redirect into the default
|
||||
collection, so old bookmarks keep working. */}
|
||||
<Route path="e/:slug" element={<LegacyCorpusRedirect kind="entry" />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<LegacyCorpusRedirect kind="entryPr" />} />
|
||||
<Route path="proposals/:prNumber" element={<LegacyCorpusRedirect kind="proposal" />} />
|
||||
{/* Collection-scoped corpus. Serving stays project-scoped in
|
||||
S1 (collection = default); collection-aware serving is S2. */}
|
||||
<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>
|
||||
</main>
|
||||
</ProjectLayout>
|
||||
@@ -378,12 +395,13 @@ export default function App() {
|
||||
viewer={viewer}
|
||||
initialTitle={proposeParam || ''}
|
||||
projectId={currentProjectId}
|
||||
collectionId={currentCollectionId}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(proposalPath(currentProjectId, pr_number))
|
||||
navigate(proposalPath(currentProjectId, pr_number, currentCollectionId))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -403,21 +421,46 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// §22 S2 — the project landing at /p/:projectId/ is the collection directory.
|
||||
// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
|
||||
// (which lists collections, or redirects into the sole one — C3.7/C3.8).
|
||||
function CollectionDirectoryRoute() {
|
||||
const { projectId } = useParams()
|
||||
return <CollectionDirectory projectId={projectId} />
|
||||
}
|
||||
|
||||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||||
// /c/<collection>/ segment: redirect into the default collection, preserving any
|
||||
// query string (e.g. ?branch=).
|
||||
function LegacyCorpusRedirect({ kind }) {
|
||||
const { projectId, slug, prNumber } = useParams()
|
||||
const { search } = useLocation()
|
||||
const base = `/p/${projectId}/c/${DEFAULT_COLLECTION}`
|
||||
let to = `${base}/`
|
||||
if (kind === 'entry') to = `${base}/e/${slug}`
|
||||
else if (kind === 'entryPr') to = `${base}/e/${slug}/pr/${prNumber}`
|
||||
else if (kind === 'proposal') to = `${base}/proposals/${prNumber}`
|
||||
return <Navigate to={to + (search || '')} replace />
|
||||
}
|
||||
|
||||
function DeploymentLanding() {
|
||||
// §22.10 + design decision 2 — N=1 lands in the single visible project so
|
||||
// OHM's "land in the corpus" UX is preserved; the directory appears only
|
||||
// when 2+ projects are visible to the caller. Visibility is per-caller
|
||||
// (§22.5), so the unlisted projects never count toward the directory.
|
||||
const { projects, defaultProjectId, loading } = useDeployment()
|
||||
const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
|
||||
if (loading) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
if (projects.length === 1) {
|
||||
return <Navigate to={`/p/${projects[0].id}/`} replace />
|
||||
}
|
||||
if (projects.length === 0 && defaultProjectId) {
|
||||
// No enumerable projects but a default exists (e.g. an anon hitting a
|
||||
// deployment whose only project is unlisted-but-default) — land there.
|
||||
if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
|
||||
// No enumerable projects but the default is readable by this viewer (e.g. an
|
||||
// anon hitting a deployment whose only project is unlisted-but-default) —
|
||||
// land there. §22 S5: when the default is gated to this viewer (C3.2) or
|
||||
// absent (C3.1, no projects), we do NOT bounce into a 404 — we fall through
|
||||
// to the directory's role-aware empty state below.
|
||||
return <Navigate to={`/p/${defaultProjectId}/`} replace />
|
||||
}
|
||||
return <Directory />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// §22 S2 — the API client builds collection-scoped URLs when a collection id is
|
||||
// supplied, and falls back to the project/default-collection paths otherwise.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { listRFCs, getRFC, proposeRFC, listCollections } from './api.js'
|
||||
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [] }),
|
||||
}))
|
||||
global.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('collection-scoped api URLs', () => {
|
||||
it('listRFCs scopes to a collection when given one', async () => {
|
||||
const f = mockFetch()
|
||||
await listRFCs('ohm', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs')
|
||||
})
|
||||
|
||||
it('listRFCs falls back to the project default path without a collection', async () => {
|
||||
const f = mockFetch()
|
||||
await listRFCs('ohm')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/rfcs')
|
||||
})
|
||||
|
||||
it('getRFC scopes to a collection when given one', async () => {
|
||||
const f = mockFetch()
|
||||
await getRFC('ohm', 'login', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs/login')
|
||||
})
|
||||
|
||||
it('proposeRFC targets the collection-scoped propose route', async () => {
|
||||
const f = mockFetch()
|
||||
await proposeRFC('ohm', { title: 'T', slug: 's', pitch: 'p', tags: [], collectionId: 'features' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/projects/ohm/collections/features/rfcs/propose')
|
||||
})
|
||||
|
||||
it('listCollections hits the project collections route', async () => {
|
||||
const f = mockFetch()
|
||||
await listCollections('ohm')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// §22.8 — the request-to-join API client builds scope-keyed URLs and methods.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { joinTarget, requestJoin, acceptJoinRequest, declineJoinRequest } from './api.js'
|
||||
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
}))
|
||||
global.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('join-request api URLs', () => {
|
||||
it('joinTarget GETs the scope join-target', async () => {
|
||||
const f = mockFetch()
|
||||
await joinTarget('collection', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/scopes/collection/features/join-target')
|
||||
})
|
||||
|
||||
it('requestJoin POSTs the desired role + message', async () => {
|
||||
const f = mockFetch()
|
||||
await requestJoin('project', 'ohm', { role: 'contributor', message: 'hi' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/project/ohm/join-requests')
|
||||
const opts = f.mock.calls[0][1]
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ role: 'contributor', message: 'hi' })
|
||||
})
|
||||
|
||||
it('acceptJoinRequest POSTs the accept route with an optional role override', async () => {
|
||||
const f = mockFetch()
|
||||
await acceptJoinRequest('collection', 'features', 7, 'contributor')
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/accept')
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ role: 'contributor' })
|
||||
})
|
||||
|
||||
it('declineJoinRequest POSTs the decline route', async () => {
|
||||
const f = mockFetch()
|
||||
await declineJoinRequest('collection', 'features', 7)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/decline')
|
||||
expect(f.mock.calls[0][1].method).toBe('POST')
|
||||
})
|
||||
})
|
||||
+129
-7
@@ -182,22 +182,102 @@ export async function getProject(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||
}
|
||||
|
||||
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
||||
// project-scoped routes so a non-default project's corpus renders; without
|
||||
// one, fall back to the default-project compat path.
|
||||
export async function listRFCs(projectId) {
|
||||
// §22 S5: create-project (global Owner). The backend provisions a Gitea content
|
||||
// repo, commits the project to projects.yaml, re-mirrors the registry, and
|
||||
// returns the new project { id, name, type, visibility }.
|
||||
export async function createProject({ projectId, name, type, visibility, contentRepo }) {
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
name,
|
||||
type,
|
||||
visibility: visibility || null,
|
||||
content_repo: contentRepo || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||
// project default-collection compat path; with neither, the unscoped path.
|
||||
export async function listRFCs(projectId, collectionId) {
|
||||
if (projectId && collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`))
|
||||
}
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
export async function getRFC(projectId, slug) {
|
||||
export async function getRFC(projectId, slug, collectionId) {
|
||||
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||
if (slug === undefined) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
||||
}
|
||||
if (collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`))
|
||||
}
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
// §22 S2: the collections of a project (for the /p/<project>/ directory). The
|
||||
// response also carries a `viewer` block (§22 S4 capability flags:
|
||||
// can_create_collection, can_invite, role) driving the role-aware directory.
|
||||
export async function listCollections(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
|
||||
}
|
||||
|
||||
// §22 S4: one collection's settings + the viewer's collection-level
|
||||
// capabilities (viewer.can_contribute / can_invite / role) — drives the
|
||||
// propose-first empty state and the collection invite control.
|
||||
export async function getCollection(projectId, collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}`))
|
||||
}
|
||||
|
||||
// §22 S4 (C.2): the scope-role membership surface. An Owner grants
|
||||
// {owner, contributor} at the project, or at one collection (collectionId set),
|
||||
// to an existing account by email; the backend writes the membership row and
|
||||
// §15-notifies the grantee.
|
||||
export async function listScopeMembers(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/members`))
|
||||
}
|
||||
|
||||
export async function grantScopeMember(projectId, { email, role, collectionId }) {
|
||||
const res = await fetch(`/api/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, role, collection_id: collectionId || null }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function revokeScopeMember(projectId, userId, collectionId) {
|
||||
const qs = collectionId ? `?collection_id=${encodeURIComponent(collectionId)}` : ''
|
||||
const res = await fetch(`/api/projects/${projectId}/members/${userId}${qs}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22 S2: create-collection (deployment owner/admin). The backend commits a
|
||||
// .collection.yaml and re-mirrors the registry, returning the new collection.
|
||||
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {
|
||||
const res = await fetch(`/api/projects/${projectId}/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
collection_id: collectionId,
|
||||
type,
|
||||
name: name || null,
|
||||
visibility: visibility || null,
|
||||
initial_state: initialState || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listProposals(projectId) {
|
||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
@@ -209,8 +289,10 @@ export async function getProposal(prNumber) {
|
||||
|
||||
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||
// given; else the default-project compat path.
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
|
||||
const url = (projectId && collectionId)
|
||||
? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose`
|
||||
: (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose')
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -285,6 +367,46 @@ export async function declineContributionRequest(slug, requestId) {
|
||||
))
|
||||
}
|
||||
|
||||
// §22.8: request-to-join a scope + the cross-collection inbox.
|
||||
// `joinTarget` feeds the request modal (scope name, the viewer's eligibility);
|
||||
// `requestJoin` submits the ask (desired role + optional message); accept/decline
|
||||
// are the scope Owner's inbox actions (accept writes the membership row).
|
||||
export async function joinTarget(scopeType, scopeId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function requestJoin(scopeType, scopeId, { role, message }) {
|
||||
const res = await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role, message: message || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptJoinRequest(scopeType, scopeId, requestId, role) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
export async function declineJoinRequest(scopeType, scopeId, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`,
|
||||
{ method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { listRFCs, listProposals } from '../api'
|
||||
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
|
||||
import { listRFCs, listProposals, getCollection } from '../api'
|
||||
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||
|
||||
const STATE_CHIPS = [
|
||||
{ id: 'super-draft', label: 'Super-draft' },
|
||||
@@ -26,17 +27,45 @@ const SORT_OPTIONS = [
|
||||
export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
const [rfcs, setRfcs] = useState([])
|
||||
const [proposals, setProposals] = useState([])
|
||||
// §22 S4: the viewer's contribute capability in this collection, driving the
|
||||
// propose-first empty state (C3.5) and the propose control. `null` until the
|
||||
// collection's caps load; we fall back to "any authenticated viewer" so the
|
||||
// common case doesn't flicker, then refine.
|
||||
const [canContribute, setCanContribute] = useState(null)
|
||||
// §22.8: when the viewer can't contribute here but holds no role reaching the
|
||||
// collection, offer "Request to join" in the footer.
|
||||
const [canRequestJoin, setCanRequestJoin] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
// §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
|
||||
// collection, read from the API. Defaults to the generic "RFC" until loaded.
|
||||
const [entryNoun, setEntryNoun] = useState('RFC')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState('recent')
|
||||
const [activeChips, setActiveChips] = useState(new Set())
|
||||
const [pendingOpen, setPendingOpen] = useState(true)
|
||||
const { slug, prNumber } = useParams()
|
||||
const pid = useProjectId()
|
||||
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||
// route segment, else the project's default collection).
|
||||
const cid = useCollectionId()
|
||||
|
||||
useEffect(() => {
|
||||
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version, pid])
|
||||
setCanContribute(null)
|
||||
setCanRequestJoin(false)
|
||||
getCollection(pid, cid)
|
||||
.then(c => {
|
||||
setCanContribute(!!c?.viewer?.can_contribute)
|
||||
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
||||
setEntryNoun(c?.entry_noun || 'RFC')
|
||||
})
|
||||
.catch(() => setCanContribute(false))
|
||||
}, [version, pid, cid])
|
||||
|
||||
// While caps load, fall back to "any authenticated viewer" so the propose
|
||||
// affordance on the common (default-collection) case doesn't flash off.
|
||||
const mayPropose = canContribute === null ? !!viewer : canContribute
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
@@ -95,7 +124,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{rfcs.length === 0
|
||||
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
|
||||
// C3.5: a contributor sees a propose-first call to action; a
|
||||
// granted viewer without contribute rights sees a bare empty
|
||||
// state; an anonymous reader sees the read-only note (the footer
|
||||
// carries the sign-in prompt).
|
||||
? (mayPropose
|
||||
? 'No entries yet. Propose the first entry below.'
|
||||
: 'No entries in the catalog yet.')
|
||||
: 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -105,7 +140,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
return (
|
||||
<Link
|
||||
key={r.slug}
|
||||
to={entryPath(pid, r.slug)}
|
||||
to={entryPath(pid, r.slug, cid)}
|
||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||
>
|
||||
<div className="row-top">
|
||||
@@ -133,7 +168,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{proposals.map(p => (
|
||||
<Link
|
||||
key={p.pr_number}
|
||||
to={proposalPath(pid, p.pr_number)}
|
||||
to={proposalPath(pid, p.pr_number, cid)}
|
||||
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
||||
>
|
||||
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
||||
@@ -151,13 +186,30 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
|
||||
<div className="catalog-footer">
|
||||
{viewer ? (
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
||||
// §22 S4: the propose control is offered only when the viewer may
|
||||
// contribute to *this* collection (the scope-role gate); a granted
|
||||
// viewer with no contribute right in this collection sees nothing.
|
||||
mayPropose ? (
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
|
||||
) : (
|
||||
// §22.8: signed in but no contribute right here — offer to join.
|
||||
canRequestJoin && (
|
||||
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
||||
Sign in to propose <span className="beta-chip">Beta</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="collection"
|
||||
scopeId={cid}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// §22 S2 — the project collection directory at `/p/<project>/`. Lists the
|
||||
// project's caller-visible collections as cards linking into each collection's
|
||||
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
|
||||
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
|
||||
// single-collection UX, preserved).
|
||||
//
|
||||
// §22 S4 — role-aware empty states + owner controls. The list response carries
|
||||
// a `viewer` capability block; the directory reads it to render:
|
||||
// * C3.3: a project Owner sees a "Create your first collection" CTA (and a
|
||||
// "New collection" control when the directory is non-empty), opening the
|
||||
// create-collection modal (choose a type + id + visibility).
|
||||
// * C3.4: a contributor without create rights sees the empty directory with
|
||||
// no create action.
|
||||
// * C.2: an Owner with membership-management reach sees a "Members" control
|
||||
// opening the scope-role invitation modal.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { listCollections } from '../api'
|
||||
import { collectionHome } from '../lib/entryPaths'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
import CreateCollectionModal from './CreateCollectionModal.jsx'
|
||||
import ScopeMembersModal from './ScopeMembersModal.jsx'
|
||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||
|
||||
export default function CollectionDirectory({ projectId }) {
|
||||
const [cols, setCols] = useState(null)
|
||||
const [viewer, setViewer] = useState(null)
|
||||
const [version, setVersion] = useState(0)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [membersOpen, setMembersOpen] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
listCollections(projectId)
|
||||
.then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } })
|
||||
.catch(() => { if (live) { setCols([]); setViewer(null) } })
|
||||
return () => { live = false }
|
||||
}, [projectId, version])
|
||||
|
||||
if (cols === null) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
// C3.7/C3.8: a single visible collection skips the directory.
|
||||
if (cols.length === 1) {
|
||||
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||
}
|
||||
|
||||
const canCreate = !!viewer?.can_create_collection
|
||||
const canInvite = !!viewer?.can_invite
|
||||
const canRequestJoin = !!viewer?.can_request_join
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<div className="directory-head">
|
||||
<h1>Collections</h1>
|
||||
<div className="directory-actions">
|
||||
{canInvite && (
|
||||
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
|
||||
)}
|
||||
{canRequestJoin && (
|
||||
<button className="btn-link" onClick={() => setJoinOpen(true)}>Request to join</button>
|
||||
)}
|
||||
{canCreate && cols.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cols.length === 0 ? (
|
||||
// C3.3 / C3.4: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first collection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{cols.map(c => (
|
||||
<li key={c.id} className="directory-card">
|
||||
<Link to={collectionHome(projectId, c.id)}>
|
||||
<span className="directory-card-name">{c.name || c.id}</span>
|
||||
<span className="directory-card-type">{entryNoun(c.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateCollectionModal
|
||||
projectId={projectId}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }}
|
||||
/>
|
||||
)}
|
||||
{membersOpen && (
|
||||
<ScopeMembersModal
|
||||
projectId={projectId}
|
||||
collections={cols}
|
||||
viewer={viewer}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="project"
|
||||
scopeId={projectId}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
|
||||
let mockItems = []
|
||||
let mockViewer = null
|
||||
vi.mock('../api', () => ({
|
||||
listCollections: vi.fn(async () => ({ items: mockItems, viewer: mockViewer })),
|
||||
// JoinRequestModal (rendered behind the affordance) pulls these in.
|
||||
joinTarget: vi.fn(async () => ({ eligible: true, name: 'Ohm', already_requested: false })),
|
||||
requestJoin: vi.fn(async () => ({ status: 'pending' })),
|
||||
}))
|
||||
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||
|
||||
beforeEach(() => { mockItems = []; mockViewer = null })
|
||||
|
||||
function renderDir(items, viewer = null) {
|
||||
mockItems = items
|
||||
mockViewer = viewer
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||
<Routes>
|
||||
<Route path="/p/:projectId/*" element={<CollectionDirectory projectId="ohm" />} />
|
||||
<Route path="/p/:projectId/c/:collectionId/*" element={<div>collection home</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CollectionDirectory', () => {
|
||||
it('lists a card per collection with the type-driven noun + link when 2+', async () => {
|
||||
renderDir([
|
||||
{ id: 'default', name: 'Model', type: 'document' },
|
||||
{ id: 'features', name: 'Scenarios', type: 'bdd' },
|
||||
])
|
||||
await waitFor(() => expect(screen.getByText('Scenarios')).toBeInTheDocument())
|
||||
expect(screen.getByText('Model').closest('a')).toHaveAttribute('href', '/p/ohm/c/default/')
|
||||
expect(screen.getByText('Scenarios').closest('a')).toHaveAttribute('href', '/p/ohm/c/features/')
|
||||
expect(screen.getByText('RFCs')).toBeInTheDocument() // document → RFCs
|
||||
expect(screen.getByText('Features')).toBeInTheDocument() // bdd → Features (type noun)
|
||||
})
|
||||
|
||||
it('redirects into the sole collection when exactly one is visible', async () => {
|
||||
renderDir([{ id: 'default', name: 'Model', type: 'document' }])
|
||||
await waitFor(() => expect(screen.getByText('collection home')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows a minimal empty note when there are no collections', async () => {
|
||||
renderDir([])
|
||||
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('offers "Request to join" when the viewer holds no role (§22.8)', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ can_request_join: true },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('Request to join')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('hides "Request to join" from a member', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ role: 'owner', can_request_join: false },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Request to join')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// CreateCollectionModal.jsx — §22 S2 endpoint, §22 S4 UI. The create-collection
|
||||
// form the project directory's "Create your first collection" / "New
|
||||
// collection" control opens. S2 shipped the POST
|
||||
// /api/projects/:id/collections endpoint but left it UI-less; S4 surfaces it,
|
||||
// gated on the viewer's `can_create_collection` capability.
|
||||
//
|
||||
// The form lets the Owner choose an id (a slug, not "default"), a type, an
|
||||
// optional display name, and an optional visibility (defaulting to the
|
||||
// project's). The backend commits a `.collection.yaml`, re-mirrors the
|
||||
// registry, and returns the new collection; on success the caller refreshes
|
||||
// the directory.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createCollection } from '../api'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'document', label: 'Document — prose RFCs' },
|
||||
{ value: 'specification', label: 'Specification' },
|
||||
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: '', label: 'Inherit from project' },
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'unlisted', label: 'Unlisted (link-only)' },
|
||||
{ value: 'gated', label: 'Gated (hidden from the public)' },
|
||||
]
|
||||
|
||||
export default function CreateCollectionModal({ projectId, onClose, onCreated }) {
|
||||
const [collectionId, setCollectionId] = useState('')
|
||||
const [type, setType] = useState('document')
|
||||
const [name, setName] = useState('')
|
||||
const [visibility, setVisibility] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const cid = collectionId.trim().toLowerCase()
|
||||
if (!cid) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const col = await createCollection(projectId, {
|
||||
collectionId: cid,
|
||||
type,
|
||||
name: name.trim() || null,
|
||||
visibility: visibility || null,
|
||||
})
|
||||
onCreated?.(col)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create the collection.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New collection</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="col-id">Collection id</label>
|
||||
<input
|
||||
id="col-id"
|
||||
value={collectionId}
|
||||
onChange={e => setCollectionId(e.target.value)}
|
||||
placeholder="e.g. model, features"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="col-type" style={{ marginTop: 10 }}>Type</label>
|
||||
<select id="col-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="col-name" style={{ marginTop: 10 }}>Display name (optional)</label>
|
||||
<input
|
||||
id="col-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. The Model"
|
||||
/>
|
||||
<label htmlFor="col-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="col-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create collection'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// CreateProjectModal.jsx — §22 S5. The create-project form the deployment
|
||||
// directory's "Create your first project" / "New project" control opens, gated
|
||||
// on the viewer's `can_create_project` capability (a global Owner action).
|
||||
//
|
||||
// The form lets the Owner choose a project id (a slug, not "default"), a display
|
||||
// name, the type of its initial (default) collection, a visibility, and an
|
||||
// optional content-repo name (defaulting to `<id>-content`). The backend
|
||||
// provisions the Gitea content repo, commits the project to projects.yaml,
|
||||
// re-mirrors the registry, and returns the new project; on success the caller
|
||||
// refreshes the directory (and the N=1 redirect then lands in the new project).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createProject } from '../api'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'document', label: 'Document — prose RFCs' },
|
||||
{ value: 'specification', label: 'Specification' },
|
||||
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'unlisted', label: 'Unlisted (link-only)' },
|
||||
{ value: 'gated', label: 'Gated (hidden from the public)' },
|
||||
]
|
||||
|
||||
export default function CreateProjectModal({ onClose, onCreated }) {
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState('document')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [contentRepo, setContentRepo] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const pid = projectId.trim().toLowerCase()
|
||||
if (!pid || !name.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const project = await createProject({
|
||||
projectId: pid,
|
||||
name: name.trim(),
|
||||
type,
|
||||
visibility,
|
||||
contentRepo: contentRepo.trim() || null,
|
||||
})
|
||||
onCreated?.(project)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create the project.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New project</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="proj-id">Project id</label>
|
||||
<input
|
||||
id="proj-id"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
placeholder="e.g. ohm, acme"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-name" style={{ marginTop: 10 }}>Display name</label>
|
||||
<input
|
||||
id="proj-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Open Human Model"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-type" style={{ marginTop: 10 }}>Initial collection type</label>
|
||||
<select id="proj-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="proj-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-repo" style={{ marginTop: 10 }}>Content repo (optional)</label>
|
||||
<input
|
||||
id="proj-repo"
|
||||
value={contentRepo}
|
||||
onChange={e => setContentRepo(e.target.value)}
|
||||
placeholder="defaults to <id>-content"
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,71 @@
|
||||
// §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible
|
||||
// projects (from /api/deployment) as cards linking into each project's
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
|
||||
// are visible; the N=1 case redirects straight into the single project so
|
||||
// OHM's "land in the corpus" UX is preserved.
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when the N=1
|
||||
// land-in-corpus redirect does not apply (2+ visible projects, or no readable
|
||||
// default) so OHM's "land in the corpus" UX is preserved.
|
||||
//
|
||||
// §22 S5 — role-aware empty states + the global-Owner create-project action.
|
||||
// The deployment payload carries a `viewer` block; the directory reads it to
|
||||
// render:
|
||||
// * C3.1: a global Owner sees a "Create your first project" CTA (and a "New
|
||||
// project" control when the directory is non-empty), opening the
|
||||
// create-project modal (choose id, name, type, visibility).
|
||||
// * C3.2: a non-owner (a granted account with no roles) sees the empty
|
||||
// directory with no create action and a note that nothing is shared yet.
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
import CreateProjectModal from './CreateProjectModal.jsx'
|
||||
|
||||
export default function Directory() {
|
||||
const { name, tagline, projects } = useDeployment()
|
||||
const { name, tagline, projects, viewer, refresh } = useDeployment()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canCreate = !!viewer?.can_create_project
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-head">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-actions">
|
||||
{canCreate && projects.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New project</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tagline && <p className="directory-tagline">{tagline}</p>}
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{projects.length === 0 ? (
|
||||
// C3.1 / C3.2: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No projects yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">Nothing has been shared with you yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateProjectModal
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); refresh?.() }}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
|
||||
// from) resolve the deployment hook and api without a live fetch.
|
||||
let mockProjects = []
|
||||
vi.mock('../api', () => ({ getProject: vi.fn() }))
|
||||
let mockDeployment = {}
|
||||
vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() }))
|
||||
vi.mock('../context/DeploymentProvider', () => ({
|
||||
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
|
||||
useDeployment: () => mockDeployment,
|
||||
}))
|
||||
import Directory from './Directory.jsx'
|
||||
|
||||
function renderDir(projects) {
|
||||
mockProjects = projects
|
||||
function renderDir(overrides = {}) {
|
||||
mockDeployment = {
|
||||
name: 'Wiggleverse',
|
||||
tagline: 'a directory of projects',
|
||||
projects: [],
|
||||
viewer: null,
|
||||
defaultProjectId: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
return render(<MemoryRouter><Directory /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('Directory', () => {
|
||||
it('renders a card per visible project with the type-driven noun + link', () => {
|
||||
renderDir([
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
])
|
||||
renderDir({
|
||||
projects: [
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
],
|
||||
})
|
||||
const ohm = screen.getByText('Open Human Model').closest('a')
|
||||
expect(ohm).toHaveAttribute('href', '/p/ohm/')
|
||||
const ecomm = screen.getByText('Ecomm').closest('a')
|
||||
@@ -33,13 +43,41 @@ describe('Directory', () => {
|
||||
})
|
||||
|
||||
it('renders the deployment name and tagline', () => {
|
||||
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }])
|
||||
renderDir({ projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
expect(screen.getByText('a directory of projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty list without crashing when no projects are visible', () => {
|
||||
renderDir([])
|
||||
renderDir({ projects: [] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// §22 S5 — role-aware empty states (C3.1 / C3.2).
|
||||
it('shows a "Create your first project" CTA to a global Owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: true } })
|
||||
expect(screen.getByText('Create your first project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a "nothing shared with you yet" note to a non-owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: false } })
|
||||
expect(screen.getByText(/Nothing has been shared with you yet/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create your first project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a "New project" control to an Owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: true },
|
||||
})
|
||||
expect(screen.getByText('New project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer a create control to a non-owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: false },
|
||||
})
|
||||
expect(screen.queryByText('New project')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
acceptJoinRequest,
|
||||
declineContributionRequest,
|
||||
declineJoinRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
@@ -228,11 +230,76 @@ function ContributionRequestRow({ item, onMarkRead }) {
|
||||
)
|
||||
}
|
||||
|
||||
// §22.8: the cross-collection inbox row — a request to join a scope, surfaced
|
||||
// to that scope's Owners across the subtree. Mirrors ContributionRequestRow:
|
||||
// the requester's role + message inline, an Accept/Decline pair that writes (or
|
||||
// refuses) the membership grant.
|
||||
function JoinRequestRow({ item, onMarkRead }) {
|
||||
const unread = !item.read_at
|
||||
const x = item.extras || {}
|
||||
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function act(accept) {
|
||||
if (busy || outcome) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (accept) await acceptJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
else await declineJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
setOutcome(accept ? 'accepted' : 'declined')
|
||||
await onMarkRead(item)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = x.requested_role === 'owner' ? 'Owner' : 'RFC Contributor'
|
||||
|
||||
return (
|
||||
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
|
||||
<div className="inbox-row-main">
|
||||
<span className="inbox-unread-dot" aria-hidden />
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="inbox-request-detail">
|
||||
<p><strong>Requested role:</strong> {roleLabel}</p>
|
||||
{x.message && <p><strong>Message:</strong> {x.message}</p>}
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
{outcome ? (
|
||||
<p className="inbox-request-outcome muted">
|
||||
{outcome === 'accepted'
|
||||
? `Accepted — they're now a member as ${roleLabel}.`
|
||||
: 'Declined.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="inbox-request-actions">
|
||||
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
const pid = useProjectId()
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
if (item.event_kind === 'join_request_on_scope') {
|
||||
return <JoinRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item, pid)
|
||||
const handle = async () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// JoinRequestModal.jsx — §22.8: request-to-join a scope.
|
||||
//
|
||||
// A gated project or collection is invisible to non-members, so a user who
|
||||
// knows it exists asks to join — naming a desired role ({owner, contributor})
|
||||
// and an optional message. The request is recorded and fanned out to the
|
||||
// scope's Owners across the subtree (the cross-collection inbox), who accept
|
||||
// (writing the membership row) or decline.
|
||||
//
|
||||
// Sibling of ScopeMembersModal (the Owner's grant surface) and the per-RFC
|
||||
// ContributeRequestForm (the per-entry ask). Opens from the "Request to join"
|
||||
// affordance the directory / collection view shows when the viewer's
|
||||
// `can_request_join` flag is set.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { joinTarget, requestJoin } from '../api'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'RFC Contributor — propose entries in the scope' },
|
||||
{ value: 'owner', label: 'Owner — administer the scope and its membership' },
|
||||
]
|
||||
|
||||
export default function JoinRequestModal({ scopeType, scopeId, scopeName, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [role, setRole] = useState('contributor')
|
||||
const [message, setMessage] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
joinTarget(scopeType, scopeId)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(e => { if (live) setError(e.message || 'Could not load this scope.') })
|
||||
return () => { live = false }
|
||||
}, [scopeType, scopeId])
|
||||
|
||||
const label = (target && target.name) || scopeName || scopeId
|
||||
const where = scopeType === 'collection' ? 'collection' : 'project'
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestJoin(scopeType, scopeId, { role, message: message.trim() || null })
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to send the request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Request to join</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{done ? (
|
||||
<p>
|
||||
Your request to join {where} <strong>{label}</strong> has been sent to
|
||||
its Owners. You'll get an inbox notification when it's decided.
|
||||
</p>
|
||||
) : target && !target.eligible ? (
|
||||
<p style={{ color: '#666' }}>
|
||||
{target.already_requested
|
||||
? `You already have a pending request to join ${where} ${label}.`
|
||||
: (target.reason || 'You cannot request to join this scope.')}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="invitations-form">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Ask the Owners of {where} <strong>{label}</strong> for a role. An{' '}
|
||||
<strong>RFC Contributor</strong> may propose entries; an{' '}
|
||||
<strong>Owner</strong> administers the scope.
|
||||
</p>
|
||||
<label htmlFor="join-role">Role</label>
|
||||
<select id="join-role" value={role} onChange={e => setRole(e.target.value)}>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="join-message" style={{ marginTop: 10 }}>
|
||||
Message <span style={{ color: '#999' }}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="join-message"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Who you are and why you'd like to join."
|
||||
maxLength={4000}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>
|
||||
{done ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// success navigates the proposer to the pending-idea view per §9.3.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { proposeRFC, suggestTags } from '../api'
|
||||
import { proposeRFC, suggestTags, getCollection } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
// How long the draft must sit unchanged before we ask for suggestions —
|
||||
@@ -28,11 +28,14 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId, collectionId }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
const [title, setTitle] = useState(initialTitle)
|
||||
// §22.4a: the type-driven noun for the active collection ("RFC" | "Spec" |
|
||||
// "Feature"), read from the API. Defaults to "RFC" until the collection loads.
|
||||
const [entryNoun, setEntryNoun] = useState('RFC')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
@@ -53,6 +56,17 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
if (!slugEdited) setSlug(slugify(title))
|
||||
}, [title, slugEdited])
|
||||
|
||||
// §22.4a: resolve the active collection's entry noun so the modal chrome
|
||||
// names entries per the collection type. Best-effort; falls back to "RFC".
|
||||
useEffect(() => {
|
||||
if (!projectId || !collectionId) return
|
||||
let live = true
|
||||
getCollection(projectId, collectionId)
|
||||
.then(c => { if (live) setEntryNoun(c?.entry_noun || 'RFC') })
|
||||
.catch(() => {})
|
||||
return () => { live = false }
|
||||
}, [projectId, collectionId])
|
||||
|
||||
// #27: debounced tag-suggestion fetch. Fires after the draft sits
|
||||
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
|
||||
// on (a title). A stale-response guard keeps an earlier in-flight
|
||||
@@ -98,6 +112,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
collectionId,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
@@ -115,7 +130,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Propose a New RFC</h2>
|
||||
<h2>Propose a New {entryNoun}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -129,7 +144,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<p className="field-help">The word or topic this RFC will define.</p>
|
||||
<p className="field-help">The word or topic this {entryNoun} will define.</p>
|
||||
|
||||
<label htmlFor="propose-slug">Slug</label>
|
||||
<input
|
||||
@@ -141,22 +156,22 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
/>
|
||||
<p className="field-help">Auto-derived from the title; edit if needed.</p>
|
||||
|
||||
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
|
||||
<label htmlFor="propose-pitch">Why is this {entryNoun} needed?</label>
|
||||
<textarea
|
||||
id="propose-pitch"
|
||||
value={pitch}
|
||||
onChange={e => setPitch(e.target.value)}
|
||||
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
|
||||
placeholder={`One or two paragraphs answering 'why this ${entryNoun} is needed.'`}
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
||||
<label htmlFor="propose-use-case">What will you be using this {entryNoun} for? (optional)</label>
|
||||
<textarea
|
||||
id="propose-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
||||
placeholder={`The concrete thing you intend to build or do with this ${entryNoun}. Optional, but it helps ground the work.`}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="field-help">
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// ScopeMembersModal.jsx — §22 S4 (C.2): the Owner-only scope-role invitation
|
||||
// surface, sibling of the per-RFC InvitationsModal.
|
||||
//
|
||||
// An Owner grants {owner, contributor} at a scope their reach covers — the
|
||||
// project, or a single collection within it — to an existing account by email.
|
||||
// The grant writes the membership row immediately and §15-notifies the grantee
|
||||
// (no accept round-trip). The modal opens from the project directory's
|
||||
// owner-only "Members" control.
|
||||
//
|
||||
// Two stacked sections, mirroring InvitationsModal:
|
||||
//
|
||||
// 1. "Grant a role" — email + role picker (Owner | RFC Contributor) + scope
|
||||
// picker (the project, or one collection). The scope options are bounded
|
||||
// to what the inviter may grant: a project Owner may pick the project or
|
||||
// any collection; a collection-only Owner sees only their collection(s),
|
||||
// never the project (C.2.3). There is no "grant at the project but exclude
|
||||
// a child" option (C.2.5) — only role and a single scope.
|
||||
//
|
||||
// 2. "Current members" — the project subtree's grants, with revoke buttons.
|
||||
//
|
||||
// RFC Contributors never see the trigger that opens this modal (C.2.4); the
|
||||
// directory gates it on the viewer's `can_invite` flag.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { grantScopeMember, listScopeMembers, revokeScopeMember } from '../api'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'RFC Contributor — may propose entries in the scope' },
|
||||
{ value: 'owner', label: 'Owner — administers the scope and its membership' },
|
||||
]
|
||||
|
||||
export default function ScopeMembersModal({ projectId, collections, viewer, onClose }) {
|
||||
const [members, setMembers] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('contributor')
|
||||
const [scope, setScope] = useState('project') // 'project' | a collection id
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(null)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(null)
|
||||
const [revokingKey, setRevokingKey] = useState(null)
|
||||
|
||||
// The project-scope option is offered only to an inviter whose reach covers
|
||||
// the project (can_invite at project level); a collection-only Owner gets the
|
||||
// collection options alone (C.2.3). Each collection is offered only when the
|
||||
// viewer may invite there (its own `can_invite` flag).
|
||||
const canInviteAtProject = !!viewer?.can_invite
|
||||
const scopeOptions = useMemo(() => {
|
||||
const opts = []
|
||||
if (canInviteAtProject) {
|
||||
opts.push({ value: 'project', label: 'The whole project (every collection)' })
|
||||
}
|
||||
for (const c of collections || []) {
|
||||
if (c.viewer_can_invite || canInviteAtProject) {
|
||||
opts.push({ value: c.id, label: `Collection — ${c.name || c.id}` })
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}, [collections, canInviteAtProject])
|
||||
|
||||
// Default the scope picker to the first allowed option.
|
||||
useEffect(() => {
|
||||
if (scopeOptions.length && !scopeOptions.some(o => o.value === scope)) {
|
||||
setScope(scopeOptions[0].value)
|
||||
}
|
||||
}, [scopeOptions]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function refresh() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const r = await listScopeMembers(projectId)
|
||||
setMembers(r.items || [])
|
||||
} catch (e) {
|
||||
// A collection-only Owner is not authorized for the project-wide list;
|
||||
// that is expected — they manage their collection via grant/revoke.
|
||||
setMembers([])
|
||||
setLoadError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [projectId])
|
||||
|
||||
async function handleGrant(e) {
|
||||
e.preventDefault()
|
||||
const addr = email.trim()
|
||||
if (!addr) return
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(null)
|
||||
try {
|
||||
await grantScopeMember(projectId, {
|
||||
email: addr,
|
||||
role,
|
||||
collectionId: scope === 'project' ? null : scope,
|
||||
})
|
||||
const where = scope === 'project' ? 'the project' : `collection ${scope}`
|
||||
setSubmitSuccess(`Granted ${addr} on ${where}.`)
|
||||
setEmail('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to grant the role.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(m) {
|
||||
const collectionId = m.scope_type === 'collection' ? m.scope_id : null
|
||||
const key = `${m.scope_type}:${m.scope_id}:${m.user_id}`
|
||||
setRevokingKey(key)
|
||||
try {
|
||||
await revokeScopeMember(projectId, m.user_id, collectionId)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to revoke.')
|
||||
} finally {
|
||||
setRevokingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(m) {
|
||||
if (m.scope_type === 'project') return 'Project'
|
||||
return `Collection · ${m.collection_name || m.scope_id}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Members</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Grant collaborators a role at this project or a single collection
|
||||
within it. An <strong>RFC Contributor</strong> may propose entries
|
||||
in the scope; an <strong>Owner</strong> administers the scope and
|
||||
invites others. A grant at the project covers every collection.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleGrant} className="invitations-form" style={{ marginTop: 16 }}>
|
||||
<label htmlFor="grant-email">Email</label>
|
||||
<input
|
||||
id="grant-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="grant-role" style={{ marginTop: 10 }}>Role</label>
|
||||
<select id="grant-role" value={role} onChange={e => setRole(e.target.value)}>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="grant-scope" style={{ marginTop: 10 }}>Scope</label>
|
||||
<select id="grant-scope" value={scope} onChange={e => setScope(e.target.value)}>
|
||||
{scopeOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting || !scopeOptions.length}>
|
||||
{submitting ? 'Granting…' : 'Grant role'}
|
||||
</button>
|
||||
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
|
||||
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr style={{ margin: '20px 0' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 8px' }}>Current members</h3>
|
||||
{members === null && <div>Loading…</div>}
|
||||
{members !== null && members.length === 0 && (
|
||||
<div style={{ color: '#666' }}>
|
||||
{loadError ? 'Membership list is available to project Owners.' : 'No scope roles granted yet.'}
|
||||
</div>
|
||||
)}
|
||||
{members !== null && members.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Member</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Scope</th>
|
||||
<th style={{ padding: 4 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(m => {
|
||||
const key = `${m.scope_type}:${m.scope_id}:${m.user_id}`
|
||||
return (
|
||||
<tr key={key} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 4 }}>
|
||||
{m.display_name || m.email}
|
||||
{m.permission_state !== 'granted' && (
|
||||
<span style={{ color: '#a60', marginLeft: 6 }}>(pending)</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 4 }}>{m.role === 'owner' ? 'Owner' : 'RFC Contributor'}</td>
|
||||
<td style={{ padding: 4, color: '#666' }}>{scopeLabel(m)}</td>
|
||||
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleRevoke(m)}
|
||||
disabled={revokingKey === key}
|
||||
>
|
||||
{revokingKey === key ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,30 @@
|
||||
// VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to
|
||||
// the whole tree:
|
||||
//
|
||||
// { name, tagline, defaultProjectId, projects[], loading }
|
||||
// { name, tagline, defaultProjectId, defaultProjectReadable, projects[],
|
||||
// viewer, loading, refresh }
|
||||
//
|
||||
// `projects` is the per-caller-visible set (§22.5: public + member-gated;
|
||||
// unlisted omitted). `defaultProjectId` is the corpus-served project the
|
||||
// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back
|
||||
// to the neutral brandTitle() default ('RFC') — never a hardcoded deployment
|
||||
// name.
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
// M3-frontend guard keys on. `defaultProjectReadable` (§22 S5) is whether that
|
||||
// redirect target is reachable by this viewer — the deployment landing only
|
||||
// bounces into it when true, else it renders the role-aware directory empty
|
||||
// state. `viewer` carries the §22 S5 capability flags (`can_create_project`).
|
||||
// `refresh` re-fetches the payload (e.g. after creating a project). During the
|
||||
// pre-fetch paint, consumers fall back to the neutral brandTitle() default
|
||||
// ('RFC') — never a hardcoded deployment name.
|
||||
import { createContext, useContext, useCallback, useEffect, useState } from 'react'
|
||||
import { getDeployment } from '../api'
|
||||
|
||||
const DeploymentContext = createContext({
|
||||
name: '',
|
||||
tagline: '',
|
||||
defaultProjectId: null,
|
||||
defaultProjectReadable: false,
|
||||
projects: [],
|
||||
viewer: null,
|
||||
loading: true,
|
||||
refresh: () => {},
|
||||
})
|
||||
|
||||
export function useDeployment() {
|
||||
@@ -29,33 +37,43 @@ export function DeploymentProvider({ children }) {
|
||||
name: '',
|
||||
tagline: '',
|
||||
defaultProjectId: null,
|
||||
defaultProjectReadable: false,
|
||||
projects: [],
|
||||
viewer: null,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getDeployment()
|
||||
const load = useCallback((cancelledRef) => {
|
||||
return getDeployment()
|
||||
.then(d => {
|
||||
if (cancelled) return
|
||||
if (cancelledRef?.cancelled) return
|
||||
setState({
|
||||
name: d.name || '',
|
||||
tagline: d.tagline || '',
|
||||
defaultProjectId: d.default_project_id || null,
|
||||
defaultProjectReadable: !!d.default_project_readable,
|
||||
projects: Array.isArray(d.projects) ? d.projects : [],
|
||||
viewer: d.viewer || null,
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed deployment fetch must not wedge the app: fall back to the
|
||||
// neutral brand and an empty directory so the shell still paints.
|
||||
if (!cancelled) setState(s => ({ ...s, loading: false }))
|
||||
if (!cancelledRef?.cancelled) setState(s => ({ ...s, loading: false }))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ref = { cancelled: false }
|
||||
load(ref)
|
||||
return () => { ref.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const refresh = useCallback(() => load(), [load])
|
||||
|
||||
return (
|
||||
<DeploymentContext.Provider value={state}>
|
||||
<DeploymentContext.Provider value={{ ...state, refresh }}>
|
||||
{children}
|
||||
</DeploymentContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
// §22.10 — project-scoped path builders. After M3 every entry/proposal link
|
||||
// lives under `/p/<project>/…`. Until Plan B serves multiple corpora, that
|
||||
// project id is the deployment's corpus-served default for chrome surfaces, or
|
||||
// the contextual project when a component renders inside a project subtree
|
||||
// (ProjectContext). Components build links via these helpers so the later
|
||||
// per-project-serving slice flips them in one place.
|
||||
// §22 three-tier — project + collection-scoped path builders. The canonical
|
||||
// entry route now carries the collection segment: `/p/<project>/c/<collection>/…`.
|
||||
// In S1 each project has a single (default) collection and serving stays
|
||||
// project-scoped, so the builders emit the default collection segment; the
|
||||
// collection-aware link layer (named collections) lands in S2. Components build
|
||||
// links via these helpers so that flip happens in one place.
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useProject } from '../components/ProjectLayout.jsx'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
|
||||
export function entryPath(pid, slug) {
|
||||
return `/p/${pid}/e/${slug}`
|
||||
// The default collection id (migration 029 seeds one per project at this id).
|
||||
export const DEFAULT_COLLECTION = 'default'
|
||||
|
||||
export function entryPath(pid, slug, cid = DEFAULT_COLLECTION) {
|
||||
return `/p/${pid}/c/${cid}/e/${slug}`
|
||||
}
|
||||
|
||||
export function entryPrPath(pid, slug, prNumber) {
|
||||
return `/p/${pid}/e/${slug}/pr/${prNumber}`
|
||||
export function entryPrPath(pid, slug, prNumber, cid = DEFAULT_COLLECTION) {
|
||||
return `/p/${pid}/c/${cid}/e/${slug}/pr/${prNumber}`
|
||||
}
|
||||
|
||||
export function proposalPath(pid, prNumber) {
|
||||
return `/p/${pid}/proposals/${prNumber}`
|
||||
export function proposalPath(pid, prNumber, cid = DEFAULT_COLLECTION) {
|
||||
return `/p/${pid}/c/${cid}/proposals/${prNumber}`
|
||||
}
|
||||
|
||||
export function collectionHome(pid, cid = DEFAULT_COLLECTION) {
|
||||
return `/p/${pid}/c/${cid}/`
|
||||
}
|
||||
|
||||
export function projectHome(pid) {
|
||||
@@ -30,3 +38,10 @@ export function useProjectId() {
|
||||
const { defaultProjectId } = useDeployment()
|
||||
return (ctx && ctx.projectId) || defaultProjectId
|
||||
}
|
||||
|
||||
// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/`
|
||||
// route segment when present, else the project's default collection.
|
||||
export function useCollectionId() {
|
||||
const { collectionId } = useParams()
|
||||
return collectionId || DEFAULT_COLLECTION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// §22 three-tier — the canonical corpus path now carries the /c/<collection>/
|
||||
// segment. These builders default to the project's `default` collection (S1).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
entryPath, entryPrPath, proposalPath, collectionHome, projectHome, DEFAULT_COLLECTION,
|
||||
} from './entryPaths.js'
|
||||
|
||||
describe('entryPaths — collection-scoped corpus URLs', () => {
|
||||
it('entryPath defaults to the default collection segment', () => {
|
||||
expect(entryPath('ohm', 'human')).toBe('/p/ohm/c/default/e/human')
|
||||
})
|
||||
|
||||
it('entryPath honours an explicit collection id', () => {
|
||||
expect(entryPath('ohm', 'login', 'features')).toBe('/p/ohm/c/features/e/login')
|
||||
})
|
||||
|
||||
it('entryPrPath carries the collection segment', () => {
|
||||
expect(entryPrPath('ohm', 'human', 7)).toBe('/p/ohm/c/default/e/human/pr/7')
|
||||
})
|
||||
|
||||
it('proposalPath carries the collection segment', () => {
|
||||
expect(proposalPath('ohm', 42)).toBe('/p/ohm/c/default/proposals/42')
|
||||
})
|
||||
|
||||
it('collectionHome targets the collection root', () => {
|
||||
expect(collectionHome('ohm')).toBe('/p/ohm/c/default/')
|
||||
})
|
||||
|
||||
it('projectHome stays at the project root (redirects into the collection)', () => {
|
||||
expect(projectHome('ohm')).toBe('/p/ohm/')
|
||||
})
|
||||
|
||||
it('exposes the default collection id constant', () => {
|
||||
expect(DEFAULT_COLLECTION).toBe('default')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user