From 7c6c906db2a56aa70a0960aaa868001699fe864a Mon Sep 17 00:00:00 2001
From: Ben Stull
Date: Thu, 28 May 2026 12:28:52 -0700
Subject: [PATCH] #26: optional proposed-use-case field on propose-RFC +
propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.
- propose-RFC modal (ProposeModal): optional textarea below the required
"Why is this RFC needed?" pitch, labeled "What will you be using this
RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
(NULL/omitted accepted, no min, 8000-char cap matching the existing
free-text bound). Persisted to a new canonical side table
`proposed_use_cases` keyed by PR number, mirrored onto the cache
columns added by migration 021. Returned on the proposal list/detail,
RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
supplied, accepted as NULL/omitted, for both surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
backend/app/api.py | 53 +++++-
backend/app/api_prs.py | 37 ++++
backend/migrations/021_proposed_use_case.sql | 47 +++++
.../tests/test_proposed_use_case_vertical.py | 161 ++++++++++++++++++
frontend/src/api.js | 17 +-
frontend/src/components/PRModal.jsx | 22 ++-
frontend/src/components/PRView.jsx | 11 ++
frontend/src/components/ProposalView.jsx | 8 +
frontend/src/components/ProposeModal.jsx | 16 ++
frontend/src/components/RFCView.jsx | 13 ++
10 files changed, 379 insertions(+), 6 deletions(-)
create mode 100644 backend/migrations/021_proposed_use_case.sql
create mode 100644 backend/tests/test_proposed_use_case_vertical.py
diff --git a/backend/app/api.py b/backend/app/api.py
index 4e36942..09a66bc 100644
--- a/backend/app/api.py
+++ b/backend/app/api.py
@@ -51,6 +51,11 @@ class ProposeBody(BaseModel):
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
+ # Roadmap #26: optional "What will you be using this RFC for?" — the
+ # concrete ground-truth use case, distinct from the `pitch`'s abstract
+ # "why is this needed." Optional (NULL/omitted accepted), no minimum,
+ # generous cap matching the pitch's free-text body bound.
+ proposed_use_case: str | None = Field(default=None, max_length=8000)
class DeclineBody(BaseModel):
@@ -557,12 +562,36 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
- return _serialize_rfc(row)
+ payload = _serialize_rfc(row)
+ # Roadmap #26: surface the optional propose-time use case on the
+ # RFC view. The idea PR closes on merge, but the canonical row in
+ # `proposed_use_cases` persists; look it up by slug (the latest
+ # 'rfc'-scope row for this slug). NULL == "left blank".
+ uc = db.conn().execute(
+ """
+ SELECT use_case FROM proposed_use_cases
+ WHERE scope = 'rfc' AND rfc_slug = ?
+ ORDER BY id DESC LIMIT 1
+ """,
+ (slug,),
+ ).fetchone()
+ payload["proposed_use_case"] = uc["use_case"] if uc else None
+ return payload
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
+ def _proposal_use_case(pr_number: int) -> str | None:
+ """Roadmap #26: read the optional use case for an idea PR from the
+ canonical side table. Returns None when none was supplied (the
+ "left blank" sentinel the frontend renders tastefully)."""
+ row = db.conn().execute(
+ "SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
+ (pr_number,),
+ ).fetchone()
+ return row["use_case"] if row else None
+
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
rows = db.conn().execute(
@@ -582,6 +611,7 @@ def make_router(
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
+ "proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
@@ -630,6 +660,7 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
+ "proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
@@ -706,6 +737,26 @@ def make_router(
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
+ # Roadmap #26: persist the optional use case to the canonical,
+ # reconcile-proof side table keyed by the idea PR number. NULL/
+ # blank simply writes no row (absence == "left blank"). Done after
+ # the refresh so the cache row exists; the mirror onto cached_prs
+ # keeps the cache column in parity for any read that uses it.
+ use_case = (payload.proposed_use_case or "").strip()
+ if use_case:
+ db.conn().execute(
+ """
+ INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
+ VALUES ('rfc', ?, ?, ?)
+ ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
+ """,
+ (slug, pr["number"], use_case),
+ )
+ db.conn().execute(
+ "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
+ (use_case, pr["number"]),
+ )
+
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py
index bb9c105..d15a7c0 100644
--- a/backend/app/api_prs.py
+++ b/backend/app/api_prs.py
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
class OpenPRBody(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(max_length=8000)
+ # Roadmap #26: optional "What will you be using this change for?" —
+ # the concrete ground-truth use case sibling to the required
+ # "why is this change needed" (the `description`). Optional, generous
+ # cap matching the description bound.
+ proposed_use_case: str | None = Field(default=None, max_length=8000)
class PRDescriptionBody(BaseModel):
@@ -173,6 +178,26 @@ def make_router(
raise HTTPException(502, f"Gitea: {e.detail}")
await _refresh_after_pr_write(rfc)
+
+ # Roadmap #26: persist the optional use case to the canonical,
+ # reconcile-proof side table keyed by the PR number. Blank/omitted
+ # writes no row (absence == "left blank"). The mirror onto
+ # cached_prs keeps the cache column in parity.
+ use_case = (body.proposed_use_case or "").strip()
+ if use_case:
+ db.conn().execute(
+ """
+ INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
+ VALUES ('pr', ?, ?, ?)
+ ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
+ """,
+ (slug, pr["number"], use_case),
+ )
+ db.conn().execute(
+ "UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
+ (use_case, slug, pr["number"]),
+ )
+
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -300,6 +325,7 @@ def make_router(
"pr_number": pr_number,
"title": pr_row["title"],
"description": pr_row["description"],
+ "proposed_use_case": _pr_use_case(pr_number),
"state": pr_row["state"],
"opened_by": pr_row["opened_by"],
"opened_at": pr_row["opened_at"],
@@ -762,6 +788,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
return _can_withdraw(rfc, pr_row, viewer)
+def _pr_use_case(pr_number: int) -> str | None:
+ """Roadmap #26: the optional propose-PR use case from the canonical
+ side table, or None when the change was opened without one ("left
+ blank")."""
+ row = db.conn().execute(
+ "SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
+ (pr_number,),
+ ).fetchone()
+ return row["use_case"] if row else None
+
+
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
return {
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
diff --git a/backend/migrations/021_proposed_use_case.sql b/backend/migrations/021_proposed_use_case.sql
new file mode 100644
index 0000000..fe540c2
--- /dev/null
+++ b/backend/migrations/021_proposed_use_case.sql
@@ -0,0 +1,47 @@
+-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
+-- this for?" capture on the two propose surfaces.
+--
+-- The roadmap's framing names "the rfcs table" and "the PR-metadata
+-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
+-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
+-- (002_cache.sql). We add the nullable column to each, matching the
+-- existing naming convention (no NOT NULL, no default — NULL is the
+-- "left blank" sentinel the view surfaces render tastefully).
+--
+-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
+-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
+-- sets only the columns it knows about, so an unlisted column is
+-- preserved on the update path — yet a propose/open never *writes* the
+-- column through the cache (the write path is endpoint -> Gitea ->
+-- reconcile, and the reconciler does not carry this field). So the cache
+-- column alone would always read NULL.
+--
+-- The durable home is therefore a dedicated app-truth table the propose
+-- /open endpoints write directly (keyed by the PR number, which is the
+-- stable identity for both idea PRs and rfc_branch PRs) and the view
+-- endpoints read back. This is not cache — it is canonical and survives
+-- any reconcile. The cache columns are added too for parity with the
+-- roadmap's literal shape and for any future reconciler that learns to
+-- carry the field, but the side table is the source of truth read at
+-- view time.
+
+ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
+ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
+
+-- Canonical, reconcile-proof store. One row per propose/open that
+-- supplied a use case. `scope` distinguishes the propose-RFC surface
+-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
+-- is the join key the endpoints already have in hand. NULL/omitted use
+-- cases simply never write a row here, so absence == "left blank".
+CREATE TABLE proposed_use_cases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
+ rfc_slug TEXT NOT NULL,
+ pr_number INTEGER NOT NULL,
+ use_case TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE (scope, pr_number)
+);
+
+CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
+CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
diff --git a/backend/tests/test_proposed_use_case_vertical.py b/backend/tests/test_proposed_use_case_vertical.py
new file mode 100644
index 0000000..edfd623
--- /dev/null
+++ b/backend/tests/test_proposed_use_case_vertical.py
@@ -0,0 +1,161 @@
+"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
+"What will you be using this for?" capture on the two propose surfaces.
+
+Reuses the FakeGitea + session helpers from test_propose_vertical.py and
+the active-RFC seed from test_rfc_view_vertical.py. Proves:
+
+ (a) propose-RFC persists and returns `proposed_use_case` when supplied,
+ and the value survives onto the merged super-draft's RFC view;
+ (b) propose-RFC accepts a NULL / omitted use case ("left blank");
+ (c) propose-PR persists and returns `proposed_use_case` when supplied,
+ and accepts a NULL / omitted one.
+"""
+from __future__ import annotations
+
+from test_propose_vertical import ( # noqa: F401
+ FakeGitea,
+ app_with_fake_gitea,
+ provision_user_row,
+ sign_in_as,
+ tmp_env,
+)
+from test_pr_flow_vertical import _cut_branch_and_accept_change
+from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
+
+
+# ---------------------------------------------------------------------------
+# propose-RFC
+# ---------------------------------------------------------------------------
+
+
+def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=2, login="alice", role="contributor")
+ provision_user_row(user_id=1, login="ben", role="owner")
+ sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
+
+ r = client.post("/api/rfcs/propose", json={
+ "title": "Open Human Model",
+ "slug": "open-human-model",
+ "pitch": "A shared definition of what we mean by *human*.",
+ "tags": ["identity"],
+ "proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
+ })
+ assert r.status_code == 200, r.text
+ pr_number = r.json()["pr_number"]
+
+ # The pending-idea list carries the use case.
+ items = client.get("/api/proposals").json()["items"]
+ assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
+
+ # The pending-idea detail view carries it too.
+ proposal = client.get(f"/api/proposals/{pr_number}").json()
+ assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
+
+ # Merge as owner; the use case survives onto the RFC view (looked
+ # up by slug from the canonical side table, since the idea PR
+ # closes on merge).
+ sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
+ r = client.post(f"/api/proposals/{pr_number}/merge")
+ assert r.status_code == 200, r.text
+
+ view = client.get("/api/rfcs/open-human-model").json()
+ assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
+
+
+def test_propose_rfc_use_case_optional(app_with_fake_gitea):
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=3, login="carol", role="contributor")
+ sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
+
+ # Omitted entirely.
+ r = client.post("/api/rfcs/propose", json={
+ "title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
+ })
+ assert r.status_code == 200, r.text
+ pr_a = r.json()["pr_number"]
+
+ # Explicit null.
+ r = client.post("/api/rfcs/propose", json={
+ "title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
+ "tags": [], "proposed_use_case": None,
+ })
+ assert r.status_code == 200, r.text
+ pr_b = r.json()["pr_number"]
+
+ # Blank/whitespace — treated as "left blank", no row written.
+ r = client.post("/api/rfcs/propose", json={
+ "title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
+ "tags": [], "proposed_use_case": " ",
+ })
+ assert r.status_code == 200, r.text
+ pr_c = r.json()["pr_number"]
+
+ for pr in (pr_a, pr_b, pr_c):
+ assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
+
+
+# ---------------------------------------------------------------------------
+# propose-PR (against an active RFC)
+# ---------------------------------------------------------------------------
+
+
+def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
+ from fastapi.testclient import TestClient
+
+ app, fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=2, login="alice", role="contributor")
+ seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
+ sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
+
+ branch, _ = _cut_branch_and_accept_change(
+ client, fake, slug="ohm",
+ original="Open Human Model is a framework for representing humans.",
+ proposed="Open Human Model is a framework for representing humans across systems.",
+ )
+ r = client.post(
+ f"/api/rfcs/ohm/branches/{branch}/open-pr",
+ json={
+ "title": "Tighten the opening",
+ "description": "Scope to systems.",
+ "proposed_use_case": "Building a cross-system consent registry.",
+ },
+ )
+ assert r.status_code == 200, r.text
+ pr_number = r.json()["pr_number"]
+
+ pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
+ assert pr["proposed_use_case"] == "Building a cross-system consent registry."
+
+
+def test_propose_pr_use_case_optional(app_with_fake_gitea):
+ from fastapi.testclient import TestClient
+
+ app, fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=2, login="alice", role="contributor")
+ seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
+ sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
+
+ branch, _ = _cut_branch_and_accept_change(
+ client, fake, slug="ohm",
+ original="It defines consent, trait, and agency in compatible terms.",
+ proposed="It defines consent, trait, harm, and agency in compatible terms.",
+ )
+ # No proposed_use_case key at all.
+ r = client.post(
+ f"/api/rfcs/ohm/branches/{branch}/open-pr",
+ json={"title": "Add harm", "description": "Name harm explicitly."},
+ )
+ assert r.status_code == 200, r.text
+ pr_number = r.json()["pr_number"]
+
+ pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
+ assert pr["proposed_use_case"] is None
diff --git a/frontend/src/api.js b/frontend/src/api.js
index b5d9cb4..27e6881 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -166,11 +166,19 @@ export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
-export async function proposeRFC({ title, slug, pitch, tags }) {
+export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
+ // #26: proposed_use_case is optional; send null when blank so the
+ // backend treats it as "left blank".
+ body: JSON.stringify({
+ title,
+ slug,
+ pitch,
+ tags: tags || [],
+ proposed_use_case: proposedUseCase || null,
+ }),
})
return jsonOrThrow(res)
}
@@ -492,13 +500,14 @@ export async function draftPRText(slug, branch) {
return jsonOrThrow(res)
}
-export async function openPR(slug, branch, { title, description }) {
+export async function openPR(slug, branch, { title, description, proposedUseCase }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title, description }),
+ // #26: proposed_use_case is optional; null when blank.
+ body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
},
)
return jsonOrThrow(res)
diff --git a/frontend/src/components/PRModal.jsx b/frontend/src/components/PRModal.jsx
index 23ba78c..1506268 100644
--- a/frontend/src/components/PRModal.jsx
+++ b/frontend/src/components/PRModal.jsx
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
+ // #26: optional ground-truth use case for this change.
+ const [useCase, setUseCase] = useState('')
const [drafting, setDrafting] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setSubmitting(true)
setError(null)
try {
- const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
+ const { pr_number } = await openPR(slug, branch, {
+ title: title.trim(),
+ description: description.trim(),
+ proposedUseCase: useCase.trim() || null,
+ })
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
// and pr_number are the join keys; title/description stay out.
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
@@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
what was argued, what shifted, what the arbiters are asked
to consider.
+
+