fix(issue-5): address phase 2a cross-review findings (F-A, F-B, R1, R2, R3)

Two reviewers raised five defects. Every one was reproduced here before being
accepted; none were rebutted.

F-A (claude, blocking) -- the CAS table had no row for two transitions the
wrapper legitimately makes, so `submit --type loop|discuss` silently stopped
working by default: the per-iteration re-dispatch was refused and its metadata
discarded, the exhausted-budget finalisation was refused, and the CLI returned 0
both times. Reproduced against the real modules. Fixed by naming those
transitions rather than carving an exception: `redispatch` and `finalize` are
requests gated on operator authority, so the wrapper can move a finished record
while an agent still cannot touch its own terminal. A re-dispatch must advance
the iteration, so a replay cannot resurrect a job. The operator CLI now exits
non-zero with a reason when a write is refused -- the silence is what let this
hide.

F-B (claude) -- moving the audit mirror out of the lock made status.json
last-writer-wins, so a slow flush could leave it behind the registry. It now
carries transition_seq and skips a write that would go backwards, restoring the
sequential consistency the lock used to provide for free.

R1 (codex, blocking) -- the policy called attribution with only the dispatch
record, so it proved the record was well-formed and never confronted the
identifiers the evidence carried. A finalized scenario with a different session,
a different attempt and a manifest from another attempt was still approved.
Observed identifiers are now collected from the timeline and every one must
agree; a manifest belonging to another attempt holds. Collecting every
occurrence rather than the first matters, because taking the first would drop
exactly the contradicting value this is meant to catch.

R2 (codex, blocking) -- a supplied expected_attempt was ignored when the record
had no attempt field, so a compare-and-swap with nothing to compare committed.
It now fails closed, which matters because 2b plans to gate inferred completion
on precisely that precondition.

R3 (codex) -- a status could ride in as an ordinary field, so `progress` could
commit a terminal from inside the boundary that exists to prevent it. Status is
now settable only by the request that owns it, unknown requests are refused, and
pending has an owning request instead of being an untyped merge.

Each of the eleven new regressions was checked against the pre-fix code first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-06 19:33:24 +09:00
1 parent 17a2dcd988
commit 56d08efa95
6 files changed
+378 -23

No files matched your search

+46 -1
View File
@@ -25,6 +25,31 @@ def _timeline_by_kind(scenario):
return out
def collect_observed_identity(scenario) -> dict:
"""Gather identifiers the *evidence* carries, for comparison with dispatch.
Without this, attribution only proves the dispatch record is internally
well-formed -- it never confronts what was actually seen. Association of a
job or attempt to an observation is the external collector's business, so
these are read from the timeline items and the report manifest, never
invented as native Herdr fields.
"""
observed = {}
for item in scenario.get("timeline") or []:
payload = item.get("payload") or {}
for field in ("job_id", "attempt_id", "agent_session_uuid",
"pane_process_generation"):
if payload.get(field) is not None:
# Every occurrence is kept. Taking only the first would let a
# later, contradicting observation pass unnoticed -- which is the
# opposite of what this collection is for.
observed.setdefault(field, []).append(payload[field])
manifest = payload.get("report_manifest")
if isinstance(manifest, dict) and manifest.get("attempt_id") is not None:
observed.setdefault("manifest_attempt_id", []).append(manifest["attempt_id"])
return observed
def decide(scenario) -> Decision:
"""Map one composite scenario to a decision.
@@ -62,11 +87,31 @@ def decide(scenario) -> Decision:
if latest.get("agent_status") == "blocked" or latest.get("matched_rule"):
return Decision("hold_blocked", "agent is blocked on an interactive prompt")
# 4. Attribution, including the execution anchor.
# 4. Attribution: the dispatch record must be well formed AND agree with the
# identifiers the evidence itself carries. Flags on the dispatch record
# alone are self-assertion, not correspondence.
observed = collect_observed_identity(scenario)
manifest_attempts = observed.pop("manifest_attempt_id", [])
attributed = attribution.check(dispatch)
if not attributed.ok:
return Decision("hold", attributed.reason)
for field, values in observed.items():
for value in values:
if value != dispatch.get(field):
return Decision("hold", "attribution mismatch on {}: observed {!r}, "
"dispatched {!r}".format(field, value,
dispatch.get(field)))
# The finalized report must belong to the attempt being judged; a manifest
# from another attempt is evidence of a different run finishing.
for manifest_attempt in manifest_attempts:
if manifest_attempt != dispatch.get("attempt_id"):
return Decision("hold",
"report manifest belongs to attempt {!r}, not {!r}".format(
manifest_attempt, dispatch.get("attempt_id")))
# 5. Still working, or a tool is running underneath a ready prompt.
if latest.get("agent_status") not in QUIESCENT:
return Decision("hold", "agent is not quiescent (status={!r})".format(