agentmandate 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {agentmandate-0.3.2 → agentmandate-0.4.0}/CHANGELOG.md +18 -0
  2. {agentmandate-0.3.2 → agentmandate-0.4.0}/PKG-INFO +29 -2
  3. {agentmandate-0.3.2 → agentmandate-0.4.0}/README.md +28 -1
  4. {agentmandate-0.3.2 → agentmandate-0.4.0}/ROADMAP.md +9 -0
  5. {agentmandate-0.3.2 → agentmandate-0.4.0}/STABILITY.md +1 -1
  6. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/__init__.py +19 -1
  7. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/cli.py +51 -0
  8. agentmandate-0.4.0/agentmandate/scenarios.py +334 -0
  9. {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/assets/authority-path.svg +18 -16
  10. agentmandate-0.4.0/docs/evaluation-loop.md +104 -0
  11. {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/test-obligations.md +5 -1
  12. {agentmandate-0.3.2 → agentmandate-0.4.0}/pyproject.toml +1 -1
  13. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_cli.py +70 -0
  14. agentmandate-0.4.0/tests/test_readme_asset.py +30 -0
  15. agentmandate-0.4.0/tests/test_scenarios.py +268 -0
  16. {agentmandate-0.3.2 → agentmandate-0.4.0}/.github/workflows/ci.yml +0 -0
  17. {agentmandate-0.3.2 → agentmandate-0.4.0}/.github/workflows/release.yml +0 -0
  18. {agentmandate-0.3.2 → agentmandate-0.4.0}/.gitignore +0 -0
  19. {agentmandate-0.3.2 → agentmandate-0.4.0}/CONTRIBUTING.md +0 -0
  20. {agentmandate-0.3.2 → agentmandate-0.4.0}/DESIGN.md +0 -0
  21. {agentmandate-0.3.2 → agentmandate-0.4.0}/LICENSE +0 -0
  22. {agentmandate-0.3.2 → agentmandate-0.4.0}/RELEASING.md +0 -0
  23. {agentmandate-0.3.2 → agentmandate-0.4.0}/SECURITY.md +0 -0
  24. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/diff.py +0 -0
  25. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/lint.py +0 -0
  26. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/manifest.py +0 -0
  27. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/obligations.py +0 -0
  28. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/py.typed +0 -0
  29. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/reach.py +0 -0
  30. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/scan.py +0 -0
  31. {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/verify.py +0 -0
  32. {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/manifest.md +0 -0
  33. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver-sod.yaml +0 -0
  34. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver-v2.yaml +0 -0
  35. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver.yaml +0 -0
  36. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/mcp-tools.json +0 -0
  37. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/observed-calls.jsonl +0 -0
  38. {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/reviewed-obligations.json +0 -0
  39. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_diff.py +0 -0
  40. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_lint.py +0 -0
  41. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_manifest.py +0 -0
  42. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_obligations.py +0 -0
  43. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_reach.py +0 -0
  44. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_scan.py +0 -0
  45. {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_verify.py +0 -0
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.4.0 - 2026-07-30
10
+
11
+ ### Added
12
+
13
+ - `mandate scenarios` exports each reachable breach as a versioned, structured
14
+ scenario-test skeleton. It preserves the counterexample while leaving the
15
+ environment, agent input, and expected control boundary for human review.
16
+ - Reviewed scenario fields reconcile against the current reachable witnesses.
17
+ Disappearing paths are removed and newly reachable paths return unreviewed.
18
+ - An evaluation-loop guide separates permitted reachability, observed agent
19
+ behaviour, per-call enforcement, and runtime conformance.
20
+
21
+ ### Changed
22
+
23
+ - The README and test-obligation guide connect decision-point obligations to
24
+ AgentVerity and compound paths to external scenario evaluators without
25
+ turning AgentMandate into a runner.
26
+
9
27
  ## 0.3.2 - 2026-07-28
10
28
 
11
29
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentmandate
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Compound-path and cross-release analysis of what an AI agent is permitted to do
5
5
  Project-URL: Homepage, https://github.com/mrwersa/agentmandate
6
6
  Project-URL: Repository, https://github.com/mrwersa/agentmandate
@@ -80,6 +80,8 @@ cannot:
80
80
  closed when a required control field is absent.
81
81
  - **What must the tests exercise?** `mandate obligations` turns reachable
82
82
  authority into reviewable test obligations.
83
+ - **Which compound risks need scenarios?** `mandate scenarios` preserves each
84
+ counterexample as a neutral test skeleton without inventing an agent prompt.
83
85
 
84
86
  Alpha. Apache-2.0.
85
87
 
@@ -217,6 +219,7 @@ A ceiling is the maximum **cumulative** value one tool may spend against one bin
217
219
  | `mandate diff` | Effective-authority comparison of two manifests, including limits, preconditions, approvals, effects, and scope minting. `--record` emits a change record |
218
220
  | `mandate verify` | Replays recorded tool calls against the manifest and fails closed when evidence required by a declared control is missing |
219
221
  | `mandate obligations` | Derives reviewable test obligations from reachable authority, and renders reviewed ones as an [AgentVerity](https://github.com/mrwersa/agentverity) decision suite |
222
+ | `mandate scenarios` | Exports reachable breach paths with blank environment, agent-input, and expected-control fields for human review and execution by an external evaluation harness |
220
223
 
221
224
  Every analysis command takes `--json` and exits non-zero on a finding, so they drop into CI unchanged. `scan` writes a manifest to standard output and is a one-off, not a gate.
222
225
 
@@ -241,6 +244,28 @@ For a spending tool, each trace record must carry the scope, value, currency,
241
244
  approval state, and executing principal. Missing or malformed control evidence
242
245
  does not pass as an empty value.
243
246
 
247
+ ## From authority to evaluation
248
+
249
+ AgentMandate produces two different test inputs:
250
+
251
+ - `obligations` names consequential decision points that reviewed bounded
252
+ decision tests should reach
253
+ - `scenarios` preserves compound counterexample paths that a multi-step
254
+ scenario test should attempt
255
+
256
+ It does not execute either test. Promptfoo, LangSmith, AgentCore Evaluations,
257
+ pytest, or an internal harness owns behaviour and outcome grading. AgentVerity
258
+ can qualify the repeated bounded decisions after correctness passes.
259
+
260
+ ```text
261
+ reachability -> reviewed obligations and scenarios -> external evaluation
262
+ ^ |
263
+ | v
264
+ manifest <- reviewed production incidents <- runtime policy and traces
265
+ ```
266
+
267
+ [Read the complete evaluation-loop workflow](docs/evaluation-loop.md).
268
+
244
269
  ## Where it fits, and what already exists
245
270
 
246
271
  This is analysis, not enforcement. It runs in CI against a manifest, it does not sit in the request path.
@@ -285,6 +310,8 @@ Search is bounded by `limits.depth`. No breach at depth 8 is not proof that none
285
310
  ## Documentation
286
311
 
287
312
  - [DESIGN.md](DESIGN.md) — the authority model, why the search is shaped this way, and what was left out
313
+ - [docs/evaluation-loop.md](docs/evaluation-loop.md) — how authority analysis, scenario evaluation, runtime policy, and production feedback remain distinct
314
+ - [docs/test-obligations.md](docs/test-obligations.md) — decision-point obligations and the AgentVerity bridge
288
315
  - [CONTRIBUTING.md](CONTRIBUTING.md) — branch and review workflow
289
316
  - [SECURITY.md](SECURITY.md) — reporting, and what a manifest may contain
290
317
  - [STABILITY.md](STABILITY.md) — what is guaranteed before 1.0
@@ -304,7 +331,7 @@ ruff check .
304
331
 
305
332
  ## Status
306
333
 
307
- Alpha, version 0.3.2. The authority model is the part most likely to change,
334
+ Alpha, version 0.4.0. The authority model is the part most likely to change,
308
335
  because it has not yet been pointed at enough real tool graphs to know where it
309
336
  is too coarse. Issues describing a graph it models badly are the most useful
310
337
  thing you can file.
@@ -46,6 +46,8 @@ cannot:
46
46
  closed when a required control field is absent.
47
47
  - **What must the tests exercise?** `mandate obligations` turns reachable
48
48
  authority into reviewable test obligations.
49
+ - **Which compound risks need scenarios?** `mandate scenarios` preserves each
50
+ counterexample as a neutral test skeleton without inventing an agent prompt.
49
51
 
50
52
  Alpha. Apache-2.0.
51
53
 
@@ -183,6 +185,7 @@ A ceiling is the maximum **cumulative** value one tool may spend against one bin
183
185
  | `mandate diff` | Effective-authority comparison of two manifests, including limits, preconditions, approvals, effects, and scope minting. `--record` emits a change record |
184
186
  | `mandate verify` | Replays recorded tool calls against the manifest and fails closed when evidence required by a declared control is missing |
185
187
  | `mandate obligations` | Derives reviewable test obligations from reachable authority, and renders reviewed ones as an [AgentVerity](https://github.com/mrwersa/agentverity) decision suite |
188
+ | `mandate scenarios` | Exports reachable breach paths with blank environment, agent-input, and expected-control fields for human review and execution by an external evaluation harness |
186
189
 
187
190
  Every analysis command takes `--json` and exits non-zero on a finding, so they drop into CI unchanged. `scan` writes a manifest to standard output and is a one-off, not a gate.
188
191
 
@@ -207,6 +210,28 @@ For a spending tool, each trace record must carry the scope, value, currency,
207
210
  approval state, and executing principal. Missing or malformed control evidence
208
211
  does not pass as an empty value.
209
212
 
213
+ ## From authority to evaluation
214
+
215
+ AgentMandate produces two different test inputs:
216
+
217
+ - `obligations` names consequential decision points that reviewed bounded
218
+ decision tests should reach
219
+ - `scenarios` preserves compound counterexample paths that a multi-step
220
+ scenario test should attempt
221
+
222
+ It does not execute either test. Promptfoo, LangSmith, AgentCore Evaluations,
223
+ pytest, or an internal harness owns behaviour and outcome grading. AgentVerity
224
+ can qualify the repeated bounded decisions after correctness passes.
225
+
226
+ ```text
227
+ reachability -> reviewed obligations and scenarios -> external evaluation
228
+ ^ |
229
+ | v
230
+ manifest <- reviewed production incidents <- runtime policy and traces
231
+ ```
232
+
233
+ [Read the complete evaluation-loop workflow](docs/evaluation-loop.md).
234
+
210
235
  ## Where it fits, and what already exists
211
236
 
212
237
  This is analysis, not enforcement. It runs in CI against a manifest, it does not sit in the request path.
@@ -251,6 +276,8 @@ Search is bounded by `limits.depth`. No breach at depth 8 is not proof that none
251
276
  ## Documentation
252
277
 
253
278
  - [DESIGN.md](DESIGN.md) — the authority model, why the search is shaped this way, and what was left out
279
+ - [docs/evaluation-loop.md](docs/evaluation-loop.md) — how authority analysis, scenario evaluation, runtime policy, and production feedback remain distinct
280
+ - [docs/test-obligations.md](docs/test-obligations.md) — decision-point obligations and the AgentVerity bridge
254
281
  - [CONTRIBUTING.md](CONTRIBUTING.md) — branch and review workflow
255
282
  - [SECURITY.md](SECURITY.md) — reporting, and what a manifest may contain
256
283
  - [STABILITY.md](STABILITY.md) — what is guaranteed before 1.0
@@ -270,7 +297,7 @@ ruff check .
270
297
 
271
298
  ## Status
272
299
 
273
- Alpha, version 0.3.2. The authority model is the part most likely to change,
300
+ Alpha, version 0.4.0. The authority model is the part most likely to change,
274
301
  because it has not yet been pointed at enough real tool graphs to know where it
275
302
  is too coarse. Issues describing a graph it models badly are the most useful
276
303
  thing you can file.
@@ -18,6 +18,11 @@ Version 0.3.2 makes the existing gate dependable:
18
18
  - explicit handling of incomparable currencies and different agents
19
19
  - reviewed test obligations reconciled against current reachable authority
20
20
 
21
+ ## In progress: evaluation-loop bridge
22
+
23
+ - neutral scenario skeletons derived from compound counterexample paths, with
24
+ application input, environment, and expected control left for human review
25
+
21
26
  ## Next: easier adoption
22
27
 
23
28
  1. **Framework inventory adapters.** Import tool catalogues from common agent
@@ -34,6 +39,10 @@ Version 0.3.2 makes the existing gate dependable:
34
39
  These features overlap with mature CI and security tooling deliberately. The
35
40
  new value remains the authority graph and its counterexamples.
36
41
 
42
+ The scenario exporter is the first evaluation-loop bridge. Keep execution in
43
+ the team's existing harness. The next adoption work should make manifests and
44
+ traces easier to obtain, not add a bundled judge or scenario runner.
45
+
37
46
  ## Then: widen the model where evidence demands it
38
47
 
39
48
  Do not implement every candidate below in parallel. First collect tool graphs
@@ -19,7 +19,7 @@ statement, not a waiver for silent breakage.
19
19
  Production users should pin the current minor series:
20
20
 
21
21
  ```text
22
- agentmandate~=0.2.0
22
+ agentmandate~=0.4.0
23
23
  ```
24
24
 
25
25
  ## What is most likely to change
@@ -13,7 +13,7 @@ against the declaration, so it is usable without another tool alongside it.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- __version__ = "0.3.2"
16
+ __version__ = "0.4.0"
17
17
 
18
18
  from .diff import Change, Delta, compare
19
19
  from .lint import Finding, check
@@ -29,6 +29,16 @@ from .obligations import (
29
29
  )
30
30
  from .reach import Authority, Breach, Step, analyse
31
31
  from .scan import Proposal, propose, render, scan_file
32
+ from .scenarios import (
33
+ SCENARIOS_SCHEMA,
34
+ Scenario,
35
+ ScenarioSet,
36
+ ScenarioStep,
37
+ derive_scenarios,
38
+ load_scenarios,
39
+ reconcile_scenarios,
40
+ save_scenarios,
41
+ )
32
42
  from .verify import Conformance, Observation, Violation, replay, replay_file
33
43
 
34
44
  __all__ = [
@@ -46,6 +56,10 @@ __all__ = [
46
56
  "ObligationSet",
47
57
  "Observation",
48
58
  "Proposal",
59
+ "SCENARIOS_SCHEMA",
60
+ "Scenario",
61
+ "ScenarioSet",
62
+ "ScenarioStep",
49
63
  "Step",
50
64
  "Tool",
51
65
  "Violation",
@@ -53,15 +67,19 @@ __all__ = [
53
67
  "analyse",
54
68
  "check",
55
69
  "derive",
70
+ "derive_scenarios",
56
71
  "compare",
57
72
  "load",
58
73
  "load_obligations",
74
+ "load_scenarios",
59
75
  "loads",
60
76
  "save_obligations",
77
+ "save_scenarios",
61
78
  "to_decision_suite",
62
79
  "propose",
63
80
  "render",
64
81
  "replay",
65
82
  "replay_file",
83
+ "reconcile_scenarios",
66
84
  "scan_file",
67
85
  ]
@@ -19,6 +19,12 @@ from .obligations import (
19
19
  )
20
20
  from .reach import analyse
21
21
  from .scan import scan_file
22
+ from .scenarios import (
23
+ derive_scenarios,
24
+ load_scenarios,
25
+ reconcile_scenarios,
26
+ save_scenarios,
27
+ )
22
28
  from .verify import replay_file
23
29
 
24
30
  EXIT_OK = 0
@@ -100,6 +106,26 @@ def build_parser() -> argparse.ArgumentParser:
100
106
  help="an obligations file whose decisions have been filled in",
101
107
  )
102
108
 
109
+ scenarios_parser = subparsers.add_parser(
110
+ "scenarios",
111
+ help="export reachable breaches as reviewed scenario-test skeletons",
112
+ )
113
+ _add_manifest(scenarios_parser)
114
+ scenarios_parser.add_argument("--depth", type=_positive_int, default=None)
115
+ scenarios_parser.add_argument(
116
+ "--json", action="store_true", help="machine-readable output"
117
+ )
118
+ scenarios_parser.add_argument(
119
+ "--reviewed",
120
+ default=None,
121
+ help="a scenario file whose application-specific fields were reviewed",
122
+ )
123
+ scenarios_parser.add_argument(
124
+ "--output",
125
+ default=None,
126
+ help="write the reconciled JSON scenario skeleton to this path",
127
+ )
128
+
103
129
  verify_parser = subparsers.add_parser(
104
130
  "verify", help="replay recorded calls against the manifest"
105
131
  )
@@ -171,6 +197,31 @@ def main(argv: Sequence[str] | None = None) -> int:
171
197
  _emit(obligations.to_dict(), args.json, obligations.render())
172
198
  return EXIT_FINDING if obligations.unreviewed else EXIT_OK
173
199
 
200
+ if args.command == "scenarios":
201
+ scenarios = derive_scenarios(mandate, depth=args.depth)
202
+ if args.reviewed:
203
+ try:
204
+ scenarios = reconcile_scenarios(
205
+ scenarios,
206
+ load_scenarios(args.reviewed),
207
+ )
208
+ except ValueError as exc:
209
+ print(f"error: {exc}", file=sys.stderr)
210
+ return EXIT_USAGE
211
+ if args.output:
212
+ try:
213
+ save_scenarios(scenarios, args.output)
214
+ except OSError as exc:
215
+ print(f"error: cannot write scenarios: {exc}", file=sys.stderr)
216
+ return EXIT_USAGE
217
+ print(
218
+ f"wrote {len(scenarios.scenarios)} scenario(s) to {args.output}; "
219
+ f"{len(scenarios.unreviewed)} need review"
220
+ )
221
+ else:
222
+ _emit(scenarios.to_dict(), args.json, scenarios.render())
223
+ return EXIT_FINDING if scenarios.scenarios else EXIT_OK
224
+
174
225
  if args.command == "lint":
175
226
  findings = check(mandate)
176
227
  payload = {
@@ -0,0 +1,334 @@
1
+ """Turn reachable breach paths into reviewable scenario-test skeletons.
2
+
3
+ ``reach`` proves that a call sequence is permitted by the reviewed manifest.
4
+ It does not prove that an agent will choose that sequence or that a deployed
5
+ control will allow it. A scenario test is the dynamic half of that question.
6
+
7
+ The exporter preserves the counterexample and leaves the application-specific
8
+ parts blank. A human must supply the environment setup, agent input, and
9
+ expected control boundary. The library never invents those facts.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+ from hashlib import sha256
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .manifest import Mandate
21
+ from .reach import Breach, Step, analyse
22
+
23
+ SCENARIOS_SCHEMA = "agentmandate.scenarios/v1"
24
+
25
+
26
+ def _required_string(value: Any, field: str, *, allow_blank: bool = False) -> str:
27
+ if not isinstance(value, str):
28
+ raise ValueError(f"scenario {field} must be a string")
29
+ if not allow_blank and not value.strip():
30
+ raise ValueError(f"scenario {field} must not be blank")
31
+ if value and not value.strip():
32
+ raise ValueError(f"scenario {field} must not be whitespace")
33
+ return value
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ScenarioStep:
38
+ """One structured tool call from a static counterexample."""
39
+
40
+ tool: str
41
+ binding: str | None = None
42
+ spent: str | None = None
43
+ currency: str | None = None
44
+
45
+ @classmethod
46
+ def from_step(cls, step: Step) -> ScenarioStep:
47
+ return cls(
48
+ tool=step.tool,
49
+ binding=step.binding,
50
+ spent=str(step.spent) if step.spent is not None else None,
51
+ currency=step.currency,
52
+ )
53
+
54
+ @classmethod
55
+ def from_dict(cls, value: Any) -> ScenarioStep:
56
+ if not isinstance(value, dict):
57
+ raise ValueError("scenario step must be an object")
58
+ tool = _required_string(value.get("tool"), "step tool")
59
+ binding = value.get("binding")
60
+ spent = value.get("spent")
61
+ currency = value.get("currency")
62
+ for field, item in (
63
+ ("step binding", binding),
64
+ ("step spent", spent),
65
+ ("step currency", currency),
66
+ ):
67
+ if item is not None:
68
+ _required_string(item, field)
69
+ if (spent is None) != (currency is None):
70
+ raise ValueError(
71
+ "scenario step spent and currency must be supplied together"
72
+ )
73
+ return cls(tool=tool, binding=binding, spent=spent, currency=currency)
74
+
75
+ def to_dict(self) -> dict[str, str]:
76
+ payload = {"tool": self.tool}
77
+ if self.binding is not None:
78
+ payload["binding"] = self.binding
79
+ if self.spent is not None:
80
+ payload["spent"] = self.spent
81
+ payload["currency"] = self.currency or ""
82
+ return payload
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Scenario:
87
+ """A static witness plus the application facts needed to execute it."""
88
+
89
+ kind: str
90
+ detail: str
91
+ path: tuple[ScenarioStep, ...]
92
+ environment: tuple[str, ...] = ()
93
+ agent_input: str = ""
94
+ expected_control: str = ""
95
+
96
+ @property
97
+ def identifier(self) -> str:
98
+ route = ">".join(step.tool for step in self.path)
99
+ witness = {
100
+ "kind": self.kind,
101
+ "detail": self.detail,
102
+ "path": [step.to_dict() for step in self.path],
103
+ }
104
+ digest = sha256(
105
+ json.dumps(
106
+ witness,
107
+ sort_keys=True,
108
+ separators=(",", ":"),
109
+ ).encode("utf-8")
110
+ ).hexdigest()[:12]
111
+ return f"{self.kind}:{route}:{digest}"
112
+
113
+ @property
114
+ def reviewed(self) -> bool:
115
+ return (
116
+ bool(self.environment)
117
+ and bool(self.agent_input.strip())
118
+ and bool(self.expected_control.strip())
119
+ )
120
+
121
+ @classmethod
122
+ def from_breach(cls, breach: Breach) -> Scenario:
123
+ return cls(
124
+ kind=breach.kind,
125
+ detail=breach.detail,
126
+ path=tuple(ScenarioStep.from_step(step) for step in breach.path),
127
+ )
128
+
129
+ @classmethod
130
+ def from_dict(cls, value: Any) -> Scenario:
131
+ if not isinstance(value, dict):
132
+ raise ValueError("scenario must be an object")
133
+ kind = _required_string(value.get("kind"), "kind")
134
+ detail = _required_string(value.get("detail"), "detail")
135
+ raw_path = value.get("path")
136
+ if not isinstance(raw_path, list) or not raw_path:
137
+ raise ValueError("scenario path must be a non-empty list")
138
+ raw_environment = value.get("environment", [])
139
+ if not isinstance(raw_environment, list) or any(
140
+ not isinstance(item, str) or not item.strip()
141
+ for item in raw_environment
142
+ ):
143
+ raise ValueError(
144
+ "scenario environment must be a list of non-blank strings"
145
+ )
146
+ scenario = cls(
147
+ kind=kind,
148
+ detail=detail,
149
+ path=tuple(ScenarioStep.from_dict(step) for step in raw_path),
150
+ environment=tuple(raw_environment),
151
+ agent_input=_required_string(
152
+ value.get("agent_input", ""), "agent_input", allow_blank=True
153
+ ),
154
+ expected_control=_required_string(
155
+ value.get("expected_control", ""),
156
+ "expected_control",
157
+ allow_blank=True,
158
+ ),
159
+ )
160
+ if "id" in value and value["id"] != scenario.identifier:
161
+ raise ValueError(
162
+ f"scenario id {value['id']!r} does not match "
163
+ f"{scenario.identifier!r}"
164
+ )
165
+ return scenario
166
+
167
+ def to_dict(self) -> dict[str, Any]:
168
+ return {
169
+ "id": self.identifier,
170
+ "kind": self.kind,
171
+ "detail": self.detail,
172
+ "path": [step.to_dict() for step in self.path],
173
+ "environment": list(self.environment),
174
+ "agent_input": self.agent_input,
175
+ "expected_control": self.expected_control,
176
+ }
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class ScenarioSet:
181
+ """Scenario skeletons derived from one bounded authority analysis."""
182
+
183
+ agent: str
184
+ depth: int
185
+ truncated: bool
186
+ scenarios: tuple[Scenario, ...]
187
+
188
+ @property
189
+ def unreviewed(self) -> tuple[str, ...]:
190
+ return tuple(
191
+ scenario.identifier
192
+ for scenario in self.scenarios
193
+ if not scenario.reviewed
194
+ )
195
+
196
+ def to_dict(self) -> dict[str, Any]:
197
+ return {
198
+ "schema": SCENARIOS_SCHEMA,
199
+ "agent": self.agent,
200
+ "depth": self.depth,
201
+ "truncated": self.truncated,
202
+ "scenarios": [scenario.to_dict() for scenario in self.scenarios],
203
+ }
204
+
205
+ @classmethod
206
+ def from_dict(cls, value: Any) -> ScenarioSet:
207
+ if not isinstance(value, dict):
208
+ raise ValueError("scenarios root must be an object")
209
+ if value.get("schema") != SCENARIOS_SCHEMA:
210
+ raise ValueError(
211
+ f"unsupported scenarios schema: {value.get('schema')!r}"
212
+ )
213
+ agent = _required_string(value.get("agent"), "agent")
214
+ depth = value.get("depth")
215
+ if isinstance(depth, bool) or not isinstance(depth, int) or depth < 1:
216
+ raise ValueError("scenario depth must be a positive integer")
217
+ truncated = value.get("truncated")
218
+ if not isinstance(truncated, bool):
219
+ raise ValueError("scenario truncated must be true or false")
220
+ raw_scenarios = value.get("scenarios")
221
+ if not isinstance(raw_scenarios, list):
222
+ raise ValueError("scenarios must be a list")
223
+ scenarios = tuple(Scenario.from_dict(item) for item in raw_scenarios)
224
+ identifiers = [scenario.identifier for scenario in scenarios]
225
+ if len(identifiers) != len(set(identifiers)):
226
+ raise ValueError("scenarios must not contain duplicate ids")
227
+ return cls(
228
+ agent=agent,
229
+ depth=depth,
230
+ truncated=truncated,
231
+ scenarios=scenarios,
232
+ )
233
+
234
+ def render(self) -> str:
235
+ lines = [f"counterexample scenarios for {self.agent}"]
236
+ if not self.scenarios:
237
+ lines.append(f" none within depth {self.depth}")
238
+ return "\n".join(lines)
239
+ for scenario in self.scenarios:
240
+ lines.append(f" {scenario.identifier}")
241
+ lines.append(
242
+ " path: " + " -> ".join(step.tool for step in scenario.path)
243
+ )
244
+ lines.append(
245
+ " environment: "
246
+ + (
247
+ "; ".join(scenario.environment)
248
+ if scenario.environment
249
+ else "REVIEW: describe the test fixture and starting state"
250
+ )
251
+ )
252
+ lines.append(
253
+ " agent input: "
254
+ + (
255
+ scenario.agent_input
256
+ or "REVIEW: write the input that should exercise this risk"
257
+ )
258
+ )
259
+ lines.append(
260
+ " expected: "
261
+ + (
262
+ scenario.expected_control
263
+ or "REVIEW: state where the deployed control must stop it"
264
+ )
265
+ )
266
+ if self.truncated:
267
+ lines.append("")
268
+ lines.append(
269
+ f"search stopped at depth {self.depth}; deeper paths were not analysed"
270
+ )
271
+ return "\n".join(lines)
272
+
273
+
274
+ def derive_scenarios(mandate: Mandate, depth: int | None = None) -> ScenarioSet:
275
+ """Preserve each reachable breach as a reviewable scenario skeleton."""
276
+ authority = analyse(mandate, depth=depth)
277
+ return ScenarioSet(
278
+ agent=mandate.agent,
279
+ depth=authority.depth,
280
+ truncated=authority.truncated,
281
+ scenarios=tuple(
282
+ Scenario.from_breach(breach) for breach in authority.breaches
283
+ ),
284
+ )
285
+
286
+
287
+ def reconcile_scenarios(
288
+ current: ScenarioSet,
289
+ reviewed: ScenarioSet,
290
+ ) -> ScenarioSet:
291
+ """Carry human-authored fields onto witnesses that remain reachable."""
292
+ if current.agent != reviewed.agent:
293
+ raise ValueError(
294
+ f"cannot reconcile scenarios for {reviewed.agent!r} with "
295
+ f"{current.agent!r}"
296
+ )
297
+ by_id = {scenario.identifier: scenario for scenario in reviewed.scenarios}
298
+ reconciled = []
299
+ for scenario in current.scenarios:
300
+ earlier = by_id.get(scenario.identifier)
301
+ if earlier is None:
302
+ reconciled.append(scenario)
303
+ continue
304
+ reconciled.append(
305
+ Scenario(
306
+ kind=scenario.kind,
307
+ detail=scenario.detail,
308
+ path=scenario.path,
309
+ environment=earlier.environment,
310
+ agent_input=earlier.agent_input,
311
+ expected_control=earlier.expected_control,
312
+ )
313
+ )
314
+ return ScenarioSet(
315
+ agent=current.agent,
316
+ depth=current.depth,
317
+ truncated=current.truncated,
318
+ scenarios=tuple(reconciled),
319
+ )
320
+
321
+
322
+ def load_scenarios(path: str | Path) -> ScenarioSet:
323
+ try:
324
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
325
+ except (OSError, json.JSONDecodeError) as exc:
326
+ raise ValueError(f"cannot load scenarios: {exc}") from exc
327
+ return ScenarioSet.from_dict(value)
328
+
329
+
330
+ def save_scenarios(scenarios: ScenarioSet, path: str | Path) -> None:
331
+ Path(path).write_text(
332
+ json.dumps(scenarios.to_dict(), indent=2, sort_keys=True) + "\n",
333
+ encoding="utf-8",
334
+ )