Compare commits

...

16 Commits

Author SHA1 Message Date
Ben Stull b7e23a01f8 Merge pull request '§22 S6 (last item): spec pass for per-type surfaces (§22.4a items 1 & 3)' (#25) from spec/s7-per-type-surfaces into main 2026-06-06 09:16:07 +00:00
Ben Stull 281dd29e62 §22 S6 (last item): spec pass for per-type surfaces (§22.4a items 1 & 3)
The per-type frontmatter schemas (item 1) and type-specific surfaces (item 3:
specification release-planning; bdd scenario/coverage views) were flagged at
v0.45.0 as wanting a discovery/spec pass first (they lacked BDD scenarios in
Part C). This is that pass — a design doc specifying the schemas, the surfaces,
their data model / API / frontend shape, BDD-style acceptance scenarios, and a
three-slice delivery plan (S7a schemas, S7b releases, S7c bdd surfaces), all
additive and engine-preserving per §22.4a. Doc-only; no code, no version bump.

- docs/design/2026-06-06-per-type-surfaces.md — the new spec pass.
- three-tier design doc S6 bullet: forward-pointer + S6 shipped/spec'd status.

Leaves implementation to the S7a–S7c coding slices; S7a (schemas) is unblocked,
S7b/S7c carry open product questions for the operator/discovery to settle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:15:45 -07:00
Ben Stull 1c17fecea3 Merge pull request '§22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0' (#24) from feat/s6-join-requests into main 2026-06-06 09:12:00 +00:00
Ben Stull fcc3c84d76 §22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.

Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
  requester, requested_role, message, status, granted_role); one-open-per
  (scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
  {id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
  Accept grants via memberships.grant; the request POST does not require the
  scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
  _scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
  folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.

Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
  collection directory + catalog footer; JoinRequestRow in the inbox.

Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.

Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:11:16 -07:00
Ben Stull e86fc65643 Discovery spec: configurable collection metadata (clean-doc tagging)
Solution Design for a generic per-collection metadata system — typed fields
(enum/tags/text) declared in .collection.yaml, per-entry values in a clean
<slug>.meta.yaml sidecar, schema-derived faceted left-pane filtering, and
single/bulk direct-commit tag/untag. Restores the retired BDD Release Planner's
corpus-annotation half inside the framework; release planning stays downstream.

Output of discovery session OHM-0079.0. Proposed as §23; flag for reconciliation
with §22 S6 type-modules at the SPEC merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:52:26 -07:00
Ben Stull 014015014b Merge pull request '§22 S6: SPEC merge + per-collection model universe + type noun — v0.45.0' (#23) from feat/s6-three-tier-final into main 2026-06-06 08:44:25 +00:00
Ben Stull b392fa923c §22 S6: release v0.45.0 — two-project/multi-collection test + §20.4 changelog
The release wrap for the S6 scope shipped this slice (SPEC merge, per-collection
enabled_models, type-driven entry noun).

- test_s6_two_project_multicollection.py: an integrative pass proving the new
  per-collection knobs (§22.12 enabled_models, §22.4a noun) resolve independently
  per collection across two projects with no cross-project/-collection bleed.
- CHANGELOG.md 0.45.0: the §20.4 entry + upgrade-steps (migration 031 is
  additive + automatic; per-collection enabled_models and typed collections are
  optional). Notes the two OPEN S6 items carried to a follow-up slice (per-type
  surfaces; request-to-join + cross-collection inbox).
- VERSION + frontend/package.json → 0.45.0.

Gate: backend 530 passed, frontend 30 passed, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:43:09 -07:00
Ben Stull 839404da0c §22 S6: type-driven entry noun (§22.4a) — backend source of truth + chrome
The displayed noun for an entry is a framework concept keyed on the collection's
immutable type — document→"RFC", specification→"Spec", bdd→"Feature" — not
deployment content. The chrome reads it from the API instead of hardcoding "RFC".

- collections.ENTRY_NOUN + entry_noun(type) (unknown type → generic "RFC").
- Surfaced as entry_noun on get_collection, list_collections items, the
  /api/deployment directory items, and GET /api/projects/:id.
- Frontend: Catalog reads entry_noun for the "+ Propose New <noun>" control;
  ProposeModal fetches the active collection's noun for its title + field copy.
- test_s6_entry_noun_vertical.py: map + API surfacing (collection + directory).

Scope note: this is §22.4a item (2) — terminology. The deeper type-module work
(item 1 per-type frontmatter schemas; item 3 specification release-planning +
bdd scenario/coverage surfaces) is flagged OPEN in the design doc and is handed
off to a follow-up spec+slice.

Backend 528 passed; frontend 30 passed + build green. Part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:35:57 -07:00
Ben Stull 79a27a946b §22 S6: per-collection enabled_models — the §22.12 model-universe chain
A collection's .collection.yaml may carry an enabled_models list that NARROWS
its project's universe, which narrows the deployment ENABLED_MODELS. Resolution
(extending §6.6/§6.7): funder ∩ per-entry models ∩ collection ∩ project, with
the operator providers as the ceiling.

- migration 031: additive collections.config_json (parallels projects.config_json).
- registry.parse_collection_manifest: read enabled_models into CollectionEntry.config
  (absent = inherit, present incl. [] = narrow; [] opts the collection out of AI);
  reject a non-list. _upsert_named_collection persists config_json. The default
  collection (from projects.yaml) leaves it NULL — it inherits the project.
- models_resolver: _scope_narrowed_universe narrows the operator universe by the
  entry's project then collection enabled_models; the funder universe is bounded
  by it too. A collection cannot widen its project (narrowing from the ceiling).
- collections.get_collection surfaces enabled_models; GET
  /api/projects/:id/collections/:cid returns it.
- test_s6_collection_models_vertical.py: 9 cases (parse, the narrowing chain
  incl. opt-out + cannot-widen, API surfacing).

Backend: 525 passed. Per SPEC §22.12. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:30:16 -07:00
Ben Stull 26f3680197 §22 S6: merge three-tier model into SPEC.md + registry format in DEPLOYMENTS
Write the canonical §22 (deployment → project → RFC collection) into the
binding spec, applying Part A of the design doc and the Part D amendments in
place. Lands the S3 keystone reinterpretation of §B.1/§B.3: a plain granted
account is a granted account, not a global write role; "global RFC Contributor"
is an explicit memberships(scope_type='global') grant; the implicit-public
write baseline is grandfathered onto the migration-seeded default collection
only (N=1 preserved).

- SPEC.md §22.1–§22.14: tiers + isolation, the registry + .collection.yaml
  manifests, content-repo-per-project, per-collection slug identity, collection
  type/initial_state/unreviewed, two-tier visibility (narrow-only), the unified
  {owner, contributor} role vocabulary at {global, project, collection}, the
  four-layer most-permissive union, discovery/joining (invite + request-to-join),
  runtime branding, /p/<project>/c/<collection>/ routing, one inbox,
  per-collection model universe, the default-project+collection migration, and
  §22.14 consolidating the §§1–21 amendments.
- Forward-pointer amendment notes (house style) at §1, §2, §5, §6 routing
  readers to §22.
- docs/DEPLOYMENTS.md: the registry (REGISTRY_REPO / projects.yaml) and
  .collection.yaml manifest formats, the N=1 default-project+collection upgrade,
  and in-app create-project/collection.

Per docs/design/2026-06-05-three-tier-projects-collections.md Parts A/B/D/E (S6).
Docs-only; no code or migration. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:21:39 -07:00
Ben Stull b0737380cd §22 S5: in-app create-project + global directory — v0.44.0 (@S5) (#22) 2026-06-06 08:03:12 +00:00
Ben Stull 33212c71e4 §22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
Adds the global-Owner create-project action and the role-aware deployment
directory, completing slice S5 of the three-tier refactor (release v0.44.0,
minor/non-breaking — no migration). Per
docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5),
Part C.3 (C3.1–C3.2).

Backend:
- auth.can_create_project — global-Owner gate (deployment owner/admin or an
  explicit scope_type='global' Owner grant).
- bot.create_project — provision the Gitea content repo (seed README so main
  exists), read+append+commit projects.yaml in the registry repo, audit-log
  (create_project). The bot stays the only git writer (§1).
- POST /api/projects (api_deployment) — global-Owner gated; validates the id
  (slug, not 'default'), name, type, visibility, content_repo; commits via the
  bot, then re-mirrors the registry so projects + default collection rows flow
  from git (§22.2). GET /api/deployment gains viewer.can_create_project and
  default_project_readable. make_router now takes gitea + bot.

Frontend:
- api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable
  + refresh; DeploymentLanding redirects into the default only when readable
  (gated/absent default falls through to the directory, no 404 bounce).
- Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA;
  C3.2 "Nothing has been shared with you yet") + Owner-only "New project"
  control + CreateProjectModal.

Tests: backend test_create_project_vertical.py (vertical + gates + the
deployment empty-state signals); frontend Directory.test.jsx empty-state cases.

Also: ignore the session-local .superpowers/ tooling dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:02:16 -07:00
Ben Stull 2696e64ff5 Merge pull request '§22 S4: invitation surfaces + role-aware empty states — v0.43.0 (@S4)' (#21) from feat/s4-invitation-surfaces-empty-states into main 2026-06-06 07:31:01 +00:00
Ben Stull ff54632657 §22 S4: release v0.43.0 — invitation surfaces + role-aware empty states (@S4)
Bump VERSION + frontend/package.json to 0.43.0, add the CHANGELOG entry
(minor, non-breaking — additive endpoints + UI, no migration, no change to
existing authz outcomes), and mark Part E slice S4 shipped in the design doc.

Completes @S4 (C2.1–C2.7 invitation + C3.3–C3.5 role-aware empty states).
Next: S5 (in-app create-project + the global directory, C3.1–C3.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:30:12 -07:00
Ben Stull 93cf506059 §22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)
The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:27:49 -07:00
Ben Stull c9fd1c535e §22 S4: scope-role invitation backend + capability flags (@S4 C.2)
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).

- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
  invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
  preserving a stronger child grant — no negative override), revoke, list,
  user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
  POST keying on optional collection_id so the invite UI's one control maps
  to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
  contributors refused (C.2.4); a pending grantee's row is recorded but
  confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
  notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
  can_invite, can_contribute, role) on the project/collection GETs to drive
  the C.3 role-aware empty states.

Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:47:28 -07:00
46 changed files with 5286 additions and 97 deletions
+1
View File
@@ -26,3 +26,4 @@ data/
# Claude Code (per-machine settings only; shared config under .claude/ is committed)
.claude/settings.local.json
.superpowers/
+225
View File
@@ -23,6 +23,231 @@ 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 S1S5
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.1C3.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.1C3.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.1C3.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.1C2.7: invitation
reach, bounding, supersession, and the pending-account floor; C3.3C3.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.1C3.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.3C.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:
+525
View File
@@ -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 §§121 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 §§121 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 S1S6 (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 (§§913), 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 §§121 (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.
+1 -1
View File
@@ -1 +1 @@
0.42.0
0.46.0
+10 -1
View File
@@ -27,6 +27,8 @@ from . import (
api_discussion,
api_graduation,
api_invitations,
api_join_requests,
api_memberships,
api_notifications,
api_prs,
auth,
@@ -152,8 +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.
+47 -1
View File
@@ -41,6 +41,34 @@ class CreateCollectionBody(BaseModel):
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()
@@ -57,7 +85,10 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
for c in collections_mod.list_collections(project_id, include_unlisted=True)
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
]
return {"items": items}
# §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]:
@@ -68,6 +99,21 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
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")
+148 -8
View File
@@ -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, collections as collections_mod, 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")
@@ -46,11 +78,26 @@ def make_router(config: Config) -> APIRouter:
"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 "",
@@ -58,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}")
@@ -87,6 +226,7 @@ def make_router(config: Config) -> APIRouter:
"name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "",
"type": collections_mod.collection_type(cid),
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
"visibility": row["visibility"],
"initial_state": collections_mod.collection_initial_state(cid),
"theme": cfg.get("theme") or {},
+311
View File
@@ -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
+157
View File
@@ -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
+75
View File
@@ -574,6 +574,42 @@ def effective_scope_role(user: SessionUser | None, collection_id: str) -> str |
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."""
@@ -682,6 +718,45 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
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
+76
View File
@@ -198,6 +198,82 @@ class Bot:
)
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(
+45 -5
View File
@@ -8,10 +8,42 @@ authz (auth.py) recovers a row's project by joining `collections` on
"""
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
@@ -60,13 +92,19 @@ def subfolder_of(collection_id: str) -> str:
def get_collection(collection_id: str) -> dict | None:
"""The full collection row as a dict, or None if unknown."""
"""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 "
"FROM collections WHERE id = ?",
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
"config_json FROM collections WHERE id = ?",
(collection_id,),
).fetchone()
return dict(row) if row else None
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]:
@@ -82,5 +120,7 @@ def list_collections(project_id: str, include_unlisted: bool = False) -> list[di
for r in rows:
if not include_unlisted and r["visibility"] == "unlisted":
continue
out.append(dict(r))
item = dict(r)
item["entry_noun"] = entry_noun(item["type"])
out.append(item)
return out
+141
View File
@@ -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]
+62 -6
View File
@@ -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:
+196
View File
@@ -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
+16 -4
View File
@@ -64,6 +64,7 @@ class CollectionEntry:
visibility: str | None
initial_state: str
name: str | None
config: dict = field(default_factory=dict) # §22.12 enabled_models
@dataclass
@@ -149,7 +150,16 @@ def parse_collection_manifest(text: str) -> CollectionEntry:
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
name = raw.get("name")
name = str(name).strip() if name else None
return CollectionEntry(ctype, vis, initial_state, name)
# §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:
@@ -268,17 +278,19 @@ def _upsert_named_collection(
conn.execute(
"""
INSERT INTO collections
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
(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, sha),
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name,
json.dumps(ce.config), sha),
)
@@ -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;
+58
View File
@@ -0,0 +1,58 @@
-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
--
-- A gated project or collection is invisible to non-members (§22.5), so a user
-- who knows a scope exists can ask to join it: they name a desired role and the
-- request is recorded here, then fanned out to that scope's Owners *across the
-- subtree* (a collection request reaches the collection's Owners, its project's
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
-- the requester is §15-notified of the decision either way.
--
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
-- project or collection a user discovers, not the deployment), and accept grants
-- a scope role rather than minting an RFC invitation.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS join_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- The target scope. 'global' is intentionally excluded: the deployment is
-- not a thing one "discovers and joins" (§22.8 names a project/collection).
scope_type TEXT NOT NULL
CHECK (scope_type IN ('project', 'collection')),
scope_id TEXT NOT NULL,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The role the requester is asking for ({owner, contributor}, the §22.6
-- unified vocabulary). The accepting Owner may grant this or a narrower role.
requested_role TEXT NOT NULL
CHECK (requested_role IN ('owner', 'contributor')),
-- Optional free text — "who I am / why I want in". Bounded by the API layer.
message TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The role actually granted on accept (may differ from requested_role if the
-- Owner narrowed it); NULL until accepted.
granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')),
-- The owner-facing notification row that carries the Accept/Decline action.
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_join_requests_scope
ON join_requests(scope_type, scope_id, status);
CREATE INDEX IF NOT EXISTS idx_join_requests_requester
ON join_requests(requester_user_id, status);
-- At most one open (pending) request per (scope, requester): a second ask while
-- one is still pending is a 409, not a duplicate row. A decided request
-- (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
ON join_requests(scope_type, scope_id, requester_user_id)
WHERE status = 'pending';
@@ -0,0 +1,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
@@ -0,0 +1,339 @@
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
A user who knows a (gated) scope exists asks to join it, naming a desired role;
the request is recorded and fanned out to that scope's Owners *across the
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
the `memberships` row via memberships.grant — or declines, and the requester is
§15-notified either way.
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
and test_s4_invitations_vertical.py (the scope/membership world-builders).
World: project "ohm" owns collections "model" (document, gated) and "features"
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
(no scope role) — the would-be joiner.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# World-builders (mirror the S4 vertical)
# ---------------------------------------------------------------------------
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now'))",
(pid, pid.capitalize(), content_repo, visibility),
)
def _collection(cid: str, project_id: str, *, ctype: str = "document",
visibility: str = "gated") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO collections "
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
)
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
db.conn().execute(
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES (?, ?, ?, ?)",
(scope_type, scope_id, user_id, role),
)
def _membership(user_id: int):
rows = db.conn().execute(
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
(user_id,),
).fetchall()
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
def _join_requests(scope_type: str, scope_id: str):
rows = db.conn().execute(
"SELECT id, requester_user_id, requested_role, status, granted_role "
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
(scope_type, scope_id),
).fetchall()
return [dict(r) for r in rows]
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
return {
r["recipient_user_id"]
for r in db.conn().execute(
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
(event_kind,),
)
}
def _seed_world() -> None:
_project("ohm", "gated")
_collection("model", "ohm", ctype="document")
_collection("features", "ohm", ctype="bdd")
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
_grant("project", "ohm", 5, "owner")
_grant("collection", "features", 4, "owner")
_grant("global", "*", 6, "owner")
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
# ---------------------------------------------------------------------------
# Request → cross-collection fan-out
# ---------------------------------------------------------------------------
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
"""A request to join a collection lands a row and notifies every Owner whose
reach covers it — the collection's Owner, the project's Owner, a global
Owner, and the deployment admin (the cross-collection inbox) — never the
requester."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor", "message": "I work on BDD corpora."},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "pending"
reqs = _join_requests("collection", "features")
assert len(reqs) == 1
assert reqs[0]["requester_user_id"] == 2
assert reqs[0]["requested_role"] == "contributor"
assert reqs[0]["status"] == "pending"
# Owners across the subtree are notified; ben (requester) is not.
recips = _join_notif_recipients()
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
assert 2 not in recips
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
"""A project-scope request reaches the project's Owners + global Owners +
admin, but NOT a collection-only Owner (their reach doesn't cover the
project)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/project/ohm/join-requests",
json={"role": "owner"},
)
assert r.status_code == 200, r.text
recips = _join_notif_recipients()
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
assert 4 not in recips # dan is only a collection Owner
# ---------------------------------------------------------------------------
# Accept → writes membership + notifies
# ---------------------------------------------------------------------------
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
"""The collection Owner accepts; a `memberships` row is written at the
requested scope/role and the requester gets a join_request_accepted inbox
row."""
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor"},
)
req_id = _join_requests("collection", "features")[0]["id"]
# dan (collection Owner of features) accepts.
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={},
)
assert r.status_code == 200, r.text
assert r.json()["granted_role"] == "contributor"
# ben now holds the collection role and can contribute there.
assert ("collection", "features", "contributor") in _membership(2)
ben = auth.SessionUser(
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
)
assert auth.can_contribute_in_collection(ben, "features") is True
assert auth.can_contribute_in_collection(ben, "model") is False
# the row is closed; the requester is notified.
assert _join_requests("collection", "features")[0]["status"] == "accepted"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
assert accepted, inbox
assert "Features" in accepted[0]["summary"]
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
the grant; the membership row carries the granted (not requested) role."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 5, "eve") # project Owner — reach covers the collection
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={"role": "contributor"},
)
assert r.status_code == 200, r.text
assert ("collection", "features", "contributor") in _membership(2)
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 4, "dan")
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
assert r.status_code == 200, r.text
assert _membership(2) == set()
assert _join_requests("collection", "features")[0]["status"] == "declined"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
# ---------------------------------------------------------------------------
# Gates & guards
# ---------------------------------------------------------------------------
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r1.status_code == 200, r1.text
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r2.status_code == 409, r2.text
def test_existing_member_cannot_request(app_with_fake_gitea):
"""dan already owns the collection — there is nothing to request (409)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 4, "dan")
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r.status_code == 409, r.text
def test_non_owner_cannot_accept(app_with_fake_gitea):
"""A plain requester (or any non-Owner) is refused the accept action."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
# provision a second plain account that tries to accept
provision_user_row(user_id=12, login="mal", role="contributor")
_login(client, 12, "mal")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
assert _membership(2) == set()
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "model")[0]["id"]
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
def test_unknown_scope_404(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
assert client.post(
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
).status_code == 404
assert client.post(
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
).status_code == 404
# 'global' is not a join-able scope_type.
assert client.post(
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
).status_code == 404
def test_join_target_reports_eligibility(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
# ben: eligible (granted, no role).
_login(client, 2, "ben")
t = client.get("/api/scopes/collection/features/join-target").json()
assert t["eligible"] is True
assert t["name"] == "Features"
assert t["current_role"] is None
# after requesting, already_requested flips and eligible drops.
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
t2 = client.get("/api/scopes/collection/features/join-target").json()
assert t2["already_requested"] is True
assert t2["eligible"] is False
# dan: already a member → ineligible with current_role.
_login(client, 4, "dan")
t3 = client.get("/api/scopes/collection/features/join-target").json()
assert t3["eligible"] is False
assert t3["current_role"] == "owner"
@@ -0,0 +1,95 @@
"""Migration 032 — the join_requests table (§22.8 S6).
Proves: the table exists with its CHECK constraints (scope_type ∈
{project,collection}; role/status enums), the one-open-per-(scope,user) partial
unique index holds, and a decided request frees a fresh ask.
Template: test_migration_030_global_scope.py.
"""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def _add_user(conn, uid, login):
conn.execute(
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
"VALUES (?, ?, ?, ?, 'contributor')",
(uid, uid, login, login.capitalize()),
)
def _request(conn, scope_type="collection", scope_id="features", uid=1, role="contributor"):
conn.execute(
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role) "
"VALUES (?, ?, ?, ?)",
(scope_type, scope_id, uid, role),
)
def test_join_request_row_round_trips():
conn = _fresh_db()
_add_user(conn, 1, "ben")
_request(conn)
row = conn.execute("SELECT * FROM join_requests WHERE requester_user_id = 1").fetchone()
assert row["scope_type"] == "collection"
assert row["requested_role"] == "contributor"
assert row["status"] == "pending"
assert row["granted_role"] is None
def test_global_scope_type_is_rejected():
conn = _fresh_db()
_add_user(conn, 1, "ben")
with pytest.raises(sqlite3.IntegrityError):
_request(conn, scope_type="global", scope_id="*")
def test_bad_role_and_status_rejected():
conn = _fresh_db()
_add_user(conn, 1, "ben")
with pytest.raises(sqlite3.IntegrityError):
_request(conn, role="viewer")
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role, status) "
"VALUES ('project', 'ohm', 1, 'owner', 'maybe')"
)
def test_one_open_request_per_scope_user():
conn = _fresh_db()
_add_user(conn, 1, "ben")
_request(conn)
with pytest.raises(sqlite3.IntegrityError):
_request(conn)
def test_decided_request_frees_a_fresh_ask():
conn = _fresh_db()
_add_user(conn, 1, "ben")
_request(conn)
conn.execute("UPDATE join_requests SET status = 'declined' WHERE requester_user_id = 1")
# a second open ask is now allowed
_request(conn)
n = conn.execute(
"SELECT COUNT(*) AS n FROM join_requests WHERE requester_user_id = 1"
).fetchone()["n"]
assert n == 2
@@ -0,0 +1,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"
+93
View File
@@ -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
@@ -581,17 +581,28 @@ means the deployment runs and either gains a capability or provably loses none
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
inheritance, union, no-negative-override).
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI
- **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.
**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.3C3.5).
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.3C3.5).
- **S5 — In-app create-project + the global directory.** The global-Owner
- **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. **Usable end-state:**
`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.1C3.2).
@@ -603,6 +614,15 @@ means the deployment runs and either gains a capability or provably loses none
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 (S7aS7c) 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)
@@ -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 P0P3, 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 P0P3 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 PerProduct-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).
+352
View File
@@ -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, S1S6 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.1C.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 (§§913), 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.42.0",
"version": "0.46.0",
"type": "module",
"scripts": {
"dev": "vite",
+22
View File
@@ -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;
}
+7 -4
View File
@@ -448,16 +448,19 @@ function DeploymentLanding() {
// 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 />
+46
View File
@@ -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')
})
})
+93 -1
View File
@@ -182,6 +182,24 @@ export async function getProject(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${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.
@@ -204,11 +222,45 @@ export async function getRFC(projectId, slug, collectionId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}
// §22 S2: the collections of a project (for the /p/<project>/ directory).
// §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 }) {
@@ -315,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)
+52 -3
View File
@@ -8,8 +8,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api'
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,6 +27,18 @@ 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())
@@ -39,8 +52,21 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
useEffect(() => {
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
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()
let items = rfcs.filter(r => {
@@ -98,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>
) : (
@@ -154,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>
)
}
@@ -2,23 +2,41 @@
// 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). The role-keyed "Create your first
// collection" empty state is S4; S2 shows a minimal note when there are none.
// 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) })
.catch(() => { if (live) setCols([]) })
.then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } })
.catch(() => { if (live) { setCols([]); setViewer(null) } })
return () => { live = false }
}, [projectId])
}, [projectId, version])
if (cols === null) {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
@@ -27,12 +45,40 @@ export default function CollectionDirectory({ projectId }) {
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">
<h1>Collections</h1>
<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 ? (
<p className="directory-tagline">No collections yet.</p>
// 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 => (
@@ -46,6 +92,28 @@ export default function CollectionDirectory({ projectId }) {
</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>
)
}
@@ -4,15 +4,20 @@ 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 })),
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 = [] })
beforeEach(() => { mockItems = []; mockViewer = null })
function renderDir(items) {
function renderDir(items, viewer = null) {
mockItems = items
mockViewer = viewer
return render(
<MemoryRouter initialEntries={["/p/ohm/"]}>
<Routes>
@@ -45,4 +50,27 @@ describe('CollectionDirectory', () => {
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>
)
}
+56 -15
View File
@@ -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>
)
}
+50 -12
View File
@@ -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()
})
})
+67
View File
@@ -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>
)
}
+21 -7
View File
@@ -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
@@ -33,6 +33,9 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
// (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
@@ -116,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}>
@@ -130,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
@@ -142,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>
)
}
+30 -12
View File
@@ -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>
)