Builds the three-tier authorization resolver on M1's schema spine: the
most-permissive union of deployment role (§6.1), project role (§22.6), and
per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on
top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no
behavior change on the public default project. Verifiable on the single
default project by flipping its visibility and granting/revoking roles.
- app/auth.py: the §22.7 resolver primitives — project_visibility,
project_member_role, project_of_rfc, is_project_superuser, can_read_project,
effective write/discuss standing (can_contribute_in_project /
can_discuss_in_project), require_project_readable, visible_project_ids.
The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc
/ can_invite_to_rfc) now compose all three tiers, so the ~20 call sites
inherit M2 unchanged.
- Read pass: the §22.5 visibility gate (404 to non-members) threaded into
every RFC-resolution helper across api.py / api_branches / api_prs /
api_graduation / api_discussion / api_contributions / api_invitations, plus
the catalog + proposals listings filtered by visible_project_ids.
- Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/
edit, graduation, discussion resolve) fold in project_admin via
is_project_superuser; the "any-contributor" branch mode routes through
can_contribute_in_project; propose + claim gate on project contribute
standing. Deployment-level surfaces (admin idea-PR merge/decline, account
notification-mute) stay deployment-scoped.
- Operator decisions: implicit-on-public (a granted deployment contributor
keeps its pre-M2 write baseline on a public project, no membership row, so
the N=1 case stays whole) and preserve-curation (that baseline does not
override per-RFC owner curation — only an explicit project grant or a
deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract
intact on public.
- tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the
resolver tiers, public-unchanged regression, the gated 404 read gate, the
gated contribution gate, union + subtractive visibility, and revocation.
Full suite 390 passed.
- docs: mark Part C M2 "(landed)" with the two operator decisions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.
Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.
Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.
New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.
Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>