Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+97 -28
View File
@@ -204,6 +204,46 @@ def make_router(
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
COUNT(DISTINCT t.id) AS thread_count,
COUNT(DISTINCT m.id) AS message_count,
MAX(m.created_at) AS last_activity_at
FROM threads t
LEFT JOIN thread_messages m ON m.thread_id = t.id
WHERE t.rfc_slug = ?
AND (
t.branch_name LIKE 'edit-' || ? || '-%'
OR t.branch_name LIKE 'edit/' || ? || '/%'
)
GROUP BY t.branch_name
ORDER BY MAX(m.created_at) DESC NULLS LAST, t.branch_name
""",
(slug, slug, slug),
).fetchall()
for r in pre_grad_rows:
change_count = db.conn().execute(
"SELECT COUNT(*) AS n FROM changes WHERE rfc_slug = ? AND branch_name = ?",
(slug, r["branch_name"]),
).fetchone()["n"]
pre_grad.append({
"branch_name": r["branch_name"],
"thread_count": r["thread_count"],
"message_count": r["message_count"],
"change_count": change_count,
"last_activity_at": r["last_activity_at"],
})
return {
"slug": slug,
"title": rfc["title"],
@@ -215,6 +255,7 @@ def make_router(
"body_sha": rfc["body_sha"],
"branches": branches,
"open_prs": prs,
"pre_graduation_history": pre_grad,
}
# -------------------------------------------------------------------
@@ -232,8 +273,8 @@ def make_router(
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
result = await gitea.read_file(owner, repo, path, ref=branch)
if result is None:
br = await gitea.get_branch(owner, repo, branch)
@@ -242,7 +283,7 @@ def make_router(
body, body_sha = "", ""
else:
content, body_sha = result
body = _extract_body(rfc, content)
body = _extract_body(rfc, content, branch)
# Ensure the whole-doc chat thread for the branch exists.
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
@@ -482,13 +523,13 @@ def make_router(
# Fetch current file and extract the editable body. For super-draft
# the file is rfcs/<slug>.md with frontmatter; for active it's RFC.md.
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
current_body = _extract_body(rfc, prior_content)
current_body = _extract_body(rfc, prior_content, branch)
original = row["original"]
occurrences = current_body.count(original)
@@ -508,7 +549,7 @@ def make_router(
else:
new_body = current_body.replace(original, body.proposed, 1)
new_file_contents = _wrap_body(rfc, prior_content, new_body)
new_file_contents = _wrap_body(rfc, prior_content, new_body, branch)
try:
sha = await bot.commit_accepted_change(
@@ -590,10 +631,10 @@ def make_router(
if not providers:
raise HTTPException(503, "No AI providers configured")
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = next(iter(providers.values()))
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
@@ -638,18 +679,18 @@ def make_router(
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
prior_body = _extract_body(rfc, prior_content)
prior_body = _extract_body(rfc, prior_content, branch)
if prior_body == body.new_content:
return {"ok": True, "noop": True}
new_file_contents = _wrap_body(rfc, prior_content, body.new_content)
new_file_contents = _wrap_body(rfc, prior_content, body.new_content, branch)
# Per §8.11: materialize the manual change as a `changes` row
# first so the resolved card binds 1:1 to the commit.
@@ -898,10 +939,10 @@ def make_router(
# Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache. For super-draft,
# extract just the body part from the entry envelope.
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
prompt_text = body.text
if body.quote:
@@ -981,22 +1022,42 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _repo_for(rfc) -> tuple[str, str]:
def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
return True
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
if _is_meta_target(rfc, branch):
return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
def _file_path_for(rfc, branch: str = "main") -> str:
if _is_meta_target(rfc, branch):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body. For
active RFCs the file is just RFC.md and the whole thing is body."""
if not _is_super_draft(rfc):
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
"""For super-draft entries (and active-RFC pre-graduation reads
per §9.8) the file on disk is the full frontmatter+body envelope;
the editable body is entry.body. For active RFCs reading their
per-RFC repo the file is just RFC.md and the whole thing is body."""
if not _is_meta_target(rfc, branch):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -1004,10 +1065,10 @@ def make_router(
return file_contents
return entry.body
def _wrap_body(rfc, prior_contents: str, new_body: str) -> str:
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
"""Inverse of _extract_body: re-wrap a new body into the entry
envelope, preserving the prior frontmatter exactly."""
if not _is_super_draft(rfc):
if not _is_meta_target(rfc, branch):
return new_body
entry = entry_mod.parse(prior_contents)
# Ensure exactly one trailing newline so the serializer's
@@ -1125,6 +1186,13 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
@@ -1150,7 +1218,8 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch")