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

+29 -5
View File
@@ -30,7 +30,7 @@ import logging
import os
from typing import Any, Dict, List, Optional
from lib_py.delegation import mqtt_common
from lib_py.delegation import mqtt_common, notifier
logger = logging.getLogger("loop.engine")
@@ -65,6 +65,12 @@ REQUEST_TO_STATUS = {
# including a terminal one. Keep this list short and obviously status-free.
BOOKKEEPING_REQUESTS = ("notification_settled",)
# The only fields a bookkeeping request may write.
BOOKKEEPING_FIELDS = ("pending_notification",)
# Distinguishes "no expectation" from "expect the field to be None".
_UNSET = object()
# Only these sources may move a record out of a terminal state. An agent can
# never walk back its own terminal; that is the whole point of the CAS table.
OPERATOR_SOURCES = ("registry_cli", "operator")
@@ -122,7 +128,7 @@ class _Decision(object):
def _decide(record: Dict[str, Any], request: str, expected_status, expected_attempt,
source="unknown", fields=None) -> _Decision:
source="unknown", fields=None, expected_notification=_UNSET) -> _Decision:
"""Pure CAS decision. No I/O, so it is safe to call under the lock.
Implements the target table in docs/contracts/issue-5/state-transitions.md §3.
@@ -169,6 +175,24 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
"{} requires operator authority (source={!r})".format(request, source))
if request in BOOKKEEPING_REQUESTS:
# Bookkeeping is allowed in a terminal state precisely because it cannot
# change anything of consequence. That has to be enforced here, at the
# boundary, and not merely by what today's one caller happens to pass:
# otherwise a status-free request becomes an unrestricted merge on a
# record nothing else is allowed to touch.
extra = sorted(set(fields) - set(BOOKKEEPING_FIELDS))
if extra:
return _Decision(False, current,
"bookkeeping may not merge {}".format(extra),
conflict=True)
expected = expected_notification
if expected is not _UNSET:
current_intent = record.get("pending_notification")
if current_intent != expected:
# A newer intent replaced the one being settled while it was in
# flight. Clearing it now would drop a notification nobody sent.
return _Decision(False, current,
"notification intent changed while it was being sent")
return _Decision(True, None, "bookkeeping ({})".format(request))
if current in TERMINAL_STATUSES:
@@ -219,7 +243,7 @@ def _notification(job_id, kind, **fields) -> Dict[str, Any]:
def transition(job_id, request="merge", *, registry_dir=None, expected_status=None,
expected_attempt=None, fields=None, source="unknown",
notify=None) -> TransitionResult:
notify=None, expected_notification=_UNSET) -> TransitionResult:
"""Decide and commit one state transition. The only writer of job status.
Returns a result whose `notifications` have ALREADY been flushed; the field
@@ -233,7 +257,8 @@ def transition(job_id, request="merge", *, registry_dir=None, expected_status=No
record = mqtt_common.load_job(job_id, registry_dir)
from_status = record.get("status")
decision = _decide(record, request, expected_status, expected_attempt,
source=source, fields=fields)
source=source, fields=fields,
expected_notification=expected_notification)
notifications: List[Dict[str, Any]] = []
if decision.commit:
@@ -248,7 +273,6 @@ def transition(job_id, request="merge", *, registry_dir=None, expected_status=No
# One atomic commit records both the new state and the fact that
# the world has not been told yet. If the process dies before the
# publish, the record still says a notification is owed.
from lib_py.delegation import notifier
record[notifier.FIELD] = notifier.intent_for(
notify, record["transition_seq"], record["updated_at"],
detail=str(fields.get("detail", "")))