diff --git a/.agents/skills/lib_py/loop/policy.py b/.agents/skills/lib_py/loop/policy.py
index 005ae65..9042b9b 100644
--- a/.agents/skills/lib_py/loop/policy.py
+++ b/.agents/skills/lib_py/loop/policy.py
@@ -154,6 +154,15 @@ def decide(scenario) -> Decision:
if not filesystem.get("hash_verified"):
return Decision("hold", "report manifest hash unverified", attribution_ok=True)
+ # Side-effect evidence is enforced authoritatively in finalize.verify_manifest,
+ # which has the job record and therefore knows what was declared. The policy
+ # honours an explicit negative from the collector so the case is expressible
+ # as a scenario vector; absence here is not read as proof, because the
+ # manifest check is the layer that fails closed on missing evidence.
+ if filesystem.get("side_effects_verified") is False:
+ return Decision("hold", "declared side effect has no evidence in the commit manifest",
+ attribution_ok=True)
+
return Decision("candidate_approved",
"attribution, quiescence and a verified manifest all hold",
attribution_ok=True)
diff --git a/docs/contracts/issue-5/implementation_plan.md b/docs/contracts/issue-5/implementation_plan.md
index 7eb9a02..b107033 100644
--- a/docs/contracts/issue-5/implementation_plan.md
+++ b/docs/contracts/issue-5/implementation_plan.md
@@ -24,7 +24,7 @@
| **Phase 0**
*(현재)* | **사전 계약 고정 및 Fixture 수집** | • CLI, 이벤트, 리포트, 락 계약 기준선 문서화
• Herdr 0.8.2 네이티브 캡처 및 복합 Watcher 시나리오 2계층 분리
• 6개 백엔드 실측 증거 행렬 수집 | • 510개 전체 테스트 수집 및 회귀 0-Fail (100% 통과)
• Fixture 스키마 및 증거 경계 검증 통과
• 프로덕션 코드 무변경 diff 검증 | 미추적 산출물 즉시 삭제 (`rm -rf docs/contracts/issue-5 tests/fixtures/issue_5 tests/test_issue5_phase0_contracts.py`) |
| **Phase 1**
*(완료 v4.3.0)* | **`lib_py/delegation/` 추출** | • `lib.sh` L1833 이하 위임 헬퍼를 파이썬 서비스 클래스로 추출
• `start_watchdog`을 내부 명칭 `ensure_subscriber_daemon`으로 명명(기존 함수 별칭 보존)
• 장수 프로세스 단일 MQTT 연결 재사용 프로토타입 | • CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과
• 데몬 수명 및 PID 계약 일치 확인
• **자동완료 정책 변경 없음** | `MAM_DELEGATION_PY=0`으로 보존된 레거시 인라인 본체(`_*_legacy`)로 즉시 분기 (재배포·revert 불필요; 레거시 본체 제거는 Phase 4) |
| **Phase 2a**
*(완료, shadow-only)* | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현
• 모든 Writer에 대한 단일 CAS 트랜잭션 경계 구현
• 자동완료를 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동 | • 락 내 비동기/I/O 배제 검증
• 소켓 단절/스냅샷 재연결 백오프 검증
• Shadow 모드가 레지스트리 상태를 쓰지 않음을 검증 | 1) Shadow 모니터 프로세스 종료 및 인메모리 큐 폐기
2) CAS 서비스는 하위 호환 모드로 유지하거나 `MAM_ENGINE_CAS=0` 플래그로 비활성화하여 레거시 무조건 필드 병합(`update_job_status`)으로 복귀
3) 기존 셸 루프 상태 및 레코드 불변 유지 |
-| **Phase 2b** | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동
• Pending Notification 지연 알림 및 크래시 복구 구현
• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료
• 경쟁/재시작 시나리오 통과
• 플래그 OFF 시 기존 explicit event 경로 100% 보존 | 환경변수/플래그 `MAM_AUTO_COMPLETE=0` 설정; 이미 확정된 terminal 레코드는 보존하고 신규 자동완료만 중단 |
+| **Phase 2b**
*(완료, 기본 비활성)* | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동
• Pending Notification 지연 알림 및 크래시 복구 구현
• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료
• 경쟁/재시작 시나리오 통과
• 플래그 OFF 시 기존 explicit event 경로 100% 보존 | 환경변수/플래그 `MAM_AUTO_COMPLETE=0` 설정; 이미 확정된 terminal 레코드는 보존하고 신규 자동완료만 중단 |
| **Phase 3** | **`run_loop.sh` Thin Wrapper 전환 및 전면 회귀** | • `run_loop.sh`를 인자 전달 래퍼(`exec python3 -m lib_py.loop "$@"`)로 전환
• 플래너/리뷰어/반론/승격 오케스트레이션 로직 완전 이식
• 엄격한 판정 파서(Strict Parser) 적용 | • 5개 subprocess 전용 테스트(~1,621줄) 포함 전체 481개+ 테스트 0-Fail
• 시그널, 루프 가드, 임의 CWD 및 공백 경로 검증 통과 | **우아한 핸드오프 시퀀스**:
1) Quiesce: 실행 중인 Python 루프 프로세스에 종료 신호를 전달하여 신규 디스패치 중단
2) Checkpoint: 현재 루프 회차, 활성 Job ID 및 리뷰 상태를 `.mam/loop-checkpoint.json`에 원자적 기록
3) Exit: Python 루프 정상 종료 및 가드 해제
4) Resume: 백업된 셸 원본 스크립트로 `run_loop.sh` 복원 후 체크포인트 기반 재개 검증 |
| **Phase 4** | **불변 런타임 검증 및 Freeze 제거** | • 기동 시 전체 모듈 상주(Eager Import) 및 자식 템플릿 불변성 검증
• 작업 도중 스킬 파일 교체 및 늦은 경로 스트레스 테스트 통과
• `/tmp/mam-loop-freeze.XXXXXX` 우회책 완전 제거 | • 런타임 자기수정 크래시(B-13) 재발 방지 검증
• Live Helper 버전 호환 게이트 검증 통과
• 운영 문서 및 배포 스크립트 갱신 | Freeze 복사 로직(`run_loop.sh:112-127`) 재활성화 및 라이브 헬퍼 버전 검사 복구 |
diff --git a/docs/contracts/issue-5/phase2b-autocomplete.md b/docs/contracts/issue-5/phase2b-autocomplete.md
new file mode 100644
index 0000000..015bd18
--- /dev/null
+++ b/docs/contracts/issue-5/phase2b-autocomplete.md
@@ -0,0 +1,84 @@
+# 📑 Issue #5 Phase 2b — 자동완료 운영 노트 (Flag-Gated / Opt-In)
+
+- **문서 버전**: Rev.1
+- **상태**: 구현 완료, **기본 비활성(`MAM_AUTO_COMPLETE=0`)**
+
+---
+
+## 1. 한 문장 요약
+
+**끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언하고, 읽는 쪽은 그 선언을 믿지 않고 해시를 다시 계산한다.**
+
+워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이다. 이 이슈의 앞선 라운드에서 리뷰어가 기각한 증거 형태이며, 같은 실수를 반복하지 않기 위한 설계 결정이다.
+
+## 2. 그래서 미지원 에이전트는 자동으로 안전하다
+
+확정을 선언하지 않은 에이전트는 매니페스트를 남기지 않는다. 매니페스트가 없으면 정책이 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). **"미지원 경로 100% 보존"은 새 분기가 아니라 기존 동작의 귀결이다.**
+
+## 3. 3중 게이트 (전부 참일 때만 커밋)
+
+| 게이트 | 기본값 | 근거 |
+|---|---|---|
+| `MAM_AUTO_COMPLETE=1` | **`0` (OFF)** | 전역 킬 스위치 |
+| 백엔드 허용목록 | `claude,grok,codex` | 정지 상태(`idle`/`done`) **관측 증거가 있는 백엔드만**. `agy`는 `working`만 관측, `hermes`·`opencode`는 관측 0건 |
+| 잡별 opt-in | 레코드 `auto_complete: true` | 잡 단위 동의 |
+
+`MAM_AUTO_COMPLETE_BACKENDS`로 목록을 **좁힐 수만** 있다. 관측된 적 없는 이름은 무시된다 — 넓히려면 설정이 아니라 증거가 필요하다.
+
+## 4. 확정 계약 (P2B-01)
+
+```
+에이전트: publish_event.py --job --event completed \
+ --finalize-report [--side-effect =[] ...
+ ① .tmp → 원자적 rename (반쯤 쓰인 리포트를 최종본으로 볼 수 없게)
+ ② SHA-256 계산
+ ③ report-manifest.json 원자적 기록 (job_id, attempt_id, sha256, side_effects, declared_by)
+```
+
+- **리포트를 다시 쓰면 선언은 즉시 무효**가 된다. 해시를 다시 계산해 대조하기 때문이다. 새 본문에는 새 확정이 필요하다.
+- **선언한 부수효과는 전부 증거가 있어야 한다.** 일부만 있는 것은 없는 것으로 취급한다 — 작업이 끝났다고 알 수 없기 때문이다.
+- 확정은 발행 **앞에** 일어난다. 리포트를 아직 쓰는 중인 `completed` 이벤트는 계약이 금지하는 부분 리포트 채택을 부른다.
+
+## 5. 지연 알림 (P2B-02)
+
+```
+락 안: 상태 커밋 + pending_notification 기록 ← 하나의 원자적 쓰기
+락 밖: 발행 → 성공 시 notification_settled 전이로 정리
+기동 시: pending_notification이 남은 레코드를 재발행 (멱등: 구독자가 seq로 중복 제거)
+```
+
+- **독립 Outbox 저장소를 만들지 않는다**(`state-transitions.md` §5 명시). 이미 쓰는 레코드에 필드 하나를 더 쓴다.
+- **발행 실패는 의도적으로 intent를 남긴다.** 보내지 않은 알림이 보낸 것처럼 보이는 편이, 재시도되는 편보다 나쁘다.
+- `notification_settled`는 **상태를 바꾸지 않는 bookkeeping 요청**이라 terminal 상태에서도 허용된다. 알림은 terminal에 *관한* 것이므로, terminal이 거부하는 요청으로는 영원히 정리할 수 없다.
+
+## 6. 무판정 fail-closed
+
+매니페스트를 읽지 못함, 정책이 예외를 던짐, 알 수 없는 결정 — 전부 `hold`다. **"판정하지 못함"은 결코 "완료"가 아니다.** 늦게 도착한 명시 terminal이 있으면 전이 경계가 추론 완료를 거부하며, 그것은 경계가 제 일을 한 것이지 게이트의 오류가 아니다.
+
+## 7. 감사 표식
+
+자동완료로 들어간 terminal에는 `completed_by: "auto_complete"`와 판정 사유가 남는다. 구독자와 래퍼는 자동/명시 완료를 구별하지 못하므로(`job_subscriber._check_disk_fallback`은 레지스트리 status만 본다), **사후에 구별할 수 있는 유일한 수단**이다.
+
+## 8. 롤백
+
+1. `MAM_AUTO_COMPLETE=0` — 신규 자동완료만 중단. **이미 확정된 terminal 레코드는 보존**한다.
+2. `MAM_ENGINE_CAS=0` — Phase 2a 경로까지 되돌린다(자동완료는 그 위에 있으므로 함께 무력화).
+3. `.mam/shadow/`는 레지스트리와 분리되어 있어 삭제해도 job 상태에 영향이 없다.
+
+## 9. 계획 대비 이탈 1건 (레코드 스키마 미변경)
+
+계획서 §D2는 `attempt_id`를 job 레코드의 1급 필드로 승격할 것을 권고하고, Phase 1이 동결한 "레코드 필드 불변" 계약의 변경이므로 **리뷰어 승인을 선행 조건**으로 걸었다.
+
+구현 중 실측한 결과 **그 변경이 필요하지 않았다**:
+- 엔진의 `expected_attempt`는 레코드의 `attempt_id`(없음)를 `iteration`(루프 회차)으로 대체 비교한다 — 두 개념이 달라 의미가 성립하지 않는다.
+- 반면 **시도 식별은 이미 증거 계층에서 검증된다**: `policy`가 관측 식별자와 shadow 저장소의 dispatch 레코드를 대조하며, 시도가 어긋나면 `hold`한다(실측 확인).
+
+따라서 자동완료는 엔진에 `expected_attempt`를 넘기지 않고 `expected_status="running"`(엔진의 고유 영역인 상태)만 전제조건으로 준다. **시도 보증은 시도 정체성이 실제로 사는 곳에 둔다.** 레코드 스키마는 바뀌지 않았고, Phase 1 계약도 유지된다.
+
+`attempt_id` 승격은 열린 질문으로 남긴다. 승격하면 엔진에 2차 방어선이 생기지만 동결 계약을 바꿔야 하며, 현재는 필요하지 않다. `test_x03_the_record_carries_iteration_not_an_attempt_id`가 이 전제를 감시한다 — 레코드가 `attempt_id`를 갖게 되는 순간 실패하며, 그때가 재검토 시점이다.
+
+## 10. Phase 3 인계
+
+1. `preexec_fn`: 2b도 데몬을 스폰하지 않아 미노출 유지. 알림 flush는 기존 구독자 프로세스 안에서 수행한다.
+2. 부수효과의 **원격** 확인(실제 push 도달 여부 등)은 비범위 — 증거의 존재·대응까지만 검증한다.
+3. `agy`/`hermes`/`opencode` 자동완료는 정지 상태 실측 증거 확보 후 별도 판단.
diff --git a/docs/contracts/issue-5/task.md b/docs/contracts/issue-5/task.md
index e9050f6..96d714c 100644
--- a/docs/contracts/issue-5/task.md
+++ b/docs/contracts/issue-5/task.md
@@ -2,7 +2,7 @@
- **Document Version**: Rev.7 (Phase 2a 실행 완료 — P2A-01~P2A-04 증거 기록)
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
-- **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / Phase 2b–4 Staged
+- **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / **Phase 2b Complete (default-off)** / Phase 3–4 Staged
---
@@ -94,10 +94,19 @@
---
-## Phase 2b: 이중 감지 자동완료 활성화 (후속)
-- [ ] **P2B-01** report finalization/manifest/hash 및 외부 부수효과 evidence protocol 구현.
-- [ ] **P2B-02** terminal+pending notification commit/재전송·재시작·늦은 conflict 처리 검증.
-- [ ] **P2B-03** 검증된 backend/job에만 default-off flag로 활성화; unsupported explicit event 경로 보존.
+## Phase 2b: 이중 감지 자동완료 활성화 (완료, 기본 비활성)
+
+- [x] **P2B-00** 자동완료 도입 전 fail-closed 선행 조건 특성화 (X-01~X-05).
+ - *증거*: 커밋 `9a827b3`. 매니페스트 없는 job이 이미 `hold`임을 고정 — 미지원 경로 보존이 새 분기 없이 성립하는 근거.
+- [x] **P2B-01** 리포트 확정 계약(매니페스트·SHA-256) 및 부수효과 증거 프로토콜.
+ - *증거*: 커밋 `3ea5757`. `lib_py/loop/finalize.py` + `publish_event --finalize-report/--side-effect`. **선언은 에이전트가, 해시 재계산은 읽는 쪽이** — 워처가 만든 매니페스트는 자기 증명이므로 채택하지 않았다. 리포트 재작성 시 즉시 무효, 선언한 부수효과는 전부 증거 필요.
+- [x] **P2B-02** terminal + pending notification, 재전송·재시작·늦은 conflict.
+ - *증거*: 커밋 `803ba43`. `pending_notification`을 상태와 **같은 원자적 쓰기**로 커밋, 락 밖 발행, `notification_settled`(상태 무변경 bookkeeping 요청 — terminal에서도 허용)로 정리, 기동 시 재발행. 발행 실패 시 intent 보존.
+- [x] **P2B-03** 검증된 backend/job 한정 default-off 플래그.
+ - *증거*: 커밋 `803ba43`. 3중 게이트(전역 OFF 기본 / 관측 증거 기반 허용목록 `claude,grok,codex` / 잡별 opt-in). 허용목록은 좁히기만 가능. 자동완료 terminal에 `completed_by` 표식.
+- [x] **P2B-04** 부수효과 예외·무판정 fail-closed·경쟁/재시작 시나리오.
+ - *증거*: 신규 시나리오 3종(`side_effect_unproven`, `no_verdict_fail_closed`, `notification_crash_window`)을 Phase 0 스키마로 추가, **총 15종 전부** `expected_policy.decision` 일치.
+ - *계획 대비 이탈*: 계획서 §D2의 `attempt_id` 레코드 승격(및 그 선행 조건인 리뷰어 승인)은 **불필요함이 실측으로 확인되어 수행하지 않았다.** 시도 보증은 이미 증거 계층(`policy` ↔ shadow dispatch 대조)에 있으며, 레코드 스키마와 Phase 1 동결 계약은 그대로다. 상세는 `phase2b-autocomplete.md` §9.
---
diff --git a/tests/fixtures/issue_5/watcher/manifest.json b/tests/fixtures/issue_5/watcher/manifest.json
index 3a5ba94..44fdc05 100644
--- a/tests/fixtures/issue_5/watcher/manifest.json
+++ b/tests/fixtures/issue_5/watcher/manifest.json
@@ -73,6 +73,24 @@
"provenance": "simulated",
"expected_decision": "reject_stale",
"description": "The pane was restarted mid-flight, so the idle signal comes from a different pane than the one dispatched to."
+ },
+ "side_effect_unproven": {
+ "file": "side_effect_unproven.json",
+ "provenance": "simulated",
+ "expected_decision": "hold",
+ "description": "The job declared an external side effect but the manifest carries no evidence for it. Partial evidence is no evidence."
+ },
+ "no_verdict_fail_closed": {
+ "file": "no_verdict_fail_closed.json",
+ "provenance": "simulated",
+ "expected_decision": "hold",
+ "description": "The collector could not establish the agent session, so attribution cannot be completed. An undecidable case must hold."
+ },
+ "notification_crash_window": {
+ "file": "notification_crash_window.json",
+ "provenance": "simulated",
+ "expected_decision": "hold",
+ "description": "The agent's state became unreadable between finalization and notification. Unknown is not quiescent."
}
}
}
diff --git a/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json b/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json
new file mode 100644
index 0000000..2efa1b3
--- /dev/null
+++ b/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json
@@ -0,0 +1,66 @@
+{
+ "schema_version": "1.0.0",
+ "scenario_id": "no_verdict_fail_closed",
+ "backend": "backend_agnostic",
+ "provenance": "simulated",
+ "description": "The collector could not establish the agent session, so attribution cannot be completed. An undecidable case must hold.",
+ "dispatch_context": {
+ "job_id": "test-job-no-verdict-01",
+ "attempt_id": 1,
+ "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
+ "dispatch_ack": true,
+ "execution_anchor_observed": true
+ },
+ "timeline": [
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 0,
+ "source_kind": "event",
+ "source_provenance": "simulated",
+ "payload": {
+ "event": "started",
+ "job_id": "test-job-no-verdict-01"
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 5000,
+ "source_kind": "filesystem",
+ "source_provenance": "simulated",
+ "payload": {
+ "report_manifest": {
+ "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "attempt_id": 1,
+ "finalized": true
+ }
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 6000,
+ "source_kind": "herdr",
+ "source_provenance": "simulated",
+ "payload": {
+ "agent_status": "idle",
+ "interactive_ready": true
+ },
+ "collector_attribution": {
+ "job_id": "test-job-no-verdict-01",
+ "attempt_id": 1,
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
+ }
+ }
+ ],
+ "filesystem": {
+ "report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
+ "exists": true,
+ "manifest_committed": true,
+ "hash_verified": true
+ },
+ "expected_policy": {
+ "decision": "hold",
+ "reason": "no observed agent_session_uuid to attribute this completion to",
+ "policy_version": "2.0.0"
+ }
+}
diff --git a/tests/fixtures/issue_5/watcher/notification_crash_window.json b/tests/fixtures/issue_5/watcher/notification_crash_window.json
new file mode 100644
index 0000000..b30dfde
--- /dev/null
+++ b/tests/fixtures/issue_5/watcher/notification_crash_window.json
@@ -0,0 +1,67 @@
+{
+ "schema_version": "1.0.0",
+ "scenario_id": "notification_crash_window",
+ "backend": "backend_agnostic",
+ "provenance": "simulated",
+ "description": "The agent's state became unreadable between finalization and notification. Unknown is not quiescent.",
+ "dispatch_context": {
+ "job_id": "test-job-notify-crash-01",
+ "attempt_id": 1,
+ "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
+ "dispatch_ack": true,
+ "execution_anchor_observed": true
+ },
+ "timeline": [
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 0,
+ "source_kind": "event",
+ "source_provenance": "simulated",
+ "payload": {
+ "event": "started",
+ "job_id": "test-job-notify-crash-01"
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 5000,
+ "source_kind": "filesystem",
+ "source_provenance": "simulated",
+ "payload": {
+ "report_manifest": {
+ "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "attempt_id": 1,
+ "finalized": true
+ }
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 6000,
+ "source_kind": "herdr",
+ "source_provenance": "simulated",
+ "payload": {
+ "agent_status": "unknown",
+ "interactive_ready": false
+ },
+ "collector_attribution": {
+ "job_id": "test-job-notify-crash-01",
+ "attempt_id": 1,
+ "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
+ }
+ }
+ ],
+ "filesystem": {
+ "report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
+ "exists": true,
+ "manifest_committed": true,
+ "hash_verified": true
+ },
+ "expected_policy": {
+ "decision": "hold",
+ "reason": "agent status is unknown; fail closed",
+ "policy_version": "2.0.0"
+ }
+}
diff --git a/tests/fixtures/issue_5/watcher/side_effect_unproven.json b/tests/fixtures/issue_5/watcher/side_effect_unproven.json
new file mode 100644
index 0000000..b03f22c
--- /dev/null
+++ b/tests/fixtures/issue_5/watcher/side_effect_unproven.json
@@ -0,0 +1,68 @@
+{
+ "schema_version": "1.0.0",
+ "scenario_id": "side_effect_unproven",
+ "backend": "backend_agnostic",
+ "provenance": "simulated",
+ "description": "The job declared an external side effect but the manifest carries no evidence for it. Partial evidence is no evidence.",
+ "dispatch_context": {
+ "job_id": "test-job-sideeffect-missing-01",
+ "attempt_id": 1,
+ "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
+ "dispatch_ack": true,
+ "execution_anchor_observed": true
+ },
+ "timeline": [
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 0,
+ "source_kind": "event",
+ "source_provenance": "simulated",
+ "payload": {
+ "event": "started",
+ "job_id": "test-job-sideeffect-missing-01"
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 5000,
+ "source_kind": "filesystem",
+ "source_provenance": "simulated",
+ "payload": {
+ "report_manifest": {
+ "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "attempt_id": 1,
+ "finalized": true
+ }
+ }
+ },
+ {
+ "collector_clock_id": "CLOCK_MONOTONIC",
+ "relative_ms": 6000,
+ "source_kind": "herdr",
+ "source_provenance": "simulated",
+ "payload": {
+ "agent_status": "idle",
+ "interactive_ready": true
+ },
+ "collector_attribution": {
+ "job_id": "test-job-sideeffect-missing-01",
+ "attempt_id": 1,
+ "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
+ "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
+ }
+ }
+ ],
+ "filesystem": {
+ "report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
+ "exists": true,
+ "manifest_committed": true,
+ "hash_verified": true,
+ "side_effects_verified": false
+ },
+ "expected_policy": {
+ "decision": "hold",
+ "reason": "declared side effect has no evidence in the commit manifest",
+ "policy_version": "2.0.0"
+ }
+}
diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py
index 89b6289..3149f61 100644
--- a/tests/test_issue5_phase2a_loop.py
+++ b/tests/test_issue5_phase2a_loop.py
@@ -560,6 +560,8 @@ def _scenarios():
# ... and the five P2A-04 requires, held to the same contract
"race_double_dispatch", "attempt_reuse", "partial_report",
"unknown_status", "restart_midflight",
+ # ... and the three phase 2b adds, held to the same contract
+ "side_effect_unproven", "no_verdict_fail_closed", "notification_crash_window",
])
def test_g7_policy_matches_the_phase0_expected_decision(scenario_id):
"""G7: the seven scenarios that survived three-reviewer scrutiny are the
]