solo-builder-core 0.4.0__py3-none-any.whl
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.
- solo_builder/__init__.py +5 -0
- solo_builder/_canonical.py +59 -0
- solo_builder/_signing.py +88 -0
- solo_builder/acceptance_report.py +445 -0
- solo_builder/action_scanner.py +2229 -0
- solo_builder/action_tokens.py +309 -0
- solo_builder/actor_attestation.py +775 -0
- solo_builder/adapters/__init__.py +40 -0
- solo_builder/adapters/_common.py +145 -0
- solo_builder/adapters/anthropic.py +91 -0
- solo_builder/adapters/openai.py +85 -0
- solo_builder/adapters/stripe.py +96 -0
- solo_builder/adversarial_demo.py +449 -0
- solo_builder/agent.py +1913 -0
- solo_builder/agent_registry.py +278 -0
- solo_builder/ai.py +353 -0
- solo_builder/analyzers/go/go.mod +3 -0
- solo_builder/analyzers/go/main.go +269 -0
- solo_builder/analyzers/rust/.gitignore +1 -0
- solo_builder/analyzers/rust/Cargo.lock +120 -0
- solo_builder/analyzers/rust/Cargo.toml +18 -0
- solo_builder/analyzers/rust/src/main.rs +428 -0
- solo_builder/anomaly_engine.py +389 -0
- solo_builder/approvals.py +231 -0
- solo_builder/audit.py +164 -0
- solo_builder/authority_redemption.py +334 -0
- solo_builder/authority_resolution_log.py +110 -0
- solo_builder/autonomy.py +121 -0
- solo_builder/autonomy_envelope.py +1240 -0
- solo_builder/bootstrap.py +141 -0
- solo_builder/bridge_server.py +932 -0
- solo_builder/budget.py +268 -0
- solo_builder/build_event.py +641 -0
- solo_builder/build_genome.py +468 -0
- solo_builder/buildpack.py +203 -0
- solo_builder/bundle.py +388 -0
- solo_builder/bundle_verifier.py +566 -0
- solo_builder/capabilities.py +322 -0
- solo_builder/claim_discipline.py +193 -0
- solo_builder/cli.py +208 -0
- solo_builder/codebase_index.py +405 -0
- solo_builder/commands/__init__.py +1 -0
- solo_builder/commands/_shared.py +63 -0
- solo_builder/commands/account.py +154 -0
- solo_builder/commands/agent.py +144 -0
- solo_builder/commands/ai.py +159 -0
- solo_builder/commands/audit.py +50 -0
- solo_builder/commands/bridge.py +272 -0
- solo_builder/commands/compliance.py +765 -0
- solo_builder/commands/daemon.py +654 -0
- solo_builder/commands/demo.py +359 -0
- solo_builder/commands/distillation.py +338 -0
- solo_builder/commands/doctor.py +232 -0
- solo_builder/commands/eval.py +271 -0
- solo_builder/commands/evidence.py +350 -0
- solo_builder/commands/export.py +378 -0
- solo_builder/commands/forecast.py +107 -0
- solo_builder/commands/gate.py +149 -0
- solo_builder/commands/govern.py +1679 -0
- solo_builder/commands/governed_email.py +128 -0
- solo_builder/commands/init.py +67 -0
- solo_builder/commands/kernel.py +455 -0
- solo_builder/commands/linkedin.py +1994 -0
- solo_builder/commands/mcp_serve.py +129 -0
- solo_builder/commands/memory.py +144 -0
- solo_builder/commands/metrics.py +28 -0
- solo_builder/commands/mission_control.py +338 -0
- solo_builder/commands/onboard.py +530 -0
- solo_builder/commands/persona.py +64 -0
- solo_builder/commands/prompt.py +1040 -0
- solo_builder/commands/proof.py +791 -0
- solo_builder/commands/rcm.py +300 -0
- solo_builder/commands/registry.py +226 -0
- solo_builder/commands/reliance.py +385 -0
- solo_builder/commands/replay.py +103 -0
- solo_builder/commands/report.py +295 -0
- solo_builder/commands/resume.py +41 -0
- solo_builder/commands/serve.py +327 -0
- solo_builder/commands/ship.py +6361 -0
- solo_builder/commands/status.py +153 -0
- solo_builder/commands/strix_wire.py +1508 -0
- solo_builder/commands/sync.py +41 -0
- solo_builder/commands/ticket.py +60 -0
- solo_builder/commands/tool.py +162 -0
- solo_builder/commands/watch.py +52 -0
- solo_builder/compare.py +489 -0
- solo_builder/compliance_profiles.py +1138 -0
- solo_builder/compliance_schemas/ai_act_annex_iv_v1.schema.json +102 -0
- solo_builder/compliance_schemas/insurance_risk_v1.schema.json +91 -0
- solo_builder/compliance_schemas/iso_42001_clause_8_v1.schema.json +96 -0
- solo_builder/config.py +510 -0
- solo_builder/connected.py +135 -0
- solo_builder/credential_class_matrix.snapshot.json +24 -0
- solo_builder/csv_export.py +227 -0
- solo_builder/custody.py +545 -0
- solo_builder/daemon_proc.py +221 -0
- solo_builder/demo.py +240 -0
- solo_builder/deployers/__init__.py +67 -0
- solo_builder/deployers/_process.py +38 -0
- solo_builder/deployers/base.py +111 -0
- solo_builder/deployers/cloudflare.py +65 -0
- solo_builder/deployers/expo.py +43 -0
- solo_builder/deployers/manual.py +16 -0
- solo_builder/deployers/pages.py +173 -0
- solo_builder/deployers/railway.py +66 -0
- solo_builder/deployers/vercel.py +120 -0
- solo_builder/device_attestation.py +771 -0
- solo_builder/device_class_matrix.snapshot.json +16 -0
- solo_builder/distillation_boundary.py +397 -0
- solo_builder/distillation_copilot.py +393 -0
- solo_builder/distillation_demo.py +303 -0
- solo_builder/distillation_governance.py +611 -0
- solo_builder/entitlements.py +778 -0
- solo_builder/evals.py +655 -0
- solo_builder/events.py +190 -0
- solo_builder/evidence_store.py +338 -0
- solo_builder/evidence_stream.py +318 -0
- solo_builder/evolve.py +170 -0
- solo_builder/executor.py +670 -0
- solo_builder/executor_key_enrollment.py +430 -0
- solo_builder/external_verification_receipt.py +997 -0
- solo_builder/fleet.py +513 -0
- solo_builder/functional_gate.py +188 -0
- solo_builder/git_ops.py +212 -0
- solo_builder/github_client.py +154 -0
- solo_builder/governance_coverage.py +289 -0
- solo_builder/governance_trend.py +230 -0
- solo_builder/governed_email_revalidation.py +574 -0
- solo_builder/governed_outbound/__init__.py +32 -0
- solo_builder/governed_outbound/api.py +217 -0
- solo_builder/governed_outbound/models.py +225 -0
- solo_builder/governed_outbound/outbox_worker.py +136 -0
- solo_builder/governed_outbound/policy.py +52 -0
- solo_builder/governed_outbound/postgres_suppression.py +740 -0
- solo_builder/governed_outbound/release.py +373 -0
- solo_builder/governed_outbound/release_recheck.py +66 -0
- solo_builder/governed_outbound/repository.py +149 -0
- solo_builder/governed_outbound/service.py +299 -0
- solo_builder/governed_outbound/suppression.py +535 -0
- solo_builder/governed_outbound/webhook_sync.py +184 -0
- solo_builder/http.py +39 -0
- solo_builder/interactive_gate.py +55 -0
- solo_builder/jason_handoff.py +851 -0
- solo_builder/jason_handoff_execute.py +1174 -0
- solo_builder/jason_handoff_ingest.py +235 -0
- solo_builder/jason_plan.py +174 -0
- solo_builder/jwks.py +337 -0
- solo_builder/kernel_client.py +436 -0
- solo_builder/kernel_decision.py +90 -0
- solo_builder/linkedin_accounts.py +132 -0
- solo_builder/linkedin_authorization.py +397 -0
- solo_builder/linkedin_capabilities.py +669 -0
- solo_builder/linkedin_console.py +645 -0
- solo_builder/linkedin_content.py +505 -0
- solo_builder/linkedin_drafting.py +523 -0
- solo_builder/linkedin_email.py +331 -0
- solo_builder/linkedin_enrich.py +251 -0
- solo_builder/linkedin_evidence.py +231 -0
- solo_builder/linkedin_gate.py +189 -0
- solo_builder/linkedin_history.py +206 -0
- solo_builder/linkedin_ingest.py +533 -0
- solo_builder/linkedin_kernel_bridge.py +766 -0
- solo_builder/linkedin_mc1.py +1006 -0
- solo_builder/linkedin_mcp.py +126 -0
- solo_builder/linkedin_memory.py +138 -0
- solo_builder/linkedin_pass.py +922 -0
- solo_builder/linkedin_pipeline.py +254 -0
- solo_builder/linkedin_qualify.py +404 -0
- solo_builder/linkedin_queue.py +133 -0
- solo_builder/linkedin_scoring.py +141 -0
- solo_builder/linkedin_signals.py +465 -0
- solo_builder/llm.py +324 -0
- solo_builder/marketplace.py +334 -0
- solo_builder/mc_1.py +1184 -0
- solo_builder/mcp_adapter.py +484 -0
- solo_builder/mcp_auth.py +224 -0
- solo_builder/mcp_governance.py +544 -0
- solo_builder/mcp_http.py +312 -0
- solo_builder/mcp_proof.py +1369 -0
- solo_builder/memory.py +258 -0
- solo_builder/metrics.py +152 -0
- solo_builder/mission_control.py +1315 -0
- solo_builder/mission_control_svg.py +289 -0
- solo_builder/network_signatures.py +326 -0
- solo_builder/notion_client.py +67 -0
- solo_builder/objective_graph.py +623 -0
- solo_builder/outcome_swarm.py +170 -0
- solo_builder/output.py +73 -0
- solo_builder/parallel.py +278 -0
- solo_builder/pattern_catalog.py +1996 -0
- solo_builder/personas.py +229 -0
- solo_builder/plan.py +325 -0
- solo_builder/planner.py +141 -0
- solo_builder/policy_export.py +294 -0
- solo_builder/policy_lint.py +295 -0
- solo_builder/policy_replay.py +238 -0
- solo_builder/process_launch_census.py +859 -0
- solo_builder/production_authority.py +404 -0
- solo_builder/production_profile.py +162 -0
- solo_builder/prompts.py +1151 -0
- solo_builder/proof.py +198 -0
- solo_builder/proof_browser.py +104 -0
- solo_builder/proof_receipt.py +601 -0
- solo_builder/provenance.py +502 -0
- solo_builder/public_verify.py +312 -0
- solo_builder/py.typed +0 -0
- solo_builder/race.py +159 -0
- solo_builder/rcm_csv_adapter.py +1108 -0
- solo_builder/rcm_signed_evidence.py +370 -0
- solo_builder/rcm_solution_pack.py +345 -0
- solo_builder/registry.py +549 -0
- solo_builder/release_authority.py +528 -0
- solo_builder/release_evidence.py +659 -0
- solo_builder/release_evidence_log.py +269 -0
- solo_builder/release_identity.py +368 -0
- solo_builder/release_population.py +546 -0
- solo_builder/release_proof_bundle.py +1290 -0
- solo_builder/reliance.py +462 -0
- solo_builder/replay.py +418 -0
- solo_builder/right_to_contact.py +532 -0
- solo_builder/right_to_contact_api.py +206 -0
- solo_builder/roots/strix-root-v1.pem +3 -0
- solo_builder/sandbox_cgroup.py +316 -0
- solo_builder/sandbox_ns.py +474 -0
- solo_builder/sarif_policy.py +207 -0
- solo_builder/scaffold.py +38 -0
- solo_builder/session_audit_demo.py +839 -0
- solo_builder/ship_attestation.py +602 -0
- solo_builder/ship_attestation_emit.py +476 -0
- solo_builder/siem_export.py +274 -0
- solo_builder/slsa_export.py +237 -0
- solo_builder/slug.py +27 -0
- solo_builder/solo_execution_receipt.py +554 -0
- solo_builder/sonarqube_export.py +261 -0
- solo_builder/state.py +121 -0
- solo_builder/strix_demo.py +892 -0
- solo_builder/strix_domains.py +72 -0
- solo_builder/strix_personal/__init__.py +92 -0
- solo_builder/strix_personal/lifecycle.py +544 -0
- solo_builder/strix_personal/patcher.py +394 -0
- solo_builder/strix_personal/signals.py +571 -0
- solo_builder/strix_redemption.py +599 -0
- solo_builder/strix_wire_analyzer_common.py +97 -0
- solo_builder/strix_wire_broker.py +280 -0
- solo_builder/strix_wire_candidate.py +410 -0
- solo_builder/strix_wire_compile.py +495 -0
- solo_builder/strix_wire_dryrun.py +558 -0
- solo_builder/strix_wire_go_adapter.py +104 -0
- solo_builder/strix_wire_hosted.py +364 -0
- solo_builder/strix_wire_langdetect.py +402 -0
- solo_builder/strix_wire_local.py +629 -0
- solo_builder/strix_wire_local_attestation.py +501 -0
- solo_builder/strix_wire_local_chain.py +416 -0
- solo_builder/strix_wire_local_dag.py +539 -0
- solo_builder/strix_wire_local_key.py +291 -0
- solo_builder/strix_wire_local_receipt.py +1055 -0
- solo_builder/strix_wire_local_reliance.py +1464 -0
- solo_builder/strix_wire_outcome.py +346 -0
- solo_builder/strix_wire_patch.py +272 -0
- solo_builder/strix_wire_rust_adapter.py +115 -0
- solo_builder/suppression_quality.py +139 -0
- solo_builder/swarm.py +315 -0
- solo_builder/sync.py +123 -0
- solo_builder/three_way_identity.py +223 -0
- solo_builder/ticket.py +295 -0
- solo_builder/ticket_integrity.py +354 -0
- solo_builder/tool_policy.py +302 -0
- solo_builder/tools.py +747 -0
- solo_builder/transaction_proof.py +1234 -0
- solo_builder/trust.py +266 -0
- solo_builder/trust_mark.py +974 -0
- solo_builder/upstream_requester_provenance.py +596 -0
- solo_builder/verification_ladder.py +436 -0
- solo_builder/watch.py +156 -0
- solo_builder/web.py +275 -0
- solo_builder/webhook.py +478 -0
- solo_builder/worklog.py +122 -0
- solo_builder/wrap_patterns.py +231 -0
- solo_builder_core-0.4.0.dist-info/METADATA +297 -0
- solo_builder_core-0.4.0.dist-info/RECORD +285 -0
- solo_builder_core-0.4.0.dist-info/WHEEL +5 -0
- solo_builder_core-0.4.0.dist-info/entry_points.txt +2 -0
- solo_builder_core-0.4.0.dist-info/licenses/LICENSE +21 -0
- solo_builder_core-0.4.0.dist-info/licenses/NOTICE.md +39 -0
- solo_builder_core-0.4.0.dist-info/top_level.txt +1 -0
solo_builder/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared canonical-byte primitives — the single source of truth.
|
|
2
|
+
|
|
3
|
+
Every hashed object in the Strix stack — ``record_hash`` (ADR-006),
|
|
4
|
+
``signed_envelope`` payload hashes (ADR-005), Kafka wire bytes
|
|
5
|
+
(ADR-007), CSV-export metadata (ADR-008), ``plan_hash`` (ADR-012),
|
|
6
|
+
webhook signature payloads (ADR-013), AA-1 attestation payload bytes
|
|
7
|
+
(ADR-015) — reaches through this module. One canonicalization
|
|
8
|
+
contract; zero parallel serialization shapes.
|
|
9
|
+
|
|
10
|
+
The byte contract is locked by ADR-005 §4:
|
|
11
|
+
|
|
12
|
+
- JSON serialization.
|
|
13
|
+
- Object keys sorted lexicographically.
|
|
14
|
+
- No whitespace (separators ``,`` and ``:``).
|
|
15
|
+
- ``ensure_ascii=False`` so Unicode survives byte-for-byte.
|
|
16
|
+
- ``allow_nan=False`` — NaN and Infinity are forbidden because they
|
|
17
|
+
break determinism across implementations.
|
|
18
|
+
|
|
19
|
+
Implementations in other languages (TypeScript ``@strixgov/sdk``)
|
|
20
|
+
MUST produce byte-identical output for the same logical input.
|
|
21
|
+
Cross-SDK byte-equivalence is asserted in every module's test suite
|
|
22
|
+
that touches hashing, so divergence is caught immediately if it ever
|
|
23
|
+
re-introduces itself.
|
|
24
|
+
|
|
25
|
+
For module-level naming compatibility, ``evidence_store`` re-exports
|
|
26
|
+
``hash_canonical`` as ``hash_payload`` so callers that import
|
|
27
|
+
``from .evidence_store import hash_payload`` continue to work
|
|
28
|
+
unchanged.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def canonicalize(obj: Any) -> bytes:
|
|
39
|
+
"""Deterministic JSON bytes used for hashing and signing.
|
|
40
|
+
|
|
41
|
+
The single byte-contract chokepoint for the entire stack.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
return json.dumps(
|
|
45
|
+
obj,
|
|
46
|
+
sort_keys=True,
|
|
47
|
+
separators=(",", ":"),
|
|
48
|
+
ensure_ascii=False,
|
|
49
|
+
allow_nan=False,
|
|
50
|
+
).encode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hash_canonical(obj: Any) -> str:
|
|
54
|
+
"""Lowercase SHA-256 hex digest of :func:`canonicalize` output."""
|
|
55
|
+
|
|
56
|
+
return hashlib.sha256(canonicalize(obj)).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["canonicalize", "hash_canonical"]
|
solo_builder/_signing.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared Ed25519 attestation-signing policy for the local proof artifacts.
|
|
2
|
+
|
|
3
|
+
The provenance chain, proof bundle, replay pack, and LinkedIn decision chain
|
|
4
|
+
all sign with the same ``SOLO_ATTESTATION_PRIVATE_KEY``. They historically each
|
|
5
|
+
inlined the same ``except Exception: return None`` block, which had one correct
|
|
6
|
+
case and one latent gap:
|
|
7
|
+
|
|
8
|
+
* **No key configured** → return ``None`` (best-effort unsigned). Correct: the
|
|
9
|
+
hash chain still verifies; the artifact is simply not authorship-bound.
|
|
10
|
+
|
|
11
|
+
* **Key configured but signing/loading FAILS** (bad hex, wrong length, broken
|
|
12
|
+
crypto backend) → the old code *also* returned ``None``, silently producing
|
|
13
|
+
an unsigned artifact indistinguishable from "intentionally unsigned". A
|
|
14
|
+
downstream consumer could mistake a misconfigured-key run for a deliberate
|
|
15
|
+
best-effort one. That is the gap this module closes.
|
|
16
|
+
|
|
17
|
+
Policy (single source of truth):
|
|
18
|
+
|
|
19
|
+
============================ ================= ========================
|
|
20
|
+
key state strict mode off strict mode on
|
|
21
|
+
============================ ================= ========================
|
|
22
|
+
absent return None raise SigningRequiredError
|
|
23
|
+
present but invalid/broken raise SigningError raise SigningError
|
|
24
|
+
present and valid return signature return signature
|
|
25
|
+
============================ ================= ========================
|
|
26
|
+
|
|
27
|
+
Strict mode is opt-in via ``SOLO_REQUIRE_SIGNING`` ∈ {1,true,yes,on}. Trust
|
|
28
|
+
surfaces that must never emit an unsigned artifact set it; local development
|
|
29
|
+
leaves it unset and keeps the best-effort behavior.
|
|
30
|
+
|
|
31
|
+
A configured-but-broken key ALWAYS raises, in both modes — a broken key is a
|
|
32
|
+
fault, never a silent downgrade.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
_ENV_KEY = "SOLO_ATTESTATION_PRIVATE_KEY"
|
|
40
|
+
_ENV_REQUIRE = "SOLO_REQUIRE_SIGNING"
|
|
41
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SigningError(RuntimeError):
|
|
45
|
+
"""A signing key was configured but signing could not be performed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SigningRequiredError(SigningError):
|
|
49
|
+
"""Strict mode is on but no signing key is configured."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def signing_required() -> bool:
|
|
53
|
+
"""True when ``SOLO_REQUIRE_SIGNING`` opts into fail-closed signing."""
|
|
54
|
+
return os.environ.get(_ENV_REQUIRE, "").strip().lower() in _TRUTHY
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sign_attestation(payload: bytes, *, require: bool | None = None) -> str | None:
|
|
58
|
+
"""Sign ``payload`` (raw bytes) with the configured Ed25519 key.
|
|
59
|
+
|
|
60
|
+
Returns the hex signature, or ``None`` when no key is configured and strict
|
|
61
|
+
mode is off. Raises :class:`SigningRequiredError` when no key is configured
|
|
62
|
+
and strict mode is on, and :class:`SigningError` when a key IS configured
|
|
63
|
+
but loading or signing fails. Callers pass pre-encoded bytes so the exact
|
|
64
|
+
signed-byte contract (e.g. ``head_hash.encode("ascii")``) stays unchanged.
|
|
65
|
+
"""
|
|
66
|
+
strict = signing_required() if require is None else require
|
|
67
|
+
raw = os.environ.get(_ENV_KEY)
|
|
68
|
+
|
|
69
|
+
if not raw:
|
|
70
|
+
if strict:
|
|
71
|
+
raise SigningRequiredError(f"{_ENV_KEY} is not set but {_ENV_REQUIRE} requires a signed artifact")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# A key is configured. From here, any failure is a real fault — never a
|
|
75
|
+
# silent unsigned artifact.
|
|
76
|
+
try:
|
|
77
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
78
|
+
Ed25519PrivateKey,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(raw))
|
|
82
|
+
except Exception as exc: # noqa: BLE001 — surface the misconfiguration loudly
|
|
83
|
+
raise SigningError(f"{_ENV_KEY} is set but is not a valid Ed25519 private key (hex): {exc}") from exc
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return key.sign(payload).hex()
|
|
87
|
+
except Exception as exc: # noqa: BLE001 — a configured key that cannot sign is a fault
|
|
88
|
+
raise SigningError(f"attestation signing failed: {exc}") from exc
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Report-only acceptance-criterion results for an autonomous ship run.
|
|
2
|
+
|
|
3
|
+
A ticket carries 3-7 acceptance tests (``ticket.py``). Until this module,
|
|
4
|
+
nothing in the agent pipeline assessed them: ``review_code`` never saw them,
|
|
5
|
+
and the execution loop runs the repo's own tests, which answer "does the
|
|
6
|
+
generated code run?" and not "was the ticket delivered?". Those are different
|
|
7
|
+
questions, and a run could finish green having answered only the first.
|
|
8
|
+
|
|
9
|
+
This module reports, per criterion, what the review stage ASSERTED — and is
|
|
10
|
+
deliberately incapable of being more than that:
|
|
11
|
+
|
|
12
|
+
* **Report-only.** There is no ``ok`` / ``passed`` / ``all_met`` on
|
|
13
|
+
``AcceptanceReport``, on purpose: a single green bit is exactly the collapse
|
|
14
|
+
this codebase's ProofState and verification ladder exist to prevent. The
|
|
15
|
+
report never feeds ``AgentResult.ok``, the trust engine, budget, deploy, or
|
|
16
|
+
any exit code; ``tests/test_acceptance_report.py`` pins that.
|
|
17
|
+
* **Model assertion is not evidence.** Every asserted verdict carries
|
|
18
|
+
``evidence_class="MODEL_ASSERTION"`` and an empty ``deterministic_evidence``.
|
|
19
|
+
``MET`` here means "the reviewing model said so about code it was shown",
|
|
20
|
+
never "verified". A verdict may only be promoted by tying the criterion to
|
|
21
|
+
deterministic evidence (a named test's recorded result) or to a named human
|
|
22
|
+
decision (``human_decision``); neither exists yet, so both fields are carried
|
|
23
|
+
empty and named rather than omitted. Promotion is NOT built, and the claim
|
|
24
|
+
ceiling is structural: ``claim_ceiling == "MODEL_ASSERTION_ONLY"``.
|
|
25
|
+
* **Absence is recorded, never fabricated.** A criterion the model did not
|
|
26
|
+
address, or addressed in a form this parser does not recognize, is
|
|
27
|
+
``UNASSESSED`` — not ``UNCLEAR`` (the model said it could not tell) and never
|
|
28
|
+
a quiet pass. A run with no acceptance criteria, or no review output,
|
|
29
|
+
produces no report at all (``None``) rather than an empty green one.
|
|
30
|
+
* **Never raises.** Parsing model prose is a best-effort read; a malformed
|
|
31
|
+
review degrades to ``UNASSESSED`` rows. It must not break a ship.
|
|
32
|
+
|
|
33
|
+
Gate H: ``AcceptanceReport`` is type-disjoint from ``EvidenceRecord`` (no
|
|
34
|
+
``record_hash`` / ``chain_seq`` / ``prev_hash`` / ``signature``); it is an
|
|
35
|
+
unsigned measurement, never proof.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import dataclasses
|
|
41
|
+
import re
|
|
42
|
+
from collections.abc import Mapping, Sequence
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from ._canonical import hash_canonical
|
|
47
|
+
|
|
48
|
+
SCHEMA = "acceptance_report_v1"
|
|
49
|
+
SOURCE_STAGE = "review_code"
|
|
50
|
+
GATING = "REPORT_ONLY"
|
|
51
|
+
CLAIM_CEILING = "MODEL_ASSERTION_ONLY"
|
|
52
|
+
|
|
53
|
+
ASSERTED_MET = "ASSERTED_MET"
|
|
54
|
+
ASSERTED_NOT_MET = "ASSERTED_NOT_MET"
|
|
55
|
+
ASSERTED_UNCLEAR = "ASSERTED_UNCLEAR"
|
|
56
|
+
UNASSESSED = "UNASSESSED"
|
|
57
|
+
#: A criterion BOUND to a named test whose recorded result decided it.
|
|
58
|
+
EVIDENCE_PASSED = "EVIDENCE_PASSED"
|
|
59
|
+
EVIDENCE_FAILED = "EVIDENCE_FAILED"
|
|
60
|
+
#: A named person decided it, with a reason, on the record.
|
|
61
|
+
HUMAN_MET = "HUMAN_MET"
|
|
62
|
+
HUMAN_NOT_MET = "HUMAN_NOT_MET"
|
|
63
|
+
|
|
64
|
+
STATES: tuple[str, ...] = (
|
|
65
|
+
ASSERTED_MET,
|
|
66
|
+
ASSERTED_NOT_MET,
|
|
67
|
+
ASSERTED_UNCLEAR,
|
|
68
|
+
UNASSESSED,
|
|
69
|
+
EVIDENCE_PASSED,
|
|
70
|
+
EVIDENCE_FAILED,
|
|
71
|
+
HUMAN_MET,
|
|
72
|
+
HUMAN_NOT_MET,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
EVIDENCE_MODEL_ASSERTION = "MODEL_ASSERTION"
|
|
76
|
+
EVIDENCE_NONE = "NONE"
|
|
77
|
+
EVIDENCE_DETERMINISTIC_TEST = "DETERMINISTIC_TEST"
|
|
78
|
+
EVIDENCE_HUMAN_DECISION = "HUMAN_DECISION"
|
|
79
|
+
|
|
80
|
+
#: Strict order of evidence strength. A stronger class REPLACES a weaker verdict
|
|
81
|
+
#: on the same criterion; a weaker one never overwrites a stronger one.
|
|
82
|
+
EVIDENCE_RANK: dict[str, int] = {
|
|
83
|
+
EVIDENCE_NONE: 0,
|
|
84
|
+
EVIDENCE_MODEL_ASSERTION: 1,
|
|
85
|
+
EVIDENCE_DETERMINISTIC_TEST: 2,
|
|
86
|
+
EVIDENCE_HUMAN_DECISION: 3,
|
|
87
|
+
}
|
|
88
|
+
#: The report-level claim ceiling is the STRONGEST evidence class any row
|
|
89
|
+
#: carries — it names what the report can at most be read as, never more.
|
|
90
|
+
CLAIM_CEILING_BY_EVIDENCE: dict[str, str] = {
|
|
91
|
+
EVIDENCE_NONE: "MODEL_ASSERTION_ONLY",
|
|
92
|
+
EVIDENCE_MODEL_ASSERTION: "MODEL_ASSERTION_ONLY",
|
|
93
|
+
EVIDENCE_DETERMINISTIC_TEST: "DETERMINISTIC_TEST",
|
|
94
|
+
EVIDENCE_HUMAN_DECISION: "HUMAN_DECISION",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#: ``[test: tests/test_api.py::test_returns_200]`` or ``[test: test_returns_200]``
|
|
98
|
+
#: inside a criterion's text binds it to a named test. Several bindings are
|
|
99
|
+
#: allowed; ALL must pass for EVIDENCE_PASSED, ANY failure is EVIDENCE_FAILED.
|
|
100
|
+
_BINDING = re.compile(r"\[\s*tests?\s*:\s*([^\]]+?)\s*\]", re.IGNORECASE)
|
|
101
|
+
#: pytest ``-rA`` short-summary lines: ``PASSED tests/test_x.py::test_a``.
|
|
102
|
+
_PYTEST_RESULT = re.compile(r"^(PASSED|FAILED|ERROR|XPASS|XFAIL|SKIPPED)\s+(\S+?)(?:\s+-\s.*)?$")
|
|
103
|
+
|
|
104
|
+
DISCLAIMER = (
|
|
105
|
+
"Unsigned measurement, never proof. Verdicts are the reviewing model's opinion about code it was "
|
|
106
|
+
"shown; none is backed by deterministic evidence or a human decision, and none affects whether the "
|
|
107
|
+
"run succeeded."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
#: Max characters of the model's stated basis kept per criterion. The report is
|
|
111
|
+
#: surfaced in terminals, audit lines and JSON; an unbounded model paragraph
|
|
112
|
+
#: does not belong in any of them.
|
|
113
|
+
_BASIS_MAX = 300
|
|
114
|
+
|
|
115
|
+
_HEADING = re.compile(r"^\W*acceptance\s+criteri(?:a|on)\s+assessment\b", re.IGNORECASE)
|
|
116
|
+
#: ``1. MET — basis`` / ``2) NOT_MET: basis`` / ``- 3. UNCLEAR - basis``,
|
|
117
|
+
#: tolerating markdown emphasis around the verdict word.
|
|
118
|
+
_ROW = re.compile(
|
|
119
|
+
r"^\s*(?:[-*]\s*)?(?P<n>\d{1,3})\s*[.)]\s*[*_`]*\s*"
|
|
120
|
+
r"(?P<verdict>NOT[\s_-]*MET|UNCLEAR|MET)\b[*_`]*\s*[—–:\-]*\s*(?P<basis>.*)$",
|
|
121
|
+
re.IGNORECASE,
|
|
122
|
+
)
|
|
123
|
+
_NEXT_HEADING = re.compile(r"^\s*#{1,6}\s+\S")
|
|
124
|
+
|
|
125
|
+
_VERDICT_STATE = {"MET": ASSERTED_MET, "NOT_MET": ASSERTED_NOT_MET, "UNCLEAR": ASSERTED_UNCLEAR}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class CriterionResult:
|
|
130
|
+
index: int
|
|
131
|
+
text: str
|
|
132
|
+
state: str
|
|
133
|
+
basis: str = ""
|
|
134
|
+
evidence_class: str = EVIDENCE_NONE
|
|
135
|
+
#: Deterministic evidence ties (e.g. a named test's recorded result). Always
|
|
136
|
+
#: empty in v1 — carried so the absence is a stated fact, not an omission.
|
|
137
|
+
deterministic_evidence: tuple[str, ...] = ()
|
|
138
|
+
#: A named human decision on this criterion. None until one is recorded.
|
|
139
|
+
human_decision: str | None = None
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"index": self.index,
|
|
144
|
+
"text": self.text,
|
|
145
|
+
"state": self.state,
|
|
146
|
+
"basis": self.basis,
|
|
147
|
+
"evidence_class": self.evidence_class,
|
|
148
|
+
"deterministic_evidence": list(self.deterministic_evidence),
|
|
149
|
+
"human_decision": self.human_decision,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class AcceptanceReport:
|
|
155
|
+
criteria: tuple[CriterionResult, ...] = field(default_factory=tuple)
|
|
156
|
+
source_stage: str = SOURCE_STAGE
|
|
157
|
+
gating: str = GATING
|
|
158
|
+
schema: str = SCHEMA
|
|
159
|
+
disclaimer: str = DISCLAIMER
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def claim_ceiling(self) -> str:
|
|
163
|
+
"""Derived from the rows, never stored: the strongest evidence present."""
|
|
164
|
+
strongest = max(
|
|
165
|
+
(c.evidence_class for c in self.criteria),
|
|
166
|
+
key=lambda e: EVIDENCE_RANK.get(e, 0),
|
|
167
|
+
default=EVIDENCE_NONE,
|
|
168
|
+
)
|
|
169
|
+
return CLAIM_CEILING_BY_EVIDENCE.get(strongest, CLAIM_CEILING)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def counts(self) -> dict[str, int]:
|
|
173
|
+
"""Per-state tallies. Deliberately NOT collapsed into one verdict."""
|
|
174
|
+
tally = dict.fromkeys(STATES, 0)
|
|
175
|
+
for c in self.criteria:
|
|
176
|
+
tally[c.state] += 1
|
|
177
|
+
return tally
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> dict[str, Any]:
|
|
180
|
+
return {
|
|
181
|
+
"schema": self.schema,
|
|
182
|
+
"gating": self.gating,
|
|
183
|
+
"claim_ceiling": self.claim_ceiling,
|
|
184
|
+
"source_stage": self.source_stage,
|
|
185
|
+
"counts": self.counts,
|
|
186
|
+
"criteria": [c.to_dict() for c in self.criteria],
|
|
187
|
+
"disclaimer": self.disclaimer,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def format_text(self) -> str:
|
|
191
|
+
icons = {
|
|
192
|
+
ASSERTED_MET: "◻︎✓",
|
|
193
|
+
ASSERTED_NOT_MET: "◻︎✗",
|
|
194
|
+
ASSERTED_UNCLEAR: "◻︎?",
|
|
195
|
+
UNASSESSED: "◻︎–",
|
|
196
|
+
EVIDENCE_PASSED: "■✓",
|
|
197
|
+
EVIDENCE_FAILED: "■✗",
|
|
198
|
+
HUMAN_MET: "👤✓",
|
|
199
|
+
HUMAN_NOT_MET: "👤✗",
|
|
200
|
+
}
|
|
201
|
+
lines = [
|
|
202
|
+
"Acceptance criteria (report only; does not affect the run) — "
|
|
203
|
+
"◻︎ model-asserted, NOT verified · ■ bound to a recorded test result · 👤 named human decision:"
|
|
204
|
+
]
|
|
205
|
+
for c in self.criteria:
|
|
206
|
+
tail = f" — {c.basis}" if c.basis else ""
|
|
207
|
+
if c.deterministic_evidence:
|
|
208
|
+
tail += f" [{', '.join(c.deterministic_evidence)}]"
|
|
209
|
+
if c.human_decision:
|
|
210
|
+
tail += f" [{c.human_decision}]"
|
|
211
|
+
lines.append(f" {icons[c.state]} {c.index}. {c.state}: {c.text}{tail}")
|
|
212
|
+
n = self.counts
|
|
213
|
+
lines.append(
|
|
214
|
+
f" asserted met {n[ASSERTED_MET]} · asserted not met {n[ASSERTED_NOT_MET]} · "
|
|
215
|
+
f"unclear {n[ASSERTED_UNCLEAR]} · unassessed {n[UNASSESSED]} · "
|
|
216
|
+
f"evidence passed {n[EVIDENCE_PASSED]} · evidence failed {n[EVIDENCE_FAILED]} · "
|
|
217
|
+
f"human met {n[HUMAN_MET]} · human not met {n[HUMAN_NOT_MET]}"
|
|
218
|
+
)
|
|
219
|
+
lines.append(f" claim ceiling: {self.claim_ceiling}")
|
|
220
|
+
return "\n".join(lines)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _clean_basis(raw: str) -> str:
|
|
224
|
+
text = re.sub(r"[*_`]+", "", raw).strip(" \t-—–:")
|
|
225
|
+
text = re.sub(r"\s+", " ", text)
|
|
226
|
+
return text[: _BASIS_MAX - 1] + "…" if len(text) > _BASIS_MAX else text
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_review_assessment(review_text: str, count: int) -> dict[int, tuple[str, str]]:
|
|
230
|
+
"""Read ``{criterion index: (state, basis)}`` from a review's assessment section.
|
|
231
|
+
|
|
232
|
+
Only rows inside the "Acceptance Criteria Assessment" section count, indices
|
|
233
|
+
must be within ``1..count`` (anything else is ignored, never remapped), and
|
|
234
|
+
the FIRST row for an index wins — a later contradictory row must not
|
|
235
|
+
silently overwrite an earlier verdict. Unrecognized shapes yield nothing.
|
|
236
|
+
"""
|
|
237
|
+
found: dict[int, tuple[str, str]] = {}
|
|
238
|
+
in_section = False
|
|
239
|
+
for line in review_text.splitlines():
|
|
240
|
+
if not in_section:
|
|
241
|
+
if _HEADING.match(line.strip().lstrip("#").strip()):
|
|
242
|
+
in_section = True
|
|
243
|
+
continue
|
|
244
|
+
if _NEXT_HEADING.match(line) and not _HEADING.match(line.strip().lstrip("#").strip()):
|
|
245
|
+
break
|
|
246
|
+
m = _ROW.match(line)
|
|
247
|
+
if not m:
|
|
248
|
+
continue
|
|
249
|
+
index = int(m.group("n"))
|
|
250
|
+
if not 1 <= index <= count or index in found:
|
|
251
|
+
continue
|
|
252
|
+
verdict = re.sub(r"[\s-]+", "_", m.group("verdict").upper())
|
|
253
|
+
state = _VERDICT_STATE.get(verdict)
|
|
254
|
+
if state is None:
|
|
255
|
+
continue
|
|
256
|
+
found[index] = (state, _clean_basis(m.group("basis")))
|
|
257
|
+
return found
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_acceptance_report(criteria: Sequence[str], review_text: str | None) -> AcceptanceReport | None:
|
|
261
|
+
"""The report for a run, or None when there is nothing honest to report.
|
|
262
|
+
|
|
263
|
+
None when the ticket has no criteria or the review stage produced no
|
|
264
|
+
output: an empty report would read as "nothing failed".
|
|
265
|
+
"""
|
|
266
|
+
texts = [c.strip() for c in criteria if c and c.strip()]
|
|
267
|
+
if not texts or not review_text or not review_text.strip():
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
found = parse_review_assessment(review_text, len(texts))
|
|
271
|
+
except Exception: # noqa: BLE001 — a parse defect must degrade to UNASSESSED, never break a ship
|
|
272
|
+
found = {}
|
|
273
|
+
rows: list[CriterionResult] = []
|
|
274
|
+
for i, text in enumerate(texts, start=1):
|
|
275
|
+
if i in found:
|
|
276
|
+
state, basis = found[i]
|
|
277
|
+
rows.append(
|
|
278
|
+
CriterionResult(
|
|
279
|
+
index=i, text=text, state=state, basis=basis, evidence_class=EVIDENCE_MODEL_ASSERTION
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
rows.append(CriterionResult(index=i, text=text, state=UNASSESSED))
|
|
284
|
+
return AcceptanceReport(criteria=tuple(rows))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Promotion beyond a model assertion: deterministic test evidence, human decision
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def bound_tests(criterion_text: str) -> tuple[str, ...]:
|
|
293
|
+
"""The test ids a criterion names with ``[test: ...]``, in order, de-duplicated."""
|
|
294
|
+
return tuple(dict.fromkeys(m.strip() for m in _BINDING.findall(criterion_text) if m.strip()))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_pytest_results(stdout: str) -> dict[str, str]:
|
|
298
|
+
"""``{nodeid: PASSED|FAILED|...}`` from pytest ``-rA`` short-summary lines.
|
|
299
|
+
|
|
300
|
+
Only lines in that exact shape count; a FAILED anywhere else in the output
|
|
301
|
+
(a traceback header, a docstring) is not a result. The LAST line for a
|
|
302
|
+
nodeid wins, since a re-run inside one capture reports the final state.
|
|
303
|
+
"""
|
|
304
|
+
results: dict[str, str] = {}
|
|
305
|
+
for line in stdout.splitlines():
|
|
306
|
+
m = _PYTEST_RESULT.match(line.strip())
|
|
307
|
+
if m:
|
|
308
|
+
results[m.group(2)] = m.group(1)
|
|
309
|
+
return results
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _match_binding(binding: str, results: Mapping[str, str]) -> list[str]:
|
|
313
|
+
"""Nodeids a binding names: exact, ``::``-suffix, or bare function name."""
|
|
314
|
+
exact = [n for n in results if n == binding]
|
|
315
|
+
if exact:
|
|
316
|
+
return exact
|
|
317
|
+
suffix = [n for n in results if n.endswith("::" + binding) or n.endswith("/" + binding)]
|
|
318
|
+
if suffix:
|
|
319
|
+
return suffix
|
|
320
|
+
return [n for n in results if n.split("::")[-1] == binding]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def bind_deterministic_evidence(report: AcceptanceReport, results: Mapping[str, str]) -> AcceptanceReport:
|
|
324
|
+
"""Replace model assertions with the recorded result of each bound test.
|
|
325
|
+
|
|
326
|
+
A criterion with no binding is untouched. A binding that names no test in
|
|
327
|
+
``results`` is recorded in the basis as unresolved and the row keeps its
|
|
328
|
+
weaker verdict — a missing test is never read as a pass. A human decision
|
|
329
|
+
already on the row outranks test evidence and is left alone.
|
|
330
|
+
"""
|
|
331
|
+
rows: list[CriterionResult] = []
|
|
332
|
+
for c in report.criteria:
|
|
333
|
+
bindings = bound_tests(c.text)
|
|
334
|
+
if not bindings or c.evidence_class == EVIDENCE_HUMAN_DECISION:
|
|
335
|
+
rows.append(c)
|
|
336
|
+
continue
|
|
337
|
+
evidence: list[str] = []
|
|
338
|
+
unresolved: list[str] = []
|
|
339
|
+
statuses: list[str] = []
|
|
340
|
+
for b in bindings:
|
|
341
|
+
matched = _match_binding(b, results)
|
|
342
|
+
if not matched:
|
|
343
|
+
unresolved.append(b)
|
|
344
|
+
continue
|
|
345
|
+
for nodeid in matched:
|
|
346
|
+
statuses.append(results[nodeid])
|
|
347
|
+
evidence.append(f"pytest:{nodeid}:{results[nodeid]}")
|
|
348
|
+
if not evidence:
|
|
349
|
+
rows.append(
|
|
350
|
+
dataclasses.replace(
|
|
351
|
+
c,
|
|
352
|
+
basis=(c.basis + " · " if c.basis else "")
|
|
353
|
+
+ f"bound test(s) not found in results: {', '.join(unresolved)}",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
failed = any(st in ("FAILED", "ERROR") for st in statuses)
|
|
358
|
+
passed = all(st in ("PASSED", "XPASS") for st in statuses)
|
|
359
|
+
if failed:
|
|
360
|
+
state = EVIDENCE_FAILED
|
|
361
|
+
elif passed and not unresolved:
|
|
362
|
+
state = EVIDENCE_PASSED
|
|
363
|
+
else:
|
|
364
|
+
# Skipped/xfail or a partly-unresolved binding set: evidence exists but
|
|
365
|
+
# does not establish the criterion either way. Keep the weaker verdict.
|
|
366
|
+
rows.append(
|
|
367
|
+
dataclasses.replace(
|
|
368
|
+
c,
|
|
369
|
+
basis=(c.basis + " · " if c.basis else "")
|
|
370
|
+
+ "bound tests inconclusive: "
|
|
371
|
+
+ ", ".join(evidence + [f"{u}:NOT_FOUND" for u in unresolved]),
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
rows.append(
|
|
376
|
+
dataclasses.replace(
|
|
377
|
+
c,
|
|
378
|
+
state=state,
|
|
379
|
+
evidence_class=EVIDENCE_DETERMINISTIC_TEST,
|
|
380
|
+
deterministic_evidence=tuple(evidence),
|
|
381
|
+
basis=(c.basis + " · " if c.basis else "") + "decided by recorded test result",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return dataclasses.replace(report, criteria=tuple(rows))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def apply_human_decision(
|
|
388
|
+
report: AcceptanceReport,
|
|
389
|
+
index: int,
|
|
390
|
+
*,
|
|
391
|
+
met: bool,
|
|
392
|
+
decided_by: str,
|
|
393
|
+
reason: str,
|
|
394
|
+
decided_at: str,
|
|
395
|
+
) -> AcceptanceReport:
|
|
396
|
+
"""Record a NAMED person's decision on one criterion. Outranks every other
|
|
397
|
+
evidence class; refuses an anonymous or unexplained decision outright."""
|
|
398
|
+
if not decided_by.strip() or not reason.strip():
|
|
399
|
+
raise ValueError("a human decision needs both a named decider and a reason")
|
|
400
|
+
if not any(c.index == index for c in report.criteria):
|
|
401
|
+
raise ValueError(f"no criterion {index}; the report has {len(report.criteria)}")
|
|
402
|
+
rows = [
|
|
403
|
+
dataclasses.replace(
|
|
404
|
+
c,
|
|
405
|
+
state=HUMAN_MET if met else HUMAN_NOT_MET,
|
|
406
|
+
evidence_class=EVIDENCE_HUMAN_DECISION,
|
|
407
|
+
human_decision=f"{decided_by.strip()} @ {decided_at}: {reason.strip()}",
|
|
408
|
+
)
|
|
409
|
+
if c.index == index
|
|
410
|
+
else c
|
|
411
|
+
for c in report.criteria
|
|
412
|
+
]
|
|
413
|
+
return dataclasses.replace(report, criteria=tuple(rows))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def report_digest(report: AcceptanceReport) -> str:
|
|
417
|
+
"""Content address of the report: sha256 over its canonical document.
|
|
418
|
+
|
|
419
|
+
Computed from ``to_dict()``, i.e. from the ROWS — ``claim_ceiling`` and
|
|
420
|
+
``counts`` in the document are derived, so a planted ceiling in a stored
|
|
421
|
+
file cannot change the digest of the report it was loaded into. Used by
|
|
422
|
+
``ship_attestation`` to bind WHICH report accompanied a ship; it says
|
|
423
|
+
nothing about whether the report is right."""
|
|
424
|
+
|
|
425
|
+
return hash_canonical(report.to_dict())
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def report_from_dict(data: Mapping[str, Any]) -> AcceptanceReport:
|
|
429
|
+
"""Rebuild a report from ``to_dict()`` output; a stored ``claim_ceiling`` is
|
|
430
|
+
ignored and re-derived from the rows (a planted ceiling is never trusted)."""
|
|
431
|
+
if data.get("schema") != SCHEMA:
|
|
432
|
+
raise ValueError(f"not an {SCHEMA} document")
|
|
433
|
+
rows = tuple(
|
|
434
|
+
CriterionResult(
|
|
435
|
+
index=int(c["index"]),
|
|
436
|
+
text=str(c["text"]),
|
|
437
|
+
state=str(c["state"]) if c.get("state") in STATES else UNASSESSED,
|
|
438
|
+
basis=str(c.get("basis", "")),
|
|
439
|
+
evidence_class=str(c.get("evidence_class", EVIDENCE_NONE)),
|
|
440
|
+
deterministic_evidence=tuple(str(x) for x in c.get("deterministic_evidence", ())),
|
|
441
|
+
human_decision=(str(c["human_decision"]) if c.get("human_decision") else None),
|
|
442
|
+
)
|
|
443
|
+
for c in data.get("criteria", [])
|
|
444
|
+
)
|
|
445
|
+
return AcceptanceReport(criteria=rows, source_stage=str(data.get("source_stage", SOURCE_STAGE)))
|