fix: mutation budget counts server-side changes only (Closes #617)

Auto-mode classifier now distinguishes local validator rejection,
capability-gate rejection, transport failure before API, and successful
server-side mutation. Pre-API validator failures no longer consume
server-side mutation budget; the final report separately accounts for
local failed attempts, blocked API attempts, and successful server-side
mutations.

Recovered from preserved unpublished commit b46f0f9 via native MCP
unpublished-claim recovery (#772) and author-worktree lock binding (#618),
reconciled onto current master.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-20 18:58:40 -05:00
co-authored by Claude Opus 4.8
parent 702ceb2480
commit 296601647d
3 changed files with 746 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
"""Mutation-budget classification for auto-mode attempts (#617).
Mutation budget must count only *server-side* Gitea state changes. A tool call
that fails closed before the Gitea API is reached changed nothing on the
server, so it must not consume the budget that protects against repeated real
mutations.
The classifier separates four outcome classes plus an explicit ambiguous class:
``local_validator_rejection``
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
``## Canonical Issue State`` blocks) rejected the payload before any API
call. No server-side state exists.
``capability_gate_rejection``
A profile/permission gate refused the operation before any API call.
``transport_failure_before_api``
The request never reached the Gitea API (transport/EOF/connection error).
``server_side_mutation``
The API succeeded and returned proof of durable state (comment id, review
id, merge commit, label result, or an issue/PR state change).
``ambiguous_requires_readback``
The API *was* reached but the result carries no usable proof either way.
This fails closed: the attempt is treated as budget-consuming until a
read-after-write check proves otherwise, so #617 never weakens the guard
that prevents repeated real mutations.
Only ``server_side_mutation`` consumes budget outright. Every attempt — failed
or not — is still recorded in the local attempt ledger so a final report can
show local failed attempts, blocked API attempts, and successful server-side
mutations separately.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
SERVER_SIDE_MUTATION = "server_side_mutation"
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
SERVER_SIDE_MUTATION,
AMBIGUOUS_REQUIRES_READBACK,
)
#: Result fields that prove durable server-side state was created.
MUTATION_PROOF_FIELDS = (
"comment_id",
"review_id",
"merge_commit_sha",
"label_result",
"state_change",
"created_pr_number",
)
#: Classes that never consume server-side mutation budget.
PRE_API_CLASSIFICATIONS = (
LOCAL_VALIDATOR_REJECTION,
CAPABILITY_GATE_REJECTION,
TRANSPORT_FAILURE_BEFORE_API,
)
FINAL_REPORT_REQUIRED_FIELDS = (
"local_failed_attempts",
"blocked_api_attempts",
"successful_server_mutations",
)
def _clean(value: Any) -> str:
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
def _proof_fields_present(result: dict) -> list[str]:
"""Return the mutation-proof fields carrying a usable value."""
present: list[str] = []
for field in MUTATION_PROOF_FIELDS:
value = result.get(field)
if value is None or value is False:
continue
if isinstance(value, str) and not value.strip():
continue
present.append(field)
return present
def _decision(
classification: str,
*,
budget_consumed: bool,
requires_readback: bool,
reasons: list[str],
proof_fields: list[str],
api_called: bool | None,
) -> dict:
return {
"classification": classification,
"budget_consumed": budget_consumed,
"requires_readback": requires_readback,
"pre_api": classification in PRE_API_CLASSIFICATIONS,
"api_called": api_called,
"proof_fields": proof_fields,
"reasons": reasons,
}
def classify_mutation_attempt(result: dict | None) -> dict:
"""Classify one mutation attempt and decide whether it consumes budget.
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
pre-interpret it: classification is driven by the explicit ``api_called``
signal plus the proof fields the tool reports.
"""
data = dict(result or {})
success = bool(data.get("success"))
proof_fields = _proof_fields_present(data)
api_called = data.get("api_called")
# An unambiguous success carrying durable proof is a real mutation however
# the attempt was labelled upstream.
if success and proof_fields:
return _decision(
SERVER_SIDE_MUTATION,
budget_consumed=True,
requires_readback=False,
reasons=[
"API reported success with durable proof field(s): "
+ ", ".join(proof_fields)
],
proof_fields=proof_fields,
api_called=True,
)
if api_called is False:
# Nothing reached the server; pick the precise pre-API class.
if data.get("transport_error") or data.get("transport_failed"):
return _decision(
TRANSPORT_FAILURE_BEFORE_API,
budget_consumed=False,
requires_readback=False,
reasons=["transport failed before the Gitea API was reached"],
proof_fields=[],
api_called=False,
)
if data.get("permission_report") or data.get("capability_blocked"):
return _decision(
CAPABILITY_GATE_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=["capability/permission gate refused before any API call"],
proof_fields=[],
api_called=False,
)
return _decision(
LOCAL_VALIDATOR_REJECTION,
budget_consumed=False,
requires_readback=False,
reasons=[
"local validator rejected the payload before any API call; "
"no server-side state was created"
],
proof_fields=[],
api_called=False,
)
if api_called is True:
if success:
reason = (
"API reported success but returned no durable proof field; "
"read-after-write verification required before counting budget"
)
else:
reason = (
"API was reached and the outcome carries no durable proof; "
"read-after-write verification required before counting budget"
)
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[reason],
proof_fields=proof_fields,
api_called=True,
)
# ``api_called`` was not reported at all. Fail closed rather than assuming
# nothing happened.
return _decision(
AMBIGUOUS_REQUIRES_READBACK,
budget_consumed=True,
requires_readback=True,
reasons=[
"attempt did not report 'api_called'; cannot prove the request "
"stopped before the Gitea API, so the attempt fails closed"
],
proof_fields=proof_fields,
api_called=None,
)
def record_attempt(
ledger: list[dict] | None,
result: dict | None,
*,
operation: str = "",
timestamp: str | None = None,
) -> dict:
"""Append one classified attempt to the local ledger and return the entry.
Every attempt is recorded, including the ones that consume no budget: the
point of #617 is that failed local attempts stay visible without being
miscounted as Gitea mutations.
"""
entries = ledger if isinstance(ledger, list) else []
entry = {
"operation": _clean(operation),
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
**classify_mutation_attempt(result),
}
entries.append(entry)
return entry
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
"""Summarize a ledger into the categories a final report must show."""
entries = [e for e in (ledger or []) if isinstance(e, dict)]
def _count(*classifications: str) -> int:
return sum(1 for e in entries if e.get("classification") in classifications)
return {
"total_attempts": len(entries),
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
"blocked_api_attempts": _count(
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
),
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
"requires_readback": any(e.get("requires_readback") for e in entries),
"entries": entries,
}
def assess_final_report_mutation_accounting(
report: dict | None,
ledger: list[dict] | None,
) -> dict:
"""Fail closed when a report's mutation accounting contradicts the ledger."""
data = dict(report or {})
summary = summarize_attempt_ledger(ledger)
reasons: list[str] = []
for field in FINAL_REPORT_REQUIRED_FIELDS:
if field not in data:
reasons.append(f"final report omits required field '{field}'")
continue
claimed = data.get(field)
actual = summary[field]
if claimed != actual:
reasons.append(
f"final report claims {field}={claimed} but the attempt ledger "
f"shows {actual}"
)
if summary["requires_readback"] and not data.get("readback_verified"):
reasons.append(
"ledger contains an ambiguous attempt; final report must record "
"'readback_verified' proof before claiming mutation accounting"
)
return {
"valid": not reasons,
"reasons": reasons,
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
}