test(issue-5): harden watcher evidence validation and add codex pass review report

- Enforce evidence-root confinement for capture_ref and 1:1 capture citation uniqueness
- Add pane identity and backend identity cross-validation
- Add bidirectional agreement checks between watcher/herdr manifests and scenario fixtures
- Expand contract test suite to 41 tests (100% pass)
- Document evidence confinement rules in herdr-runtime.md and update task.md
- Archive Codex 4th review report (Job 1bd561e6: [VERDICT: PASS])
This commit is contained in:
Godopu committed 2026-09-06 14:31:41 +09:00
1 parent 19d798bf8f
commit fc0bd96d8a
6 files changed
+286 -15

No files matched your search

@@ -0,0 +1,29 @@
# Issue #5 Phase 0 final revision review
Job: 1bd561e6
Reviewer: planner-reviewer-codex-01 (planner,reviewer)
Scope: commit 19d798b and the current five-file documentation/contract-test diff.
No blocking findings remain in the requested revision. No supplementary implementation edits were necessary.
## Findings resolved
- **Previous Codex P1:** Observed evidence now dispatches through explicit supported source kinds. Event/filesystem/harness observations fail closed until independent evidence schemas exist; their simulated fixtures remain accepted. Herdr evidence must pass the native validator, contain mandatory agent_status, match every declared native field, and correspond to the scenario session identity. Current additions also check pane/backend identity, evidence-root confinement, repeated capture citations, and manifest/file agreement.
- Independently reran the exact prior finalized_candidate mutation that cited a Claude idle capture for every observed item. It now raises AssertionError at the first event item because no observed event schema exists. This closes the reproduced false-evidence path.
- **Claude Finding 2:** baseline.md now cites registry.py:38 for VALID_STATUSES. Verified that definition directly.
- Existing native/composite separation and simulated positive scenarios are preserved. The changes remain Phase 0 fixture validation and documentation, not a claim that the future production watcher or complete execution-attribution mechanism is implemented.
## Verification
- `./.venv/bin/python -m pytest tests/test_issue5_phase0_contracts.py -q`: **41 passed in 0.02s**.
- `./.venv/bin/python -m pytest --collect-only -q`: **510 tests collected**.
- Independent previous-exploit reproduction: **rejected**.
- `git diff --check`: clean.
- `git diff -- .agents/skills`: empty; current changes are four contract documents and one test file.
- Full-suite evidence in task.md reports **499 passed in 848.90s** at 19d798b, followed by the current 41 contract tests after the test/document-only additions. This reviewer independently ran the current focused tests and collection, not another full suite.
Nonblocking documentation note: interpret the roadmap's 510-test row as its acceptance target; task.md correctly distinguishes the 499-test full run from current 510-test collection and 41-test focused execution. It should not be quoted as an independently executed 510-test full run.
This PASS applies to the reviewed Phase 0 corrections. Future runtime/CAS/rollback implementation remains subject to the staged gates already documented.
[VERDICT: PASS]
+1 -1
View File
@@ -4,7 +4,7 @@
- **Issue**: [Gitea Issue #5 — `[Refactor] lib.sh 작업 위임 로직 및 run_loop.sh 오케스트레이터의 Python 모듈화 (lib_py 승격)`](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
- **Baseline Commit**: `47640e2fd7b108be29446a9384a343727e87edf8`
- **Environment Baseline**: Python 3.9.6, Herdr 0.8.2, macOS Darwin 24.3.0
- **Test Suite Baseline**: 469 tests collected initially; 499 tests collected with Phase 0 contract suite (+30 contract & mutation tests)
- **Test Suite Baseline**: 469 tests collected initially; 510 tests collected with Phase 0 contract suite (+41 contract & mutation tests)
- **Status**: Phase 0 Baseline Contract (Observed vs. Proposed vs. Gap)
---
+18 -1
View File
@@ -1,6 +1,6 @@
# 📑 Herdr Runtime Contract and Backend Capability Matrix
- **Document Version**: Rev.3 (Post Cross-Review Corrections)
- **Document Version**: Rev.5 (Evidence Source-Kind Dispatch & Identity Correspondence — §3-1 신설)
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
- **Target Herdr Version**: `herdr 0.8.2`
- **Scope**: Native Herdr API Contract, External Collector Specification, and Backend Matrix
@@ -76,6 +76,23 @@ Herdr 네이티브 페이로드와 Multi-Agent-Mux 오케스트레이션 메타
- **불변식**: Herdr 원본 응답 페이로드에는 `job_id`, `attempt_id`, `report phase`, `expected_decision` 등의 MAM 내부 필드를 절대 주입하지 않습니다.
### 3-1. Watcher 타임라인 증거 스키마 및 `source_kind` 분기 계약 (Rev.5)
Watcher 복합 시나리오(`tests/fixtures/issue_5/watcher/*.json`)의 각 타임라인 항목은 아래 계약을 따르며, `tests/test_issue5_phase0_contracts.py`의 `validate_watcher_scenario`가 이를 기계적으로 강제합니다. 위반 시 fixture 검증은 **fail-closed(거부)** 입니다.
| 규칙 | 내용 | 강제 지점 |
|---|---|---|
| **지원 source_kind 집합** | `herdr`, `event`, `filesystem`, `harness` 4종만 허용. 그 외 값은 provenance와 무관하게 거부 | `SUPPORTED_SOURCE_KINDS` |
| **관측(observed) 증거 스키마** | 독립 증거 스키마가 구현된 kind만 `source_provenance: observed`를 선언할 수 있음. **현재 `herdr`만 해당.** `event`/`filesystem`/`harness`는 스키마가 생길 때까지 `simulated`(또는 `synthetic`)로만 기록하며, `observed`로 표기 시 `capture_ref`가 유효하더라도 즉시 거부 | `OBSERVED_EVIDENCE_SCHEMAS` |
| **capture_ref 격리** | repo 상대 경로여야 하며(절대 경로 금지), 해석 결과가 `tests/fixtures/issue_5/herdr/` 하위여야 함(`..` 우회 금지). `.json`이고 `schema_version 1.0.0`·`provenance observed`·`capture_id`를 갖는 네이티브 캡처만 증거로 인정 | `_load_observed_capture_ref` |
| **Herdr 항목 대응성** | ① 참조 캡처는 Layer 1 네이티브 계약(`validate_native_capture`)을 재통과 ② 항목 payload는 비어있지 않은 dict이며 `agent_status`를 **필수 선언**(누락 시 비교 생략이 아닌 거부) ③ 캡처는 `result.agent` 객체를 포함해야 함(`wait` timeout 등 nonzero-exit 캡처는 agent_status를 입증할 수 없으므로 거부) ④ 선언된 **모든** 필드가 agent 객체의 네이티브 필드에 존재하고 값이 정확히 일치(비네이티브 필드 선언 시 거부) | `_validate_observed_herdr_item` |
| **정체성 대응 (4중 귀속 연동)** | 캡처의 `agent_session.value` == `dispatch_context.agent_session_uuid`, 캡처의 `pane_id`가 `dispatch_context.pane_process_generation`(`<pane_id>:<pid>:<start>`)의 선두 구성요소여야 함. 시나리오 `backend`가 `backend_agnostic`이 아니면 캡처 `backend`와 일치해야 함 | `_validate_observed_herdr_item` |
| **1캡처 = 1관측** | 하나의 `capture_id`는 시나리오 내 관측 항목 하나만 입증할 수 있음(동일 스냅샷의 재인용 금지) | `validate_watcher_scenario` |
| **provenance 정직성** | observed·simulated 혼재 시 전체 `provenance`는 `mixed`, 전량 관측 시 `observed_composite`, 전량 모의 시 `simulated`여야 함 (`synthetic` 항목은 비관측으로 집계) | `validate_watcher_scenario` |
| **매니페스트 정합성** | `watcher/manifest.json`의 `provenance`/`expected_decision`은 시나리오 파일과 일치해야 하며, `herdr/manifest.json`의 observed 슬롯은 동일 backend의 캡처가 슬롯명과 같은 `agent_status`(또는 `wait_timeout` 슬롯은 `error.code=timeout`)를 기록해야 함. 디스크의 모든 캡처는 정확히 하나의 observed 슬롯에 색인되어야 함(고아 캡처 금지) | `validate_watcher_manifest`, `validate_herdr_manifest` |
> 📌 이 계약은 **fixture 검증 계약**이며 프로덕션 Watcher 구현(Phase 2a)이 아닙니다. Phase 2a의 `watcher.py`는 이 표의 "관측 증거 스키마" 열을 확장(event/filesystem 스키마 추가)할 때 `OBSERVED_EVIDENCE_SCHEMAS`를 함께 갱신해야 하며, 스키마 없는 kind를 관측으로 승격하는 일은 허용되지 않습니다.
---
## 4. 6개 백엔드 기능 및 실측 증거 행렬 (Capability Matrix)
@@ -21,7 +21,7 @@
| 단계 | 작업 목표 | 세부 구현 내용 | 필수 완료 게이트 (Gate Criteria) | 롤백 절차 (Rollback Path) |
|---|---|---|---|---|
| **Phase 0**<br>*(현재)* | **사전 계약 고정 및 Fixture 수집** | • CLI, 이벤트, 리포트, 락 계약 기준선 문서화<br>• Herdr 0.8.2 네이티브 캡처 및 복합 Watcher 시나리오 2계층 분리<br>• 6개 백엔드 실측 증거 행렬 수집 | • 499개 전체 테스트 수집 및 회귀 0-Fail (100% 통과)<br>• Fixture 스키마 및 증거 경계 검증 통과<br>• 프로덕션 코드 무변경 diff 검증 | 미추적 산출물 즉시 삭제 (`rm -rf docs/contracts/issue-5 tests/fixtures/issue_5 tests/test_issue5_phase0_contracts.py`) |
| **Phase 0**<br>*(현재)* | **사전 계약 고정 및 Fixture 수집** | • CLI, 이벤트, 리포트, 락 계약 기준선 문서화<br>• Herdr 0.8.2 네이티브 캡처 및 복합 Watcher 시나리오 2계층 분리<br>• 6개 백엔드 실측 증거 행렬 수집 | • 510개 전체 테스트 수집 및 회귀 0-Fail (100% 통과)<br>• Fixture 스키마 및 증거 경계 검증 통과<br>• 프로덕션 코드 무변경 diff 검증 | 미추적 산출물 즉시 삭제 (`rm -rf docs/contracts/issue-5 tests/fixtures/issue_5 tests/test_issue5_phase0_contracts.py`) |
| **Phase 1** | **`lib_py/delegation/` 추출** | • `lib.sh` L1833 이하 위임 헬퍼를 파이썬 서비스 클래스로 추출<br>• `start_watchdog`을 내부 명칭 `ensure_subscriber_daemon`으로 명명(기존 함수 별칭 보존)<br>• 장수 프로세스 단일 MQTT 연결 재사용 프로토타입 | • CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과<br>• 데몬 수명 및 PID 계약 일치 확인<br>• **자동완료 정책 변경 없음** | `lib.sh` 위임 헬퍼 호출을 이전 인라인/셸 방식으로 즉시 복원 |
| **Phase 2a** | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현<br>• 모든 Writer에 대한 단일 CAS 트랜잭션 경계 구현<br>• 자동완료를 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동 | • 락 내 비동기/I/O 배제 검증<br>• 소켓 단절/스냅샷 재연결 백오프 검증<br>• Shadow 모드가 레지스트리 상태를 쓰지 않음을 검증 | 1) Shadow 모니터 프로세스 종료 및 인메모리 큐 폐기<br>2) CAS 서비스는 하위 호환 모드로 유지하거나 `MAM_ENGINE_CAS=0` 플래그로 비활성화하여 레거시 무조건 필드 병합(`update_job_status`)으로 복귀<br>3) 기존 셸 루프 상태 및 레코드 불변 유지 |
| **Phase 2b** | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동<br>• Pending Notification 지연 알림 및 크래시 복구 구현<br>• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료<br>• 경쟁/재시작 시나리오 통과<br>• 플래그 OFF 시 기존 explicit event 경로 100% 보존 | 환경변수/플래그 `MAM_AUTO_COMPLETE=0` 설정; 이미 확정된 terminal 레코드는 보존하고 신규 자동완료만 중단 |
+6 -4
View File
@@ -1,6 +1,6 @@
# 📑 Issue #5 Task Checklist & Verification Evidence
- **Document Version**: Rev.3 (Post Cross-Review Corrections)
- **Document Version**: Rev.5 (Evidence Dispatch Hardening — P0-16/P0-18/P0-20 증거 갱신)
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
- **Status**: Phase 0 Complete / Phase 1–4 Staged
@@ -9,7 +9,7 @@
## Phase 0: 사전 계약 기준선 및 Fixture 수집 (이번 실행 범위)
- [x] **P0-01** 기준 commit, dirty diff, Herdr/Python 버전, pytest collection 수 기록.
- *증거*: Commit `47640e2`, Herdr `0.8.2`, Python `3.9.6`, Pytest `469 tests collected initially` ➔ `499 tests collected with Phase 0 suite` (+30 contract & mutation tests).
- *증거*: Commit `47640e2`, Herdr `0.8.2`, Python `3.9.6`, Pytest `469 tests collected initially` ➔ `510 tests collected with Phase 0 suite` (+41 contract & mutation tests).
- [x] **P0-02** `docs/contracts/issue-5/baseline.md` 작성: 공개 CLI/default/invalid input/stdout-stderr/exit/interpreter/path/signal 계약과 소스·기존 테스트 매핑.
- *증거*: `docs/contracts/issue-5/baseline.md` §2 (`--all-reviewer` 우선순위 및 L192–196 소스 매핑 반영).
- [x] **P0-03** event schema/HMAC/seq/retain/audit/디스크 fallback 및 publisher 실패 후 로컬 commit 동작 기록.
@@ -40,6 +40,7 @@
- *증거*: `tests/fixtures/issue_5/herdr/manifest.json`, `tests/fixtures/issue_5/watcher/manifest.json`.
- [x] **P0-16** 두 계층 schema/reference/coverage 테스트 추가: native payload에 job/attempt/report phase 주입 거부, 오류와 상태 분리, simulated를 observed로 표기 거부. 운영 session 쓰기 금지.
- *증거*: `tests/test_issue5_phase0_contracts.py`.
- *Rev.5 보강 (Codex [P1] 대응)*: `source_kind` 명시적 분기(`SUPPORTED_SOURCE_KINDS`), 관측 증거 스키마 미구현 kind(event/filesystem/harness)의 `observed` 표기 fail-closed 거부, Herdr 항목의 필수 필드·전 필드 대응·세션 UUID/pane/backend 정체성 대응, `capture_ref` 증거 루트 격리, 1캡처=1관측, 매니페스트↔파일 정합성 검증. 계약 명세는 `herdr-runtime.md` §3-1.
- [x] **P0-16a** watcher 복합 시나리오 7종 작성: initial idle, tool active, report pause, finalized candidate, blocked, disconnect, generation restart. source별 증거/가상 시계/expected policy를 구분.
- *증거*: `tests/fixtures/issue_5/watcher/*.json` (7개 시나리오 완료).
- [x] **P0-16b** `herdr-runtime.md`에 native API·단일 wait·외부 collector·file/event/dispatch source 경계를 명시.
@@ -47,11 +48,12 @@
- [x] **P0-17** Phase 1–4 `implementation_plan.md`/`task.md` 작성, live-helper version/공통 CAS/notification 복구 결정 기록.
- *증거*: `docs/contracts/issue-5/implementation_plan.md`, `docs/contracts/issue-5/task.md`.
- [x] **P0-18** 관련 CLI/lock/rebuttal/adapter 테스트와 전체 suite 실행; 수집·실행·통과·실패 숫자 분리.
- *증거*: 전체 484개 수집, 선행 전체 실행 481개 통과(0 failed, 0 skipped, 786.18s), 15개 계약/변이 테스트 전체 통과, 집중 코어 회귀 155개 통과(60.48s).
- *증거 (Rev.3 당시)*: 전체 484개 수집, 선행 전체 실행 481개 통과(0 failed, 0 skipped, 786.18s), 15개 계약/변이 테스트 전체 통과, 집중 코어 회귀 155개 통과(60.48s).
- *증거 (Rev.5, job `fb7fadd4`)*: 전체 suite 실행 **499 passed, 0 failed, 0 skipped (848.90s)** — 커밋 `19d798b` 기준선(계약 테스트 30개 포함)에서 실행. Rev.5 보강 후 계약 테스트 파일 단독 재실행 **41 passed** (`+11`), 수집 총계 **510** (`--collect-only`). Rev.5 변경은 `tests/test_issue5_phase0_contracts.py` 및 `docs/`에 국한되어 나머지 469개 테스트에 영향 없음.
- [x] **P0-19** 문서 링크/JSON/git diff --check/production 코드 무변경 확인.
- *증거*: `git diff --check` 클린, 공백 린트 완료, 프로덕션 `.sh`/`.py` 무변경 (오직 `docs/`, `tests/` 변경).
- [x] **P0-20** 결과 보고: Phase 0 산출물 완료 여부, backend별 native live gap·mixed/simulated scenario 현황, 남은 gate를 별도 명시하고 peer review 요청.
- *증거*: `.mam/jobs/61f2fa87/agy-reports/report-final.md`.
- *증거*: `.mam/jobs/61f2fa87/agy-reports/report-final.md` (Rev.3 최초 보고), `.mam/jobs/f29cb24e/claude-code-reports/report-final.md` (Codex [P1]·Claude [Finding 2] 정정, 커밋 `19d798b`), `.mam/jobs/fb7fadd4/claude-reports/report-final.md` (Rev.5 자체 재리뷰 및 보강).
---
+231 -8
View File
@@ -13,6 +13,11 @@ Validates:
accepted as 'observed' when an evidence schema exists for that source kind (currently Herdr only).
Event / filesystem / harness items have no independent evidence schema yet and are fail-closed when
labeled 'observed'; they must remain 'simulated' until such schemas exist.
8. Evidence identity (Rev.5 self-review hardening): observed capture_refs must be repo-relative paths confined
to the native Herdr evidence root; one capture may substantiate at most one timeline observation; the
capture's session UUID and pane id must match the scenario's 4-fold attribution; a backend-specific
scenario may only cite captures of that backend; and both fixture manifests must agree with the files
they index (no drift, no orphan captures).
"""
import copy
@@ -48,6 +53,12 @@ OBSERVED_EVIDENCE_SCHEMAS = {"herdr"}
# against the native capture cannot be skipped.
HERDR_MANDATORY_ITEM_FIELDS = ("agent_status",)
# Native agent_status values a Herdr manifest slot may be named after (slot name == recorded agent_status).
HERDR_AGENT_STATUS_SLOTS = {"idle", "working", "blocked", "done", "unknown"}
# Scenario backend marker meaning "any backend's capture may be cited".
BACKEND_AGNOSTIC = "backend_agnostic"
# ===========================================================================
# Validation Engine Functions (Used for both real fixtures & negative tests)
@@ -103,10 +114,24 @@ def _load_observed_capture_ref(index: int, item: dict) -> "tuple[Path, dict]":
assert "capture_ref" in item and item["capture_ref"], (
f"Timeline item [{index}] is marked observed but lacks non-empty 'capture_ref'"
)
ref_path = REPO_ROOT / item["capture_ref"]
raw_ref = str(item["capture_ref"])
assert not Path(raw_ref).is_absolute(), (
f"Observed timeline item [{index}] capture_ref must be a repo-relative path, got absolute '{raw_ref}'"
)
ref_path = REPO_ROOT / raw_ref
assert ref_path.exists(), f"Observed timeline item [{index}] points to nonexistent capture_ref: {ref_path}"
assert ref_path.suffix == ".json", f"capture_ref must point to a JSON evidence record, got {ref_path}"
# Confinement: evidence may only come from the native Herdr evidence root (no traversal, no foreign files).
resolved = ref_path.resolve()
try:
resolved.relative_to(HERDR_ROOT.resolve())
except ValueError:
raise AssertionError(
f"Observed timeline item [{index}] capture_ref '{raw_ref}' resolves outside the native Herdr "
f"evidence root {HERDR_ROOT}; foreign or traversed paths are not admissible evidence"
)
try:
ref_data = json.loads(ref_path.read_text(encoding="utf-8"))
except Exception as exc:
@@ -133,12 +158,12 @@ def _extract_native_agent_object(ref_data: dict):
return agent
def _validate_observed_herdr_item(index: int, item: dict, dctx: dict) -> None:
"""Strict evidence correspondence for an observed Herdr timeline item.
def _validate_observed_herdr_item(index: int, item: dict, dctx: dict, scenario_backend: str = None) -> str:
"""Strict evidence correspondence for an observed Herdr timeline item. Returns the capture_id.
Every declared payload field must be a native field of the referenced capture's agent object
and must match its value exactly. Mandatory fields cannot be omitted to dodge the comparison,
and the capture's agent session identity must match the scenario's dispatch attribution.
and the capture's identity (session UUID, pane id, backend) must match the scenario's attribution.
"""
ref_path, ref_data = _load_observed_capture_ref(index, item)
@@ -164,7 +189,7 @@ def _validate_observed_herdr_item(index: int, item: dict, dctx: dict) -> None:
f"it cannot substantiate observed herdr timeline item [{index}]"
)
# Identity correspondence: the capture must be about the session this scenario attributes.
# Identity correspondence (session): the capture must be about the session this scenario attributes.
session_obj = agent_obj.get("agent_session")
capture_uuid = session_obj.get("value") if isinstance(session_obj, dict) else None
assert capture_uuid == dctx["agent_session_uuid"], (
@@ -172,6 +197,25 @@ def _validate_observed_herdr_item(index: int, item: dict, dctx: dict) -> None:
f"'{dctx['agent_session_uuid']}' but capture {ref_path} records agent_session.value='{capture_uuid}'"
)
# Identity correspondence (pane): pane_process_generation is '<pane_id>:<pid>:<start>'; the capture's
# native pane_id must be its leading component, otherwise the capture is about a different pane/generation.
capture_pane = agent_obj.get("pane_id")
assert isinstance(capture_pane, str) and capture_pane, (
f"capture {ref_path} lacks a native pane_id; pane identity for timeline item [{index}] cannot be established"
)
ppg = str(dctx["pane_process_generation"])
assert ppg.startswith(capture_pane + ":"), (
f"Identity correspondence mismatch: dispatch_context.pane_process_generation='{ppg}' does not belong to "
f"pane_id='{capture_pane}' recorded by capture {ref_path}"
)
# Backend correspondence: a backend-specific scenario may only cite captures of that backend.
if scenario_backend and scenario_backend != BACKEND_AGNOSTIC:
assert ref_data.get("backend") == scenario_backend, (
f"Backend correspondence mismatch: scenario declares backend='{scenario_backend}' but capture "
f"{ref_path} records backend='{ref_data.get('backend')}'"
)
# Payload correspondence: every declared field must exist natively and match exactly.
for key, declared in item_payload.items():
assert key in agent_obj, (
@@ -183,6 +227,55 @@ def _validate_observed_herdr_item(index: int, item: dict, dctx: dict) -> None:
f"but capture {ref_path} records {key}='{agent_obj[key]}'"
)
return str(ref_data["capture_id"])
def validate_watcher_manifest(manifest: dict) -> None:
"""The watcher manifest must not drift from the scenario files it indexes."""
for sid, sinfo in manifest["scenarios"].items():
data = json.loads((WATCHER_ROOT / sinfo["file"]).read_text(encoding="utf-8"))
assert data["scenario_id"] == sid, f"{sinfo['file']} scenario_id '{data['scenario_id']}' != manifest key '{sid}'"
assert data["provenance"] == sinfo["provenance"], (
f"{sid}: manifest provenance '{sinfo['provenance']}' != file provenance '{data['provenance']}'"
)
assert data["expected_policy"]["decision"] == sinfo["expected_decision"], (
f"{sid}: manifest expected_decision '{sinfo['expected_decision']}' != "
f"file decision '{data['expected_policy']['decision']}'"
)
def validate_herdr_manifest(manifest: dict) -> None:
"""Every observed slot must be backed by a capture of the same backend recording the slot's state,
and every capture on disk must be indexed by exactly one observed slot (no drift, no orphans)."""
referenced = {}
for backend, bdata in manifest["backends"].items():
for slot_name, slot in bdata["slots"].items():
if slot["status"] != "observed":
continue
rel = slot["file"]
assert rel not in referenced, f"capture {rel} indexed by two slots: {referenced[rel]} and {backend}/{slot_name}"
referenced[rel] = f"{backend}/{slot_name}"
cap_path = HERDR_ROOT / rel
assert cap_path.exists(), f"Observed slot {backend}/{slot_name} references missing capture {rel}"
cap = json.loads(cap_path.read_text(encoding="utf-8"))
assert cap["backend"] == backend, f"{rel}: capture backend '{cap['backend']}' != manifest backend '{backend}'"
agent_obj = _extract_native_agent_object(cap)
if slot_name in HERDR_AGENT_STATUS_SLOTS:
assert agent_obj is not None, f"{rel}: status slot '{slot_name}' requires an agent object"
assert agent_obj.get("agent_status") == slot_name, (
f"{rel}: manifest slot '{slot_name}' but capture records agent_status='{agent_obj.get('agent_status')}'"
)
elif slot_name == "wait_timeout":
assert cap["exit_code"] != 0 and agent_obj is None, f"{rel}: wait_timeout slot must be a nonzero-exit capture"
err = json.loads(cap["stderr"]).get("error", {})
assert err.get("code") == "timeout", f"{rel}: wait_timeout slot must record error.code='timeout'"
else:
raise AssertionError(f"Unknown observed slot kind '{slot_name}' for backend '{backend}'")
on_disk = {str(pth.relative_to(HERDR_ROOT)) for pth in HERDR_ROOT.glob("*/*.json")}
orphans = on_disk - set(referenced)
assert not orphans, f"Captures on disk not indexed by any observed manifest slot: {sorted(orphans)}"
def validate_watcher_scenario(data: dict, file_path: Path = None) -> None:
"""Strictly validates a Watcher composite scenario, attribution, timeline authenticity, and correspondence."""
@@ -222,6 +315,8 @@ def validate_watcher_scenario(data: dict, file_path: Path = None) -> None:
simulated_count = 0
total_items = len(data["timeline"])
assert total_items > 0, "timeline must contain at least one item"
scenario_backend = data.get("backend")
cited_capture_ids: dict = {} # capture_id -> first timeline index that cited it
for i, item in enumerate(data["timeline"]):
source_kind = item.get("source_kind")
@@ -247,10 +342,17 @@ def validate_watcher_scenario(data: dict, file_path: Path = None) -> None:
)
if source_kind == "herdr":
_validate_observed_herdr_item(i, item, dctx)
capture_id = _validate_observed_herdr_item(i, item, dctx, scenario_backend)
else: # pragma: no cover - guarded by the assertion above
raise AssertionError(f"Unhandled observed source_kind '{source_kind}' at timeline item [{i}]")
# One capture is one point-in-time observation; it cannot substantiate two timeline points.
assert capture_id not in cited_capture_ids, (
f"Timeline item [{i}] re-cites capture_id '{capture_id}' already used by item "
f"[{cited_capture_ids[capture_id]}]; a single capture cannot substantiate multiple observations"
)
cited_capture_ids[capture_id] = i
# Provenance honesty invariant
if observed_count > 0 and simulated_count > 0:
assert overall_prov == "mixed", (
@@ -636,11 +738,11 @@ def test_negative_observed_herdr_item_session_identity_mismatch_fails():
def test_negative_observed_herdr_item_referencing_non_native_json_record_fails():
"""A JSON file that is not a native Herdr capture (e.g. a Watcher scenario) is not Herdr evidence."""
"""A JSON file inside the evidence root that is not a native capture (e.g. the manifest) is not evidence."""
sc = _load_scenario("initial_idle.json")
sc["provenance"] = "mixed"
sc["timeline"][1]["source_provenance"] = "observed"
sc["timeline"][1]["capture_ref"] = "tests/fixtures/issue_5/watcher/finalized_candidate.json"
sc["timeline"][1]["capture_ref"] = "tests/fixtures/issue_5/herdr/manifest.json"
sc["timeline"][1]["payload"] = {"agent_status": "idle"}
with pytest.raises(AssertionError, match="must have provenance 'observed'"):
validate_watcher_scenario(sc)
@@ -665,3 +767,124 @@ def test_positive_observed_herdr_item_with_full_native_correspondence_passes():
sc["provenance"] = "observed_composite"
with pytest.raises(AssertionError, match="overall provenance must be 'mixed'"):
validate_watcher_scenario(sc)
# ===========================================================================
# 6. Evidence Identity & Manifest Drift Tests (Rev.5 self-review hardening)
# ===========================================================================
def _observed_herdr_scenario(payload: dict, capture_ref: str = HERDR_CLAUDE_IDLE) -> dict:
"""initial_idle with its herdr item (index 1) relabeled observed against `capture_ref`."""
sc = _load_scenario("initial_idle.json")
sc["provenance"] = "mixed"
sc["timeline"][1]["source_provenance"] = "observed"
sc["timeline"][1]["capture_ref"] = capture_ref
sc["timeline"][1]["payload"] = payload
return sc
@pytest.mark.parametrize("bad_ref, expected", [
# Escapes the evidence root via traversal even though the target is a well-formed JSON file.
("tests/fixtures/issue_5/herdr/../watcher/finalized_candidate.json", "resolves outside the native Herdr evidence root"),
# Well-formed JSON in the repo but not under the evidence root.
("tests/fixtures/issue_5/watcher/finalized_candidate.json", "resolves outside the native Herdr evidence root"),
# Absolute paths are never admissible, even when they point at a real capture.
(str(HERDR_ROOT / "claude" / "idle.json"), "must be a repo-relative path"),
])
def test_negative_observed_capture_ref_outside_evidence_root_fails(bad_ref, expected):
sc = _observed_herdr_scenario({"agent_status": "idle"}, capture_ref=bad_ref)
with pytest.raises(AssertionError, match=expected):
validate_watcher_scenario(sc)
def test_negative_single_capture_cited_by_two_observed_items_fails():
"""One point-in-time capture cannot substantiate two distinct timeline observations."""
sc = _load_scenario("initial_idle.json")
sc["provenance"] = "observed_composite"
# Turn the event item into a second herdr observation citing the very same capture.
for item in sc["timeline"]:
item["source_kind"] = "herdr"
item["source_provenance"] = "observed"
item["capture_ref"] = HERDR_CLAUDE_IDLE
item["payload"] = {"agent_status": "idle"}
with pytest.raises(AssertionError, match="re-cites capture_id 'cap-claude-idle-01'"):
validate_watcher_scenario(sc)
def test_negative_observed_herdr_item_pane_identity_mismatch_fails():
"""Session UUID may match while the pane generation belongs to a different pane; must be rejected."""
sc = _observed_herdr_scenario({"agent_status": "idle"})
sc["dispatch_context"]["pane_process_generation"] = "w2X:p9:1234:2026-09-06T00:00:00Z" # capture says w2X:p1
with pytest.raises(AssertionError, match="does not belong to pane_id='w2X:p1'"):
validate_watcher_scenario(sc)
# A pane id that merely shares a prefix (w2X:p1 vs w2X:p10) must not be accepted either.
sc["dispatch_context"]["pane_process_generation"] = "w2X:p10:1234:2026-09-06T00:00:00Z"
with pytest.raises(AssertionError, match="does not belong to pane_id='w2X:p1'"):
validate_watcher_scenario(sc)
def test_negative_backend_specific_scenario_citing_other_backend_capture_fails():
"""A scenario pinned to one backend may not borrow another backend's capture as evidence."""
sc = _observed_herdr_scenario({"agent_status": "idle"})
sc["backend"] = "grok" # capture is claude
with pytest.raises(AssertionError, match="Backend correspondence mismatch"):
validate_watcher_scenario(sc)
sc["backend"] = "claude"
validate_watcher_scenario(sc) # matching backend is accepted
sc["backend"] = BACKEND_AGNOSTIC
validate_watcher_scenario(sc) # agnostic scenarios may cite any backend
def test_watcher_manifest_agrees_with_scenario_files():
validate_watcher_manifest(json.loads((WATCHER_ROOT / "manifest.json").read_text(encoding="utf-8")))
def test_negative_watcher_manifest_drift_is_detected():
manifest = json.loads((WATCHER_ROOT / "manifest.json").read_text(encoding="utf-8"))
manifest["scenarios"]["initial_idle"]["expected_decision"] = "candidate_approved" # file says hold
with pytest.raises(AssertionError, match="manifest expected_decision 'candidate_approved' != file decision 'hold'"):
validate_watcher_manifest(manifest)
manifest = json.loads((WATCHER_ROOT / "manifest.json").read_text(encoding="utf-8"))
manifest["scenarios"]["finalized_candidate"]["provenance"] = "observed_composite" # file says simulated
with pytest.raises(AssertionError, match="manifest provenance 'observed_composite' != file provenance 'simulated'"):
validate_watcher_manifest(manifest)
def test_herdr_manifest_observed_slots_agree_with_captures_and_no_orphan_captures():
validate_herdr_manifest(json.loads((HERDR_ROOT / "manifest.json").read_text(encoding="utf-8")))
def test_negative_herdr_manifest_slot_status_drift_is_detected():
"""Re-indexing claude/idle.json (records idle) under the 'done' slot must be rejected."""
manifest = json.loads((HERDR_ROOT / "manifest.json").read_text(encoding="utf-8"))
slots = manifest["backends"]["claude"]["slots"]
slots["done"] = {"status": "observed", "file": "claude/idle.json"}
slots["idle"] = {"status": "unobserved", "reason": "mutated"}
with pytest.raises(AssertionError, match="manifest slot 'done' but capture records agent_status='idle'"):
validate_herdr_manifest(manifest)
def test_negative_herdr_manifest_backend_or_orphan_drift_is_detected():
manifest = json.loads((HERDR_ROOT / "manifest.json").read_text(encoding="utf-8"))
# Borrow another backend's capture: grok's idle slot pointed at claude's capture (claude's own slot
# de-indexed so the duplicate-index guard does not fire first and the backend check is what rejects).
manifest["backends"]["claude"]["slots"]["idle"] = {"status": "unobserved", "reason": "mutated"}
manifest["backends"]["grok"]["slots"]["idle"]["file"] = "claude/idle.json"
with pytest.raises(AssertionError, match="capture backend 'claude' != manifest backend 'grok'"):
validate_herdr_manifest(manifest)
manifest = json.loads((HERDR_ROOT / "manifest.json").read_text(encoding="utf-8"))
# Same capture indexed by two slots must be rejected.
manifest["backends"]["grok"]["slots"]["idle"]["file"] = "claude/idle.json"
with pytest.raises(AssertionError, match="indexed by two slots"):
validate_herdr_manifest(manifest)
manifest = json.loads((HERDR_ROOT / "manifest.json").read_text(encoding="utf-8"))
# De-index a real capture: it becomes an orphan on disk and must be reported.
manifest["backends"]["codex"]["slots"]["done"] = {"status": "unobserved", "reason": "mutated"}
with pytest.raises(AssertionError, match=r"not indexed by any observed manifest slot: \['codex/done.json'\]"):
validate_herdr_manifest(manifest)