test(issue-5): implement fail-closed evidence dispatch and fix VALID_STATUSES citation
- Fix Codex [P1]: add SUPPORTED_SOURCE_KINDS dispatch and enforce fail-closed rejection for non-herdr observed items without independent schema - Add strict correspondence checks for native Herdr observed items (agent_status, native schema, session UUID) - Add negative test reproducing Codex in-memory mutation of finalized_candidate - Fix Claude Finding 2: update VALID_STATUSES citation in baseline.md to registry.py:38 - Update Phase 0 collected test counts to 499 in contract docs
This commit is contained in:
1 parent
dd774b1a9f
commit
19d798bf8f
4 files changed
+288
-54
No files matched your search
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 0 contract and fixture verification tests for Issue #5 (Rev.4).
|
||||
"""Phase 0 contract and fixture verification tests for Issue #5 (Rev.5).
|
||||
|
||||
Validates:
|
||||
1. Herdr Layer 1 native contract captures (pure native observation, no injected MAM fields).
|
||||
@@ -9,6 +9,10 @@ Validates:
|
||||
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.
|
||||
7. Source-kind dispatch: every timeline item must declare a supported source kind, and an item may only be
|
||||
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.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@@ -32,6 +36,18 @@ FORBIDDEN_MAM_KEYS = {
|
||||
"manifest_hash",
|
||||
}
|
||||
|
||||
# Explicit set of source kinds a Watcher timeline item may declare. Anything else is rejected.
|
||||
SUPPORTED_SOURCE_KINDS = {"herdr", "event", "filesystem", "harness"}
|
||||
|
||||
# Source kinds for which an independent, machine-checkable evidence schema is implemented.
|
||||
# Only these may carry source_provenance == "observed". All other supported kinds are
|
||||
# fail-closed for observed claims until their evidence schema exists.
|
||||
OBSERVED_EVIDENCE_SCHEMAS = {"herdr"}
|
||||
|
||||
# For observed Herdr items, these payload fields MUST be declared so that the comparison
|
||||
# against the native capture cannot be skipped.
|
||||
HERDR_MANDATORY_ITEM_FIELDS = ("agent_status",)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Validation Engine Functions (Used for both real fixtures & negative tests)
|
||||
@@ -82,6 +98,92 @@ def validate_native_capture(cap: dict, file_path: Path = None) -> None:
|
||||
_check_no_forbidden(payload_to_check)
|
||||
|
||||
|
||||
def _load_observed_capture_ref(index: int, item: dict) -> "tuple[Path, dict]":
|
||||
"""Common envelope checks for any observed timeline item's capture_ref. Returns (path, record)."""
|
||||
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"]
|
||||
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}"
|
||||
|
||||
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 isinstance(ref_data, dict), f"capture_ref {ref_path} must be a JSON object evidence record"
|
||||
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"
|
||||
return ref_path, ref_data
|
||||
|
||||
|
||||
def _extract_native_agent_object(ref_data: dict):
|
||||
"""Returns the native `result.agent` object from a Herdr `agent get`-style capture, or None."""
|
||||
payload_obj = ref_data.get("stdout_payload")
|
||||
if not isinstance(payload_obj, dict):
|
||||
return None
|
||||
result = payload_obj.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
agent = result.get("agent")
|
||||
if not isinstance(agent, dict):
|
||||
return None
|
||||
return agent
|
||||
|
||||
|
||||
def _validate_observed_herdr_item(index: int, item: dict, dctx: dict) -> None:
|
||||
"""Strict evidence correspondence for an observed Herdr timeline item.
|
||||
|
||||
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.
|
||||
"""
|
||||
ref_path, ref_data = _load_observed_capture_ref(index, item)
|
||||
|
||||
# Must be a native Herdr capture, and must itself pass the Layer 1 native contract.
|
||||
assert "herdr_version" in ref_data and "backend" in ref_data, (
|
||||
f"capture_ref {ref_path} for herdr source must be a native Herdr capture"
|
||||
)
|
||||
validate_native_capture(ref_data, file_path=ref_path)
|
||||
|
||||
item_payload = item.get("payload")
|
||||
assert isinstance(item_payload, dict) and item_payload, (
|
||||
f"Observed herdr timeline item [{index}] must declare a non-empty payload dict"
|
||||
)
|
||||
for mandatory in HERDR_MANDATORY_ITEM_FIELDS:
|
||||
assert mandatory in item_payload, (
|
||||
f"Observed herdr timeline item [{index}] is missing mandatory native field '{mandatory}'; "
|
||||
f"comparison against {ref_path} cannot be skipped"
|
||||
)
|
||||
|
||||
agent_obj = _extract_native_agent_object(ref_data)
|
||||
assert agent_obj is not None, (
|
||||
f"capture_ref {ref_path} does not contain a native agent object (exit_code={ref_data.get('exit_code')}); "
|
||||
f"it cannot substantiate observed herdr timeline item [{index}]"
|
||||
)
|
||||
|
||||
# Identity correspondence: 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"], (
|
||||
f"Identity correspondence mismatch: dispatch_context.agent_session_uuid="
|
||||
f"'{dctx['agent_session_uuid']}' but capture {ref_path} records agent_session.value='{capture_uuid}'"
|
||||
)
|
||||
|
||||
# Payload correspondence: every declared field must exist natively and match exactly.
|
||||
for key, declared in item_payload.items():
|
||||
assert key in agent_obj, (
|
||||
f"Payload correspondence mismatch: timeline item [{index}] declares field '{key}' which is not a "
|
||||
f"native field of capture {ref_path}; observed herdr items may only declare native fields"
|
||||
)
|
||||
assert agent_obj[key] == declared, (
|
||||
f"Payload correspondence mismatch: timeline specifies {key}='{declared}' "
|
||||
f"but capture {ref_path} records {key}='{agent_obj[key]}'"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
@@ -115,58 +217,39 @@ def validate_watcher_scenario(data: dict, file_path: Path = None) -> None:
|
||||
}, f"Invalid expected policy decision: {policy.get('decision')}"
|
||||
assert policy.get("reason"), "expected_policy.reason must not be empty"
|
||||
|
||||
# Timeline authenticity & correspondence
|
||||
# Timeline authenticity & correspondence (dispatched by explicit source kind)
|
||||
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"]):
|
||||
source_kind = item.get("source_kind")
|
||||
assert source_kind in SUPPORTED_SOURCE_KINDS, (
|
||||
f"Timeline item [{i}] declares unsupported source_kind '{source_kind}'; "
|
||||
f"supported: {sorted(SUPPORTED_SOURCE_KINDS)}"
|
||||
)
|
||||
|
||||
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:
|
||||
if item_prov != "observed":
|
||||
simulated_count += 1
|
||||
continue
|
||||
|
||||
observed_count += 1
|
||||
|
||||
# Fail-closed: an observed claim is only acceptable for source kinds with an evidence schema.
|
||||
assert source_kind in OBSERVED_EVIDENCE_SCHEMAS, (
|
||||
f"Timeline item [{i}] (source_kind='{source_kind}') is marked observed but no independent "
|
||||
f"evidence schema is implemented for this source kind; it must remain 'simulated' "
|
||||
f"(observed schemas: {sorted(OBSERVED_EVIDENCE_SCHEMAS)})"
|
||||
)
|
||||
|
||||
if source_kind == "herdr":
|
||||
_validate_observed_herdr_item(i, item, dctx)
|
||||
else: # pragma: no cover - guarded by the assertion above
|
||||
raise AssertionError(f"Unhandled observed source_kind '{source_kind}' at timeline item [{i}]")
|
||||
|
||||
# Provenance honesty invariant
|
||||
if observed_count > 0 and simulated_count > 0:
|
||||
@@ -382,22 +465,24 @@ def test_negative_watcher_observed_item_with_missing_capture_ref_fails():
|
||||
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"
|
||||
corrupted["provenance"] = "mixed"
|
||||
# timeline[1] is the herdr item in initial_idle (the only source kind with an observed evidence schema).
|
||||
corrupted["timeline"][1]["source_provenance"] = "observed"
|
||||
corrupted["timeline"][1]["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."""
|
||||
"""P1: Pointing an observed herdr 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"
|
||||
corrupted["provenance"] = "mixed"
|
||||
# timeline[1] is the herdr item in initial_idle; timeline[0] (event) stays simulated.
|
||||
corrupted["timeline"][1]["source_provenance"] = "observed"
|
||||
corrupted["timeline"][1]["capture_ref"] = "README.md"
|
||||
with pytest.raises(AssertionError, match="JSON evidence record"):
|
||||
validate_watcher_scenario(corrupted)
|
||||
|
||||
@@ -431,3 +516,152 @@ def test_negative_watcher_coexisting_sources_labeled_simulated_fails():
|
||||
# timeline[1] remains simulated
|
||||
with pytest.raises(AssertionError, match="overall provenance must be 'mixed'"):
|
||||
validate_watcher_scenario(corrupted)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. Source-Kind Dispatch & Evidence Boundary Tests (Codex [P1], Rev.5)
|
||||
# ===========================================================================
|
||||
|
||||
HERDR_CLAUDE_IDLE = "tests/fixtures/issue_5/herdr/claude/idle.json"
|
||||
HERDR_CODEX_DONE = "tests/fixtures/issue_5/herdr/codex/done.json"
|
||||
HERDR_AGY_TIMEOUT = "tests/fixtures/issue_5/herdr/agy/wait_timeout.json"
|
||||
|
||||
|
||||
def _load_scenario(name: str) -> dict:
|
||||
return json.loads((WATCHER_ROOT / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_negative_codex_reproduction_finalized_candidate_all_observed_via_idle_capture_fails():
|
||||
"""Codex [P1] reproduction: relabeling every finalized_candidate item as observed and pointing them all
|
||||
at an unrelated native idle snapshot must be rejected. A terminal idle capture cannot prove an
|
||||
external started event or a finalized report manifest."""
|
||||
x = _load_scenario("finalized_candidate.json")
|
||||
x["provenance"] = "observed_composite"
|
||||
for item in x["timeline"]:
|
||||
item["source_provenance"] = "observed"
|
||||
item["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
with pytest.raises(AssertionError, match="no independent evidence schema"):
|
||||
validate_watcher_scenario(x)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_kind", sorted(SUPPORTED_SOURCE_KINDS - OBSERVED_EVIDENCE_SCHEMAS))
|
||||
def test_negative_non_herdr_source_kinds_are_fail_closed_when_observed(source_kind):
|
||||
"""event / filesystem / harness have no evidence schema: an observed claim must be rejected even
|
||||
when capture_ref points at a perfectly valid native Herdr capture."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][0]["source_kind"] = source_kind
|
||||
sc["timeline"][0]["source_provenance"] = "observed"
|
||||
sc["timeline"][0]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
with pytest.raises(AssertionError, match=f"source_kind='{source_kind}'.*no independent evidence schema"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_kind", sorted(SUPPORTED_SOURCE_KINDS - OBSERVED_EVIDENCE_SCHEMAS))
|
||||
def test_non_herdr_source_kinds_remain_accepted_when_simulated(source_kind):
|
||||
"""The existing positive simulated fixtures must keep working: non-herdr kinds are fine as simulated."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["timeline"][0]["source_kind"] = source_kind
|
||||
sc["timeline"][0]["source_provenance"] = "simulated"
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_unsupported_source_kind_rejected_regardless_of_provenance():
|
||||
for prov in ("simulated", "observed"):
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["timeline"][0]["source_kind"] = "mqtt_broker_log"
|
||||
sc["timeline"][0]["source_provenance"] = prov
|
||||
sc["timeline"][0]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
with pytest.raises(AssertionError, match="unsupported source_kind 'mqtt_broker_log'"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_observed_herdr_item_missing_mandatory_agent_status_fails():
|
||||
"""Omitting agent_status must not let the comparison be skipped (fail-closed on missing mandatory field)."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
sc["timeline"][1]["payload"] = {"interactive_ready": True} # agent_status deliberately absent
|
||||
with pytest.raises(AssertionError, match="missing mandatory native field 'agent_status'"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
sc["timeline"][1]["payload"] = {}
|
||||
with pytest.raises(AssertionError, match="non-empty payload dict"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_observed_herdr_item_declaring_non_native_field_fails():
|
||||
"""An observed herdr item may only declare fields the native capture can substantiate."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
sc["timeline"][1]["payload"] = {"agent_status": "idle", "matched_rule": "permission_prompt"}
|
||||
with pytest.raises(AssertionError, match="declares field 'matched_rule' which is not a native field"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_observed_herdr_item_secondary_field_mismatch_fails():
|
||||
"""Every declared field is compared, not only agent_status."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
sc["timeline"][1]["payload"] = {"agent_status": "idle", "interactive_ready": False} # capture says True
|
||||
with pytest.raises(AssertionError, match="timeline specifies interactive_ready='False'"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_observed_herdr_item_referencing_capture_without_agent_object_fails():
|
||||
"""A nonzero-exit wait/timeout capture carries no agent object and cannot prove an agent_status."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_AGY_TIMEOUT
|
||||
sc["timeline"][1]["payload"] = {"agent_status": "idle"}
|
||||
with pytest.raises(AssertionError, match="does not contain a native agent object"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_negative_observed_herdr_item_session_identity_mismatch_fails():
|
||||
"""The capture must be about the session the scenario attributes (4-fold attribution identity)."""
|
||||
sc = _load_scenario("initial_idle.json") # attributes 7d5700d0... (claude)
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_CODEX_DONE # records 01a071d1... (codex)
|
||||
sc["timeline"][1]["payload"] = {"agent_status": "done"}
|
||||
with pytest.raises(AssertionError, match="Identity correspondence mismatch"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
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."""
|
||||
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]["payload"] = {"agent_status": "idle"}
|
||||
with pytest.raises(AssertionError, match="must have provenance 'observed'"):
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
|
||||
def test_positive_observed_herdr_item_with_full_native_correspondence_passes():
|
||||
"""Sanity check that the strict herdr path is not vacuously rejecting: a fully corresponding
|
||||
observed herdr item (matching identity and every declared native field) is accepted."""
|
||||
sc = _load_scenario("initial_idle.json")
|
||||
sc["provenance"] = "mixed"
|
||||
sc["timeline"][1]["source_provenance"] = "observed"
|
||||
sc["timeline"][1]["capture_ref"] = HERDR_CLAUDE_IDLE
|
||||
sc["timeline"][1]["payload"] = {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": True,
|
||||
"pane_id": "w2X:p1",
|
||||
"name": "planner-reviewer-claude-01",
|
||||
}
|
||||
validate_watcher_scenario(sc)
|
||||
|
||||
# Relabeling this mixed scenario as observed_composite while an event item stays simulated must fail.
|
||||
sc["provenance"] = "observed_composite"
|
||||
with pytest.raises(AssertionError, match="overall provenance must be 'mixed'"):
|
||||
validate_watcher_scenario(sc)
|
||||
Reference in new issue
Block a user