§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>
This commit is contained in:
Ben Stull
2026-06-06 02:11:16 -07:00
parent e86fc65643
commit fcc3c84d76
18 changed files with 1379 additions and 6 deletions
+46
View File
@@ -23,6 +23,52 @@ 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 +