rapier-runtime 0.6.2__tar.gz → 0.6.3__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 (114) hide show
  1. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/CHANGELOG.md +13 -0
  2. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/PKG-INFO +1 -1
  3. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/STATUS.md +18 -1
  4. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/202611141130.inflight.accountable-agents-gaps.md +1 -1
  5. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/pyproject.toml +1 -1
  6. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/cli.py +5 -5
  7. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/mcp/tools.py +3 -5
  8. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/operator_policy.py +30 -7
  9. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_operator_minimum.py +44 -2
  10. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.claude/scripts/loom-deploy-nonprod.sh +0 -0
  11. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.claude/scripts/loom-deploy-prod.sh +0 -0
  12. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.claude/scripts/loom-env-check.sh +0 -0
  13. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.claude/scripts/loom-state-docs.sh +0 -0
  14. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.githooks/pre-commit +0 -0
  15. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.github/workflows/secret-scan.yml +0 -0
  16. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.gitignore +0 -0
  17. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.gitleaks.toml +0 -0
  18. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.loom/audits/smuggled-virtue.done +0 -0
  19. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.loom/launcher.conf +0 -0
  20. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/.loom/project.json +0 -0
  21. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/LICENSE +0 -0
  22. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/README.md +0 -0
  23. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/SECURITY.md +0 -0
  24. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/.gitkeep +0 -0
  25. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/202607301413.checkpoint.handoff-cut-0-5-0-to-pypi.md +0 -0
  26. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/20260802-explainability-reminder.md +0 -0
  27. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/202610162038.five-releases-defaults-report-mcp2.md +0 -0
  28. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/chatlogs/202610162128.reconcile-false-alarms-0.6.1.md +0 -0
  29. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/mcp-server-scope.md +0 -0
  30. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/threat-model.md +0 -0
  31. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/docs/versioning.md +0 -0
  32. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/manifests/echo.yaml +0 -0
  33. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/manifests/sparring.frame.yaml +0 -0
  34. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/manifests/sparring.full.yaml +0 -0
  35. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/manifests/sparring.proposer.yaml +0 -0
  36. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/manifests/sparring.spar.yaml +0 -0
  37. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/site/README.md +0 -0
  38. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/site/coming-soon.html +0 -0
  39. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/site/index.html +0 -0
  40. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/site/rapier-logo.png +0 -0
  41. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/__init__.py +0 -0
  42. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/_json.py +0 -0
  43. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/convergence.py +0 -0
  44. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/envelope.py +0 -0
  45. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/ledger.py +0 -0
  46. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/manifest.py +0 -0
  47. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/mcp/__init__.py +0 -0
  48. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/mcp/server.py +0 -0
  49. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/models.py +0 -0
  50. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/onboarding.py +0 -0
  51. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/pipeline.py +0 -0
  52. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/presets.py +0 -0
  53. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/secrets.py +0 -0
  54. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stage.py +0 -0
  55. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/__init__.py +0 -0
  56. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/echo.py +0 -0
  57. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/frame.py +0 -0
  58. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/proposer/__init__.py +0 -0
  59. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/proposer/phase_stages.py +0 -0
  60. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/proposer/phases.py +0 -0
  61. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/proposer/traceability_gate.py +0 -0
  62. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/reconcile.py +0 -0
  63. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/__init__.py +0 -0
  64. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/_binding.py +0 -0
  65. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/_extract.py +0 -0
  66. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/anchored_fix.py +0 -0
  67. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/author.py +0 -0
  68. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/citation_gate.py +0 -0
  69. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/compose.py +0 -0
  70. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/cross_review.py +0 -0
  71. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/stages/resolver/definitiveness_gate.py +0 -0
  72. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/__init__.py +0 -0
  73. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_bootstrap.py +0 -0
  74. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_llm_shim.py +0 -0
  75. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/PROVENANCE.md +0 -0
  76. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/cite_check.py +0 -0
  77. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/lib_llm.py +0 -0
  78. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/spar_cross_review.py +0 -0
  79. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/spar_definitiveness_gate.py +0 -0
  80. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/spar_verify_gate.py +0 -0
  81. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/_vendor/verify_grounding.py +0 -0
  82. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/rider_record.py +0 -0
  83. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/src/rapier/verify/service.py +0 -0
  84. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_cite_check_evidence_union.py +0 -0
  85. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_cli.py +0 -0
  86. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_convergence.py +0 -0
  87. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_correctness_outcome.py +0 -0
  88. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_declared_vs_ran.py +0 -0
  89. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_envelope.py +0 -0
  90. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_extract_json.py +0 -0
  91. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_final_state.py +0 -0
  92. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_frame_stage.py +0 -0
  93. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_full_ceremony.py +0 -0
  94. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_manifest.py +0 -0
  95. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_mcp.py +0 -0
  96. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_mcp_resources.py +0 -0
  97. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_method_version.py +0 -0
  98. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_models_vendors.py +0 -0
  99. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_onboarding.py +0 -0
  100. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_persistence_default.py +0 -0
  101. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_pipeline.py +0 -0
  102. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_policy.py +0 -0
  103. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_proposer_g2.py +0 -0
  104. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_proposer_stages.py +0 -0
  105. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_proposer_traceability_gate.py +0 -0
  106. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_reconcile.py +0 -0
  107. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_require_independent.py +0 -0
  108. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_resolver_stages.py +0 -0
  109. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_review_outcome.py +0 -0
  110. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_rider_record.py +0 -0
  111. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_secrets.py +0 -0
  112. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_single_vendor_report.py +0 -0
  113. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_vendor_policy_v4.py +0 -0
  114. {rapier_runtime-0.6.2 → rapier_runtime-0.6.3}/tests/test_verify_service.py +0 -0
@@ -4,6 +4,19 @@ Notable changes to `rapier-runtime`. Earlier releases are summarized in
4
4
  [`docs/STATUS.md`](docs/STATUS.md) and on the
5
5
  [Releases](https://github.com/muddyone/rapier-runtime/releases) page.
6
6
 
7
+ ## 0.6.3 — 2026-11-14
8
+
9
+ ### Fixed: an unreadable policy file is no longer reported as the minimum refusing the run
10
+
11
+ When the operator policy file could not be read or checked (bad JSON, an
12
+ unknown level, a non-boolean `require_independent`, a folder Rapier can't
13
+ search), 0.6.2 stopped the run, correctly, but credited the stop to the
14
+ operator minimum (`refused_by: "operator_minimum"`, `final_state: "refuse"`).
15
+ The minimum was never read, so it decided nothing. The run still stops (MCP
16
+ `ok: false`, CLI exit 2), now with `final_state: "defer"`, no `refused_by`, and
17
+ `operator_policy: "did-not-run: <reason>"`. A readable policy that refuses a
18
+ request is unchanged: `refuse`, credited to the control that decided.
19
+
7
20
  ## 0.6.2 — 2026-11-14
8
21
 
9
22
  ### Added: every run names its final state
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapier-runtime
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: A code-orchestrated engine that runs the SPARRING method: grounded, cross-vendor adversarial review for AI-in-the-loop decisions.
5
5
  Project-URL: Homepage, https://rapierruntime.com
6
6
  Project-URL: Repository, https://github.com/muddyone/rapier-runtime
@@ -1,9 +1,26 @@
1
1
  # Rapier — dev status
2
2
 
3
- _Last updated: 2026-10-16 (tag v0.6.1, 264 tests)._ A running "where we are /
3
+ _Last updated: 2026-11-14 (tag v0.6.2, 382 tests)._ A running "where we are /
4
4
  what's next" so a new session can continue without reconstructing from git log.
5
5
 
6
6
  ## Done
7
+ - **RELEASED (2026-11-14) — `rapier-runtime` 0.6.2 on PyPI. The six Accountable
8
+ Agents changes** (from Thomas Gessler's two concept papers,
9
+ doi:10.5281/zenodo.20687710 and doi:10.5281/zenodo.21850919). Git tag `v0.6.2`
10
+ (1020a765); https://pypi.org/project/rapier-runtime/0.6.2/. Built by
11
+ rapier-runtime-2 and passed item by item by the rapier-runtime-1 verifier in
12
+ the first multi-project team test (D-61); Bart chose 0.6.2 (D-69). In short:
13
+ an operator minimum for the citation check that a caller can't lower
14
+ (`/etc/rapier/policy.json`, reported as out of reach only when Rapier neither
15
+ owns nor can write it), and `require_independent` there; every check reports
16
+ `ran` / `nothing-to-check` / `did-not-run: <reason>`, judged on its last run;
17
+ a WHAT RAN section and pre-run manifest checks (`rapier doctor --manifest`);
18
+ a final state in the papers' six names, with a labelled NON-DECISION (D-67 a);
19
+ and a method hash (declared and as-run, including vendor bindings and the
20
+ package code). Full list in `CHANGELOG.md`. Verified from PyPI: a clean
21
+ install reports 0.6.2 and `rapier doctor` shows the operator minimum and the
22
+ per-ceremony checks. Follow-up, not blocking: key the "gate before the answer"
23
+ rule on the pipeline's stage order rather than writers' own traces.
7
24
  - **RELEASED (2026-10-16) — `rapier-runtime` 0.6.1 on PyPI. The arithmetic
8
25
  check ("WHETHER THE NUMBERS ADD UP") stops flagging correct formulas.** Git
9
26
  tag `v0.6.1`; https://pypi.org/project/rapier-runtime/0.6.1/. Reported by
@@ -4,7 +4,7 @@
4
4
  **Project**: rapier-runtime
5
5
  **HEAD**: 9214c14b (chatlog: reconcile false alarms fixed, 0.6.1 released)
6
6
  **Captured by**: Bart (from a vitae session, vit-1)
7
- **Status**: open
7
+ **Status**: closed (all six released in 0.6.2, 2026-11-14, tag v0.6.2 at 1020a765)
8
8
 
9
9
  ## Source
10
10
 
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
9
9
 
10
10
  [project]
11
11
  name = "rapier-runtime"
12
- version = "0.6.2"
12
+ version = "0.6.3"
13
13
  description = "A code-orchestrated engine that runs the SPARRING method: grounded, cross-vendor adversarial review for AI-in-the-loop decisions."
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -377,7 +377,7 @@ def main(argv: list[str] | None = None) -> int:
377
377
  if err: # no vendor keys — fail loudly, not silently
378
378
  print(err, file=sys.stderr)
379
379
  return 2
380
- from .operator_policy import PolicyError, decide_independence, decide_verify
380
+ from .operator_policy import PolicyError, decide_independence, decide_verify, stop_message
381
381
 
382
382
  try:
383
383
  decision = decide_verify(
@@ -388,7 +388,7 @@ def main(argv: list[str] | None = None) -> int:
388
388
  else "default",
389
389
  )
390
390
  except PolicyError as exc:
391
- print(f"refused: {exc}", file=sys.stderr)
391
+ print(stop_message(exc), file=sys.stderr)
392
392
  return 2
393
393
  preset = load_preset(
394
394
  args.cmd, settle=getattr(args, "settle", 0), verify=decision.mode,
@@ -398,7 +398,7 @@ def main(argv: list[str] | None = None) -> int:
398
398
  try:
399
399
  decide_independence(getattr(preset, "policy", None))
400
400
  except PolicyError as exc:
401
- print(f"refused: {exc}", file=sys.stderr)
401
+ print(stop_message(exc), file=sys.stderr)
402
402
  return 2
403
403
  # Carry context the pipeline can't observe onto the envelope for the
404
404
  # ceremony-ledger row: the resolver knobs, and the front-door Frame
@@ -422,7 +422,7 @@ def main(argv: list[str] | None = None) -> int:
422
422
  report_all=getattr(args, "report_all", False), seed_meta=seed_meta,
423
423
  no_save=getattr(args, "no_save", False))
424
424
  if args.cmd == "run":
425
- from .operator_policy import PolicyError, decide_independence, decide_manifest
425
+ from .operator_policy import PolicyError, decide_independence, decide_manifest, stop_message
426
426
 
427
427
  manifest = Manifest.load(args.manifest)
428
428
  try:
@@ -431,7 +431,7 @@ def main(argv: list[str] | None = None) -> int:
431
431
  for s in manifest.stages):
432
432
  decide_independence(manifest.policy)
433
433
  except PolicyError as exc:
434
- print(f"refused: {exc}", file=sys.stderr)
434
+ print(stop_message(exc), file=sys.stderr)
435
435
  return 2
436
436
  return _run(manifest, request, args.ledger_dir, seed_meta=decision.meta(),
437
437
  no_save=getattr(args, "no_save", False))
@@ -69,21 +69,19 @@ def _run(
69
69
  return {"ok": False, "error": err}
70
70
  # The operator minimum is applied here, before anything runs. Its sources are
71
71
  # a fixed system file and the process environment; no tool argument reaches it.
72
- from ..operator_policy import PolicyError, decide_independence, decide_verify
72
+ from ..operator_policy import PolicyError, decide_independence, decide_verify, stop_result
73
73
 
74
74
  set_by = ("fixed by the proposer tool" if name == "proposer"
75
75
  else "caller (MCP tool argument)")
76
76
  try:
77
77
  decision = decide_verify(verify, set_by)
78
78
  except PolicyError as exc:
79
- return {"ok": False, "error": str(exc), "refused_by": "operator_minimum",
80
- "final_state": "refuse"}
79
+ return stop_result(exc, "operator_minimum")
81
80
  preset = load_preset(name, settle=settle, verify=decision.mode, seed=seed, depth=depth)
82
81
  try:
83
82
  decide_independence(getattr(preset, "policy", None))
84
83
  except PolicyError as exc:
85
- return {"ok": False, "error": str(exc), "refused_by": "operator_require_independent",
86
- "final_state": "refuse"}
84
+ return stop_result(exc, "operator_require_independent")
87
85
  # Carry context the pipeline can't observe onto the envelope for the ceremony-ledger
88
86
  # row: the resolver knobs and the front-door Frame classification (a separate call).
89
87
  seed_meta: dict[str, Any] = {"settle": settle, **decision.meta()}
@@ -59,8 +59,31 @@ POLICY_PATH = _default_policy_path()
59
59
 
60
60
 
61
61
  class PolicyError(Exception):
62
- """The run is refused: a caller asked for less than the operator minimum,
63
- or the operator's policy file is unreadable (fail closed)."""
62
+ """The run is refused: the operator's policy DECIDED against it (a caller
63
+ asked for less than the minimum, or no independent review is possible)."""
64
+
65
+
66
+ class PolicyUnreadable(PolicyError):
67
+ """The run is stopped because the operator's policy could not be read or
68
+ checked (fail closed). The control did not run, so it decided nothing: the
69
+ stop holds, but it is never credited to the minimum, and the final state is
70
+ ``defer``, not ``refuse``. Subclasses PolicyError so a caller that only
71
+ wants "stopped or not" still catches both."""
72
+
73
+
74
+ def stop_result(exc: PolicyError, refused_by: str) -> dict:
75
+ """The MCP result for a policy stop, attributing it only where a control decided."""
76
+ if isinstance(exc, PolicyUnreadable):
77
+ return {"ok": False, "error": str(exc), "final_state": "defer",
78
+ "operator_policy": f"did-not-run: {exc}"}
79
+ return {"ok": False, "error": str(exc), "refused_by": refused_by, "final_state": "refuse"}
80
+
81
+
82
+ def stop_message(exc: PolicyError) -> str:
83
+ """The CLI's stderr line for a policy stop (exit code 2 either way)."""
84
+ if isinstance(exc, PolicyUnreadable):
85
+ return f"stopped (operator policy did not run, final state defer): {exc}"
86
+ return f"refused: {exc}"
64
87
 
65
88
 
66
89
  @dataclass(frozen=True)
@@ -87,7 +110,7 @@ class VerifyDecision:
87
110
 
88
111
  def _check_level(level, where: str) -> str:
89
112
  if level not in VERIFY_RANK:
90
- raise PolicyError(f"operator policy {where}: unknown verify level {level!r}; "
113
+ raise PolicyUnreadable(f"operator policy {where}: unknown verify level {level!r}; "
91
114
  f"known: {sorted(VERIFY_RANK)}")
92
115
  return level
93
116
 
@@ -136,19 +159,19 @@ def _read_policy_file() -> tuple[dict, str, bool] | None:
136
159
  except OSError as exc:
137
160
  # Can't tell whether a policy exists (e.g. its directory can't be searched).
138
161
  # An operator who locked the directory down meant a policy to apply: fail closed.
139
- raise PolicyError(f"operator policy file {path} could not be checked ({exc}); "
162
+ raise PolicyUnreadable(f"operator policy file {path} could not be checked ({exc}); "
140
163
  "refusing to run without the minimum it may set") from exc
141
164
  try:
142
165
  with open(path, encoding="utf-8") as fh:
143
166
  data = json.load(fh)
144
167
  except (OSError, ValueError) as exc:
145
- raise PolicyError(f"operator policy file {path} could not be read ({exc}); "
168
+ raise PolicyUnreadable(f"operator policy file {path} could not be read ({exc}); "
146
169
  "refusing to run without the minimum it sets") from exc
147
170
  if not isinstance(data, dict) or not any(k in data for k in _POLICY_KEYS):
148
- raise PolicyError(f"operator policy file {path} sets none of {list(_POLICY_KEYS)}; "
171
+ raise PolicyUnreadable(f"operator policy file {path} sets none of {list(_POLICY_KEYS)}; "
149
172
  "refusing to run without the minimum it was meant to set")
150
173
  if "require_independent" in data and not isinstance(data["require_independent"], bool):
151
- raise PolicyError(f"operator policy file {path}: require_independent must be "
174
+ raise PolicyUnreadable(f"operator policy file {path}: require_independent must be "
152
175
  "true or false; refusing to run")
153
176
  return data, path, _out_of_my_reach(path)
154
177
 
@@ -212,7 +212,9 @@ def test_unsearchable_policy_dir_fails_closed(policy, mcp_stubbed):
212
212
  if os.access(path.parent, os.X_OK):
213
213
  pytest.skip("this filesystem ignores chmod")
214
214
  out = tools.run_spar("ship it?", verify="off")
215
- assert out["ok"] is False and out["refused_by"] == "operator_minimum"
215
+ # R4-3: the minimum was never read, so the stop holds but is not credited to it
216
+ assert out["ok"] is False and out["final_state"] == "defer"
217
+ assert "refused_by" not in out and out["operator_policy"].startswith("did-not-run: ")
216
218
  assert "could not be checked" in out["error"]
217
219
  with pytest.raises(PolicyError):
218
220
  decide_verify("gate", "caller") # can't know the floor: refuse every run
@@ -226,7 +228,9 @@ def test_bad_policy_file_fails_closed(policy, mcp_stubbed, body):
226
228
 
227
229
  pathlib.Path(OP.POLICY_PATH).write_text(body)
228
230
  out = tools.run_spar("ship it?", verify="gate")
229
- assert out["ok"] is False and out["refused_by"] == "operator_minimum"
231
+ # R4-3: the minimum was never read, so the stop holds but is not credited to it
232
+ assert out["ok"] is False and out["final_state"] == "defer"
233
+ assert "refused_by" not in out and out["operator_policy"].startswith("did-not-run: ")
230
234
 
231
235
 
232
236
  # ── the report says who set the level, and whether the minimum is external ──
@@ -262,3 +266,41 @@ def test_report_says_an_in_reach_minimum_is_not_independent():
262
266
  env = _env(verify="gate", verify_set_by="default", verify_minimum="gate",
263
267
  verify_minimum_source="RAPIER_MIN_VERIFY", verify_minimum_external=False)
264
268
  assert "not an independent control" in " ".join(_render_report(env).split())
269
+
270
+
271
+ # ── R4-3: a stop is credited to a control only when that control decided ────
272
+
273
+ @pytest.mark.parametrize("body", ['{"min_verify":', '{"min_verify": "loud"}', "{}",
274
+ '{"require_independent": "yes"}'])
275
+ def test_unreadable_policy_stops_as_defer_not_refuse(policy, mcp_stubbed, body):
276
+ import pathlib
277
+
278
+ pathlib.Path(OP.POLICY_PATH).write_text(body)
279
+ out = tools.run_spar("decide X", verify="gate")
280
+ assert out["ok"] is False
281
+ assert out["final_state"] == "defer"
282
+ assert "refused_by" not in out
283
+ assert out["operator_policy"].startswith("did-not-run: ")
284
+
285
+
286
+ def test_control_readable_minimum_refuses_and_is_credited(policy, mcp_stubbed):
287
+ policy("gate")
288
+ out = tools.run_spar("decide X", verify="off")
289
+ assert out["final_state"] == "refuse" and out["refused_by"] == "operator_minimum"
290
+ assert "operator_policy" not in out
291
+
292
+
293
+ def test_cli_unreadable_policy_says_defer(policy, monkeypatch, capsys):
294
+ import pathlib
295
+
296
+ from rapier import cli, onboarding
297
+
298
+ monkeypatch.setattr(onboarding, "preflight_error", lambda: None)
299
+ pathlib.Path(OP.POLICY_PATH).write_text('{"min_verify":')
300
+ assert cli.main(["spar", "--request", "x"]) == 2
301
+ err = capsys.readouterr().err
302
+ assert "operator policy did not run, final state defer" in err and "refused:" not in err
303
+
304
+
305
+ def test_unreadable_policy_is_a_policy_error_subclass():
306
+ assert issubclass(OP.PolicyUnreadable, OP.PolicyError)
File without changes
File without changes