fix(issue-5): close the phase 2b gate holes found by all three reviewers

Seven blocking items across three reviews, every one reproduced here before
being accepted. Two themes run through them: a gate whose strictness depended on
what the caller remembered to pass, and a boundary that trusted its only current
caller instead of enforcing its own rule.

Independent verification is no longer optional. Omitting the report directory
skipped the disk check entirely and handed the decision back to the collector's
own flags -- the self-certification this design exists to prevent. It is now a
refusal, as is an empty directory or a job with no recorded dispatch.

Evidence is now bound to its target. The policy compares a scenario against the
dispatch context carried inside that same scenario, which is self-consistent by
construction, so one job's evidence could complete another. The gate now checks
the job, attempt, session and pane generation against the recorded dispatch.

A reviewer with no verdict token is no longer completed. Phase 0 made this a
safety contract and run_loop reads a missing token as NOT PASS, so inferring
completion first would start a correction round against a review that was never
finished. The requirement applies to reviewers only.

Bookkeeping may write one field. A status-free request was an unrestricted merge
on a terminal record: it could rewrite the prompt and the session. Settling is
also conditional now on the exact intent that was sent, because publishing
happens outside the lock and a newer intent can legitimately land mid-flight;
clearing unconditionally dropped a notification nobody had sent. The intent's
own identity travels in the payload, since the transport sequence cannot
identify a retry of the same intent.

Side-effect evidence must be evidence. A kind with no reference names something
without evidencing it, and the CLI parser could not be the only check because
the API and hand-written manifests bypass it.

Also honours MAM_ENGINE_CAS=0, which the operations note already promised
disables auto-completion, matches backend keys exactly rather than by prefix so
a future name cannot inherit evidence never collected for it, records an
absolute report path, and imports the notifier at module scope.

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 22:25:50 +09:00
1 parent 12cba25c18
commit cb7ed1aa38
5 files changed
+444 -26

No files matched your search

+263
View File
@@ -519,3 +519,266 @@ def test_g7_restart_recovery_resends_every_owed_notification(mam_sandbox, tmp_pa
results = notifier.recover(reg, publish=lambda j, i, r: sent.append(j) or 0)
assert all(results.values()) and sorted(sent) == sorted(owed)
assert notifier.jobs_with_pending(reg) == [], "nothing is owed after recovery"
# ==============================================================================
# 6. Cross-review regressions (job f63d8279)
#
# Three reviewers, seven blocking items, all reproduced before being accepted.
# The theme: a gate whose strictness depended on the caller, and a boundary that
# trusted its only current caller instead of enforcing its own rule.
# ==============================================================================
def test_f1_an_omitted_report_directory_is_a_refusal_not_a_shortcut(
mam_sandbox, tmp_path, monkeypatch
):
"""Independent verification cannot be optional: skipping it puts the
collector's own flags back in charge of the decision."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, _report_dir = _ready_job(tmp_path, mam_sandbox)
before = _fingerprint(Path(reg))
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox)) # no report_dir
assert result.committed is False and "independently verify" in result.reason
assert _fingerprint(Path(reg)) == before, "a refusal must not write"
def test_f1_an_empty_report_directory_is_also_a_refusal(mam_sandbox, tmp_path, monkeypatch):
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, _rd = _ready_job(tmp_path, mam_sandbox)
empty = tmp_path / "empty-reports"
empty.mkdir()
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=empty)
assert result.committed is False and "no completion manifest" in result.reason
def test_r2_evidence_for_another_job_cannot_complete_this_one(
mam_sandbox, tmp_path, monkeypatch
):
"""The policy checks a scenario against the dispatch context inside that same
scenario, which is self-consistent by construction. The gate must bind the
evidence to the target."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
scenario["dispatch_context"]["job_id"] = "some-other-job"
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "not" in result.reason
assert _load(reg, job_id)["status"] == "running"
@pytest.mark.parametrize("field", ["attempt_id", "agent_session_uuid", "pane_process_generation"])
def test_r2_evidence_must_match_the_recorded_dispatch(mam_sandbox, tmp_path, monkeypatch, field):
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
scenario["dispatch_context"][field] = "not-what-was-dispatched"
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and field in result.reason
def test_r2_a_job_with_no_recorded_dispatch_is_refused(mam_sandbox, tmp_path, monkeypatch):
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
(Path(mam_sandbox) / ".mam" / "shadow" / "attribution" / (job_id + ".json")).unlink()
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "no recorded dispatch" in result.reason
def test_the_cas_rollback_switch_also_disables_auto_completion(mam_sandbox, tmp_path, monkeypatch):
"""Inferred completion is built on the CAS boundary, so committing while
that boundary is rolled back would bypass it."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox)
monkeypatch.setenv("MAM_ENGINE_CAS", "0")
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "rolled back" in result.reason
assert _load(reg, job_id)["status"] == "running"
@pytest.mark.parametrize("agent", ["claude-experimental", "codexish", "grok2"])
def test_o4_a_backend_key_must_match_exactly_not_by_prefix(
mam_sandbox, tmp_path, monkeypatch, agent
):
"""A future name must not inherit evidence that was never collected for it."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox, backend=agent)
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "quiescent-state evidence" in result.reason
def test_a_reviewer_without_a_verdict_token_is_not_completed(mam_sandbox, tmp_path, monkeypatch):
"""Phase 0 safety contract: a reviewer that has not written its verdict has
not finished reviewing, and run_loop reads a missing token as NOT PASS."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.delegation import finalize
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(
tmp_path, mam_sandbox, body="a thorough review with no verdict line\n")
from lib_py.delegation import engine
engine.transition(job_id, "progress", registry_dir=reg, fields={"role": "Reviewer"})
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed is False and "verdict token" in result.reason
assert _load(reg, job_id)["status"] == "running"
# writing the verdict requires a fresh finalization, and then it passes
report = report_dir / "report-final.md"
report.write_text("a thorough review\n\n[VERDICT: PASS]\n", encoding="utf-8")
finalize.finalize_report(report, job_id, scenario["dispatch_context"]["attempt_id"])
ok = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert ok.committed, ok.reason
def test_a_worker_role_needs_no_verdict_token(mam_sandbox, tmp_path, monkeypatch):
"""The token requirement is a reviewer contract, not a general one."""
monkeypatch.chdir(mam_sandbox)
monkeypatch.setenv("MAM_AUTO_COMPLETE", "1")
from lib_py.loop import autocomplete
job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox,
body="worker output, no verdict\n")
assert _load(reg, job_id)["role"] == "Worker"
result = autocomplete.evaluate(job_id, scenario, registry_dir=reg,
workspace=str(mam_sandbox), report_dir=report_dir)
assert result.committed, result.reason
def test_grok_f2_bookkeeping_cannot_merge_arbitrary_fields(mam_sandbox, tmp_path, monkeypatch):
"""A status-free request must not become an unrestricted merge on a record
nothing else is allowed to touch."""
monkeypatch.chdir(mam_sandbox)
from lib_py.delegation import engine
reg = _reg(tmp_path)
job_id = _make_job(reg)
engine.transition(job_id, "claim", registry_dir=reg)
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
before = _load(reg, job_id)
result = engine.transition(job_id, "notification_settled", registry_dir=reg,
fields={"pending_notification": None, "prompt": "HACKED",
"agent_session": "herdr:attacker"},
source="not-an-operator")
after = _load(reg, job_id)
assert result.committed is False and "may not merge" in result.reason
assert after["prompt"] == before["prompt"]
assert after["agent_session"] == before["agent_session"]
assert after["pending_notification"], "the intent survives a refused settle"
# the legitimate settle still works
from lib_py.delegation import notifier
assert notifier.settle(job_id, reg, sent_intent=after["pending_notification"])
assert notifier.pending(job_id, reg) is None
def test_r4_a_newer_intent_is_not_erased_by_a_slow_send(mam_sandbox, tmp_path, monkeypatch):
"""Publishing happens outside the lock, so a replacement can land mid-flight.
Settling unconditionally would drop a notification nobody sent."""
monkeypatch.chdir(mam_sandbox)
from lib_py.delegation import engine, notifier
reg = _reg(tmp_path)
job_id = _make_job(reg)
engine.transition(job_id, "claim", registry_dir=reg)
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
def racing_publisher(job, intent, registry_dir):
engine.transition(job, "notification_settled", registry_dir=registry_dir,
fields={"pending_notification":
notifier.intent_for("error", 999, "later")})
return 0
notifier.flush(job_id, reg, publish=racing_publisher)
still_owed = notifier.pending(job_id, reg)
assert still_owed and still_owed["seq"] == 999, "the replacement must survive"
def test_r4_the_published_payload_carries_the_intent_identity(mam_sandbox, tmp_path, monkeypatch):
"""The transport sequence cannot identify a retry of the same intent, so the
intent's own identity travels in the payload for consumers to dedupe on."""
monkeypatch.chdir(mam_sandbox)
from lib_py.delegation import engine, notifier
reg = _reg(tmp_path)
job_id = _make_job(reg)
record = _load(reg, job_id)
record["broker"] = {"host": "127.0.0.1", "port": 65432, "tls": False}
Path(reg, job_id + ".json").write_text(json.dumps(record), encoding="utf-8")
engine.transition(job_id, "claim", registry_dir=reg)
engine.transition(job_id, "completed", registry_dir=reg, notify="completed")
intent = notifier.pending(job_id, reg)
notifier.flush(job_id, reg, attempts=1) # real path; broker is unreachable
from lib_py.delegation import mqtt_common
events = list(mqtt_common.iter_logged_events(job_id, mqtt_common.get_logs_dir()))
published = [e for e in events if e.get("event") == "published"]
assert published, "the attempt is recorded even though the broker refused"
carried = published[-1]["payload"]["data"].get("notification_intent")
assert carried and carried["seq"] == intent["seq"]
@pytest.mark.parametrize("entry", [
{"kind": "deploy"},
{"kind": "deploy", "reference": " "},
{"reference": "abc"},
"not-an-object",
])
def test_r3_a_side_effect_entry_must_carry_real_evidence(tmp_path, entry):
"""A kind with no reference names something without evidencing it."""
from lib_py.delegation import finalize
path = _report(tmp_path)
with pytest.raises(ValueError):
finalize.finalize_report(path, "job1", 1, side_effects=[entry])
# and a hand-written manifest is rejected at verification too
finalize.finalize_report(path, "job1", 1,
side_effects=[{"kind": "deploy", "reference": "sha"}])
manifest_file = finalize.manifest_in(path.parent)
forged = json.loads(manifest_file.read_text(encoding="utf-8"))
forged["side_effects"] = [entry]
manifest_file.write_text(json.dumps(forged), encoding="utf-8")
verdict = finalize.verify_manifest(path.parent, "job1", expected_artifacts=["deploy"])
assert not verdict.ok
def test_o2_the_manifest_records_an_absolute_report_path(tmp_path, monkeypatch):
"""A relative path would resolve against whatever directory the reader is in."""
from lib_py.delegation import finalize
path = _report(tmp_path)
monkeypatch.chdir(tmp_path)
manifest = finalize.finalize_report(Path("claude-reports/report-final.md"), "job1", 1)
assert Path(manifest["report_path"]).is_absolute()
monkeypatch.chdir(REPO_ROOT)
assert finalize.verify_manifest(path.parent, "job1").ok, "verification survives a chdir"