#!/usr/bin/env python3 """Phase 0 contract and fixture verification tests for Issue #5 (Rev.4). Validates: 1. Herdr Layer 1 native contract captures (pure native observation, no injected MAM fields). 2. Herdr wait contract options, exit codes, and channel separation (stderr for timeout & not found). 3. 6-backend capability coverage matrix and honest gap tracking. 4. Watcher Layer 2 composite scenarios, 4-fold attribution, timelines, authenticity, and fail-closed policies. 5. Contract cross-checks against baseline.md and herdr-runtime.md. 6. Mutation and negative tests demonstrating that corruption, unrelated references, payload mismatches, and dishonest provenance labels are rejected. """ import copy import json from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent DOCS_ROOT = REPO_ROOT / "docs" / "contracts" / "issue-5" FIXTURES_ROOT = REPO_ROOT / "tests" / "fixtures" / "issue_5" HERDR_ROOT = FIXTURES_ROOT / "herdr" WATCHER_ROOT = FIXTURES_ROOT / "watcher" FORBIDDEN_MAM_KEYS = { "job_id", "attempt_id", "report_phase", "report_path", "expected_decision", "stable_candidate", "manifest_hash", } # =========================================================================== # Validation Engine Functions (Used for both real fixtures & negative tests) # =========================================================================== def validate_native_capture(cap: dict, file_path: Path = None) -> None: """Strictly validates a native Herdr capture envelope and channel payloads.""" assert cap.get("schema_version") == "1.0.0", "Invalid schema_version" assert cap.get("herdr_version") == "0.8.2", "Invalid herdr_version" assert cap.get("provenance") == "observed", "Native capture provenance must be 'observed'" assert "command_argv" in cap and isinstance(cap["command_argv"], list), "command_argv must be list" assert "exit_code" in cap and isinstance(cap["exit_code"], int), "exit_code must be int" assert "capture_id" in cap, "capture_id required" exit_code = cap["exit_code"] stdout_payload = cap.get("stdout_payload") stderr_raw = cap.get("stderr") # Channel separation invariant if exit_code == 0: assert stdout_payload is not None, "exit 0 must provide stdout_payload" assert stderr_raw is None, "exit 0 must have null stderr" payload_to_check = stdout_payload else: assert stdout_payload is None, "nonzero exit must have null stdout_payload" assert stderr_raw is not None and isinstance(stderr_raw, str), "nonzero exit must have string stderr" try: payload_to_check = json.loads(stderr_raw) except Exception as exc: raise AssertionError(f"stderr for nonzero exit must be valid JSON: {exc}") # Payload duplicate check (if present, must match channel payload) if "payload" in cap and cap["payload"] is not None: assert cap["payload"] == payload_to_check, "Top-level payload duplicate does not match channel payload" # Enforce zero MAM orchestration injection def _check_no_forbidden(obj, prefix=""): if isinstance(obj, dict): for k, v in obj.items(): assert k not in FORBIDDEN_MAM_KEYS, ( f"Forbidden MAM field '{k}' injected into native capture payload at {prefix}{k}" ) _check_no_forbidden(v, prefix=f"{prefix}{k}.") elif isinstance(obj, list): for i, elem in enumerate(obj): _check_no_forbidden(elem, prefix=f"{prefix}[{i}].") _check_no_forbidden(payload_to_check) def validate_watcher_scenario(data: dict, file_path: Path = None) -> None: """Strictly validates a Watcher composite scenario, attribution, timeline authenticity, and correspondence.""" assert data.get("schema_version") == "1.0.0", "Invalid schema_version" assert "scenario_id" in data, "scenario_id required" assert "description" in data, "description required" assert "filesystem" in data, "filesystem required" assert "expected_policy" in data, "expected_policy required" assert "dispatch_context" in data, "dispatch_context required" assert "timeline" in data and isinstance(data["timeline"], list), "timeline must be list" overall_prov = data.get("provenance") assert overall_prov in {"observed_composite", "simulated", "mixed"}, f"Invalid overall provenance: {overall_prov}" # 4-Fold Attribution verification dctx = data["dispatch_context"] for req_attr in ("job_id", "attempt_id", "agent_session_uuid", "pane_process_generation"): assert req_attr in dctx, f"Missing attribution field in dispatch_context: '{req_attr}'" assert dctx[req_attr] is not None and dctx[req_attr] != "", ( f"Attribution field '{req_attr}' must not be null or empty in dispatch_context" ) assert "execution_anchor_observed" in dctx, "execution_anchor_observed boolean required" # Expected Policy verification policy = data["expected_policy"] assert policy.get("decision") in { "hold", "hold_blocked", "hold_reconnect", "reject_stale", "candidate_approved", }, f"Invalid expected policy decision: {policy.get('decision')}" assert policy.get("reason"), "expected_policy.reason must not be empty" # Timeline authenticity & correspondence observed_count = 0 simulated_count = 0 total_items = len(data["timeline"]) assert total_items > 0, "timeline must contain at least one item" for i, item in enumerate(data["timeline"]): item_prov = item.get("source_provenance") assert item_prov in {"observed", "simulated", "synthetic"}, f"Invalid source_provenance: {item_prov}" if item_prov == "observed": observed_count += 1 assert "capture_ref" in item and item["capture_ref"], ( f"Timeline item [{i}] is marked observed but lacks non-empty 'capture_ref'" ) ref_path = REPO_ROOT / item["capture_ref"] assert ref_path.exists(), f"Observed timeline item [{i}] 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}" # Authenticity check: load and verify evidence record try: ref_data = json.loads(ref_path.read_text(encoding="utf-8")) except Exception as exc: raise AssertionError(f"capture_ref {ref_path} must be valid JSON: {exc}") assert ref_data.get("schema_version") == "1.0.0", f"capture_ref {ref_path} missing schema_version 1.0.0" assert ref_data.get("provenance") == "observed", f"capture_ref {ref_path} must have provenance 'observed'" assert "capture_id" in ref_data, f"capture_ref {ref_path} missing capture_id" # Source correspondence check source_kind = item.get("source_kind") if source_kind == "herdr": assert "backend" in ref_data or "herdr_version" in ref_data, ( f"capture_ref {ref_path} for herdr source must be a native Herdr capture" ) item_payload = item.get("payload", {}) if "agent_status" in item_payload: ref_status = None payload_obj = ref_data.get("stdout_payload") or ref_data.get("payload") or {} if isinstance(payload_obj, dict): if "result" in payload_obj and isinstance(payload_obj["result"], dict): ag = payload_obj["result"].get("agent", {}) if isinstance(ag, dict): ref_status = ag.get("agent_status") if not ref_status and "agent_status" in payload_obj: ref_status = payload_obj["agent_status"] assert ref_status == item_payload["agent_status"], ( f"Payload correspondence mismatch: timeline specifies agent_status='{item_payload['agent_status']}' " f"but capture {ref_path} records agent_status='{ref_status}'" ) else: simulated_count += 1 # Provenance honesty invariant if observed_count > 0 and simulated_count > 0: assert overall_prov == "mixed", ( f"Scenario has both observed ({observed_count}) and simulated ({simulated_count}) timeline items; " f"overall provenance must be 'mixed', got '{overall_prov}'" ) elif observed_count == total_items: assert overall_prov == "observed_composite", ( f"All timeline items are observed; overall provenance must be 'observed_composite', got '{overall_prov}'" ) elif simulated_count == total_items: assert overall_prov == "simulated", ( f"All timeline items are simulated; overall provenance must be 'simulated', got '{overall_prov}'" ) # =========================================================================== # 1. Layer 1 — Herdr Native Contract Fixture Tests # =========================================================================== def test_herdr_wait_contract_schema_and_channels(): contract_file = HERDR_ROOT / "wait-contract.json" assert contract_file.exists(), "wait-contract.json must exist" data = json.loads(contract_file.read_text(encoding="utf-8")) assert data["schema_version"] == "1.0.0" assert data["herdr_version"] == "0.8.2" assert "--until" in data["options"] assert "--timeout" in data["options"] outcomes = {o["outcome"]: o for o in data["call_outcomes"]} assert "state_matched" in outcomes assert "timeout" in outcomes assert "agent_not_found" in outcomes # Invariant: exit codes assert outcomes["state_matched"]["exit_code"] == 0 assert outcomes["timeout"]["exit_code"] != 0 assert outcomes["agent_not_found"]["exit_code"] != 0 # Invariant: output channels (both timeout and agent_not_found output to stderr) assert outcomes["state_matched"]["output_channel"] == "stdout" assert outcomes["timeout"]["output_channel"] == "stderr", "Herdr wait timeout must output to stderr" assert outcomes["agent_not_found"]["output_channel"] == "stderr", "Herdr wait not-found must output to stderr" def test_herdr_manifest_covers_all_six_backends(): manifest_file = HERDR_ROOT / "manifest.json" assert manifest_file.exists(), "herdr manifest.json must exist" manifest = json.loads(manifest_file.read_text(encoding="utf-8")) expected_backends = {"claude", "agy", "hermes", "grok", "opencode", "codex"} actual_backends = set(manifest["backends"].keys()) assert actual_backends == expected_backends, f"Expected 6 backends: {expected_backends}" for backend, bdata in manifest["backends"].items(): assert "installed_binary" in bdata assert "slots" in bdata for slot_name, slot in bdata["slots"].items(): if slot["status"] == "observed": target_file = HERDR_ROOT / slot["file"] assert target_file.exists(), f"Observed slot {backend}/{slot_name} missing file: {target_file}" elif slot["status"] == "unobserved": assert slot.get("reason"), f"Unobserved slot {backend}/{slot_name} must state an honest reason" def test_native_herdr_captures_strictly_exclude_orchestration_injection(): capture_files = list(HERDR_ROOT.glob("*/*.json")) assert len(capture_files) >= 4, f"Expected at least 4 native captures, found {len(capture_files)}" for cap_path in capture_files: cap = json.loads(cap_path.read_text(encoding="utf-8")) validate_native_capture(cap, file_path=cap_path) # =========================================================================== # 2. Layer 2 — Watcher Composite Scenario Tests # =========================================================================== def test_watcher_manifest_and_all_seven_scenarios_exist(): manifest_file = WATCHER_ROOT / "manifest.json" assert manifest_file.exists(), "watcher manifest.json must exist" manifest = json.loads(manifest_file.read_text(encoding="utf-8")) expected_scenarios = { "initial_idle", "tool_running", "report_pause_15s", "finalized_candidate", "blocked_permission", "transport_disconnected", "restarted_generation", } actual_scenarios = set(manifest["scenarios"].keys()) assert actual_scenarios == expected_scenarios, f"Mismatch in watcher scenarios: {actual_scenarios}" for sid, sinfo in manifest["scenarios"].items(): sfile = WATCHER_ROOT / sinfo["file"] assert sfile.exists(), f"Scenario file for {sid} missing: {sfile}" def test_watcher_composite_scenario_structures(): for sfile in WATCHER_ROOT.glob("*.json"): if sfile.name == "manifest.json": continue data = json.loads(sfile.read_text(encoding="utf-8")) validate_watcher_scenario(data, file_path=sfile) # =========================================================================== # 3. Document Alignment Tests (Guards against Prose Inversions F1/F2) # =========================================================================== def test_documents_align_with_runtime_and_cli_contracts(): """Asserts that baseline.md and herdr-runtime.md do not invert CLI precedence or timeout channels.""" baseline_text = (DOCS_ROOT / "baseline.md").read_text(encoding="utf-8") runtime_text = (DOCS_ROOT / "herdr-runtime.md").read_text(encoding="utf-8") # F1 Guard: baseline.md must specify --all-reviewer takes precedence over --reviewer assert "--all-reviewer" in baseline_text assert "run_loop.sh:64, 192-196" in baseline_text assert "--all-reviewer`가 우선" in baseline_text or "--all-reviewer takes precedence" in baseline_text assert "`0`을 허용" in baseline_text, "--plan-talk must document regex accepting 0" # F2 Guard: herdr-runtime.md must document timeout output channel as stderr assert "타임아웃" in runtime_text assert "`stderr`" in runtime_text, "herdr-runtime.md must state timeout JSON is on stderr" def test_mutation_policy_invariants(): """Ensures fail-closed invariants for target CAS state machine.""" # 1. initial_idle must HOLD because execution anchor is missing init_idle = json.loads((WATCHER_ROOT / "initial_idle.json").read_text(encoding="utf-8")) assert init_idle["dispatch_context"]["execution_anchor_observed"] is False assert init_idle["expected_policy"]["decision"] == "hold" # 2. tool_running must HOLD because tool is active tool_run = json.loads((WATCHER_ROOT / "tool_running.json").read_text(encoding="utf-8")) assert tool_run["expected_policy"]["decision"] == "hold" # 3. report_pause_15s must HOLD because commit manifest is absent pause = json.loads((WATCHER_ROOT / "report_pause_15s.json").read_text(encoding="utf-8")) assert pause["filesystem"]["manifest_committed"] is False assert pause["expected_policy"]["decision"] == "hold" # 4. finalized_candidate alone approves completion finalized = json.loads((WATCHER_ROOT / "finalized_candidate.json").read_text(encoding="utf-8")) assert finalized["dispatch_context"]["execution_anchor_observed"] is True assert finalized["filesystem"]["manifest_committed"] is True assert finalized["filesystem"]["hash_verified"] is True assert finalized["expected_policy"]["decision"] == "candidate_approved" # 5. restarted_generation must REJECT stale attempt restarted = json.loads((WATCHER_ROOT / "restarted_generation.json").read_text(encoding="utf-8")) assert restarted["expected_policy"]["decision"] == "reject_stale" # =========================================================================== # 4. Negative / Mutation Tests (Evidence Boundary & Corruption Rejection) # =========================================================================== def test_negative_native_capture_with_injected_mam_field_fails(): sample_file = HERDR_ROOT / "claude" / "idle.json" cap = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(cap) corrupted["stdout_payload"]["result"]["agent"]["report_phase"] = "writing" corrupted["payload"]["result"]["agent"]["report_phase"] = "writing" with pytest.raises(AssertionError, match="Forbidden MAM field 'report_phase'"): validate_native_capture(corrupted) def test_negative_native_capture_channel_exit_code_mismatch_fails(): sample_file = HERDR_ROOT / "claude" / "idle.json" cap = json.loads(sample_file.read_text(encoding="utf-8")) # exit 0 with non-null stderr must fail corrupted = copy.deepcopy(cap) corrupted["stderr"] = "some error" with pytest.raises(AssertionError, match="exit 0 must have null stderr"): validate_native_capture(corrupted) def test_negative_watcher_missing_session_uuid_fails(): sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) del corrupted["dispatch_context"]["agent_session_uuid"] with pytest.raises(AssertionError, match="Missing attribution field"): validate_watcher_scenario(corrupted) corrupted2 = copy.deepcopy(sc) corrupted2["dispatch_context"]["agent_session_uuid"] = None with pytest.raises(AssertionError, match="must not be null"): validate_watcher_scenario(corrupted2) def test_negative_watcher_simulated_labeled_observed_composite_fails(): sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) corrupted["provenance"] = "observed_composite" # Timeline has simulated items, so labeling as observed_composite must fail with pytest.raises(AssertionError, match="overall provenance must be 'simulated'"): validate_watcher_scenario(corrupted) def test_negative_watcher_observed_item_with_missing_capture_ref_fails(): sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) corrupted["timeline"][0]["source_provenance"] = "observed" corrupted["timeline"][0]["capture_ref"] = "nonexistent/capture/path.json" with pytest.raises(AssertionError, match="points to nonexistent capture_ref"): validate_watcher_scenario(corrupted) def test_negative_watcher_observed_item_pointing_to_unrelated_file_fails(): """P1: Pointing an observed timeline reference to an existing unrelated file (e.g. README.md) must fail.""" sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) corrupted["provenance"] = "observed_composite" for item in corrupted["timeline"]: item["source_provenance"] = "observed" item["capture_ref"] = "README.md" with pytest.raises(AssertionError, match="JSON evidence record"): validate_watcher_scenario(corrupted) def test_negative_watcher_observed_item_with_mismatched_payload_fails(): """P1: Referencing a valid capture with a mismatched payload state must fail.""" sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) corrupted["timeline"][0]["source_kind"] = "herdr" corrupted["timeline"][0]["source_provenance"] = "observed" # Points to claude/idle.json (which records agent_status='idle') corrupted["timeline"][0]["capture_ref"] = "tests/fixtures/issue_5/herdr/claude/idle.json" corrupted["timeline"][0]["payload"] = {"agent_status": "working"} # Mismatch! with pytest.raises(AssertionError, match="Payload correspondence mismatch"): validate_watcher_scenario(corrupted) def test_negative_watcher_coexisting_sources_labeled_simulated_fails(): """P1: Coexistence of observed and simulated sources must be labeled 'mixed', not 'simulated'.""" sample_file = WATCHER_ROOT / "initial_idle.json" sc = json.loads(sample_file.read_text(encoding="utf-8")) corrupted = copy.deepcopy(sc) corrupted["provenance"] = "simulated" # Dishonest! Should be mixed corrupted["timeline"][0]["source_kind"] = "herdr" corrupted["timeline"][0]["source_provenance"] = "observed" corrupted["timeline"][0]["capture_ref"] = "tests/fixtures/issue_5/herdr/claude/idle.json" corrupted["timeline"][0]["payload"] = {"agent_status": "idle"} # timeline[1] remains simulated with pytest.raises(AssertionError, match="overall provenance must be 'mixed'"): validate_watcher_scenario(corrupted)