v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests

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>
This commit is contained in:
Ben Stull
2026-05-28 20:10:13 -07:00
parent 019c8a9185
commit 3c9109c392
21 changed files with 1530 additions and 143 deletions
+38
View File
@@ -94,6 +94,44 @@ def test_longest_match_wins():
assert out[-1]["label"] == "Open Human Model"
def test_pending_term_emits_contribute_segment():
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
# owner display name, not a plain link.
idx = rfc_links.LinkIndex([
rfc_links.Term(key="open human model", kind="pending",
slug="open-human-model", title="Open Human Model", owner="Alice"),
])
out = idx.segment("see Open Human Model please")
assert out[1] == {
"type": "rfc-pending", "slug": "open-human-model",
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
}
def test_candidate_term_emits_create_segment():
# Part 2: a candidate term carries its canonical spelling for the
# propose pre-fill; no slug (no RFC exists yet).
idx = rfc_links.LinkIndex([
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
])
out = idx.segment("the memory model is unspecified")
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
def test_kind_precedence_active_beats_pending_beats_candidate():
# All three buckets contribute the same key; the highest-precedence
# kind (active) must win at the position.
key = "open human model"
idx = rfc_links.LinkIndex([
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
])
out = idx.segment("the Open Human Model")
assert out[-1]["type"] == "rfc"
assert out[-1]["slug"] == "open-human-model"
def test_keys_for_gating():
keys = lambda **kw: set(rfc_links._keys_for(**kw))
# rfc_id always contributes.