From e9e566d3a6e46a88daaedba95fdb9326c76cd2d9 Mon Sep 17 00:00:00 2001 From: nutstrut <204641859+nutstrut@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:41:55 +0000 Subject: [PATCH] feat(mesh): add SAR-402 execution/service-delivery verification agent Adds a deterministic Mesh agent that evaluates structured execution/ service-delivery evidence (optionally alongside x402 settlement context, treated as unverified supporting context) and returns a PASS / FAIL / INDETERMINATE receipt with reason codes and a recomputable digest-based integrity value. The agent never holds funds, custody, or execution/ release authority, and never claims payment settlement finality or a cryptographic signature. - mesh/agents/sar402_verification_agent.py: the Mesh agent - mesh/agents/sar402_verify_tool.py: the verify_settlement_evidence tool - mesh/tests/test_sar402_verification_agent.py: 11-case unittest smoke test (discovery, instantiation, deterministic PASS/FAIL/INDETERMINATE, fail-closed on malformed/missing input, unsupported-tool bounding, mocked query dispatch) - mesh/tests/sar402_fixtures/*.json: 5 fixtures used by the smoke test - sar402_verify_tmp/: small, self-contained, vendored copy of the SAR-402 reference implementation's evidence-normalization, receipt- construction, and schema/authority-validation logic, so the agent and its tool have zero dependency on any external repository at runtime 11 passed, 0 failed in this checkout (Python 3.10.12, pytest 9.1.0). --- mesh/agents/sar402_verification_agent.py | 89 +++++ mesh/agents/sar402_verify_tool.py | 180 ++++++++++ .../fail_constraint_drift.json | 40 +++ .../fail_evidence_mismatch.json | 40 +++ .../indeterminate_unknown_authority.json | 39 +++ .../malformed_missing_timestamp.json | 29 ++ .../sar402_fixtures/pass_record_mode.json | 43 +++ mesh/tests/test_sar402_verification_agent.py | 148 ++++++++ sar402_verify_tmp/EXCLUDED.md | 34 ++ sar402_verify_tmp/README.md | 29 ++ sar402_verify_tmp/sar402/__init__.py | 94 +++++ sar402_verify_tmp/sar402/builder.py | 325 ++++++++++++++++++ sar402_verify_tmp/sar402/constants.py | 131 +++++++ sar402_verify_tmp/sar402/models.py | 172 +++++++++ sar402_verify_tmp/sar402/predicates.py | 167 +++++++++ sar402_verify_tmp/sar402/schema.py | 243 +++++++++++++ .../sar-402-settlement-v0.1.schema.json | 318 +++++++++++++++++ sar402_verify_tmp/sar402/validate.py | 169 +++++++++ sar402_verify_tmp/sar402_agent/__init__.py | 66 ++++ sar402_verify_tmp/sar402_agent/evidence.py | 122 +++++++ sar402_verify_tmp/sar402_agent/normalizer.py | 318 +++++++++++++++++ sar402_verify_tmp/sar402_agent/runner.py | 65 ++++ 22 files changed, 2861 insertions(+) create mode 100644 mesh/agents/sar402_verification_agent.py create mode 100644 mesh/agents/sar402_verify_tool.py create mode 100644 mesh/tests/sar402_fixtures/fail_constraint_drift.json create mode 100644 mesh/tests/sar402_fixtures/fail_evidence_mismatch.json create mode 100644 mesh/tests/sar402_fixtures/indeterminate_unknown_authority.json create mode 100644 mesh/tests/sar402_fixtures/malformed_missing_timestamp.json create mode 100644 mesh/tests/sar402_fixtures/pass_record_mode.json create mode 100644 mesh/tests/test_sar402_verification_agent.py create mode 100644 sar402_verify_tmp/EXCLUDED.md create mode 100644 sar402_verify_tmp/README.md create mode 100644 sar402_verify_tmp/sar402/__init__.py create mode 100644 sar402_verify_tmp/sar402/builder.py create mode 100644 sar402_verify_tmp/sar402/constants.py create mode 100644 sar402_verify_tmp/sar402/models.py create mode 100644 sar402_verify_tmp/sar402/predicates.py create mode 100644 sar402_verify_tmp/sar402/schema.py create mode 100644 sar402_verify_tmp/sar402/schema_data/sar-402-settlement-v0.1.schema.json create mode 100644 sar402_verify_tmp/sar402/validate.py create mode 100644 sar402_verify_tmp/sar402_agent/__init__.py create mode 100644 sar402_verify_tmp/sar402_agent/evidence.py create mode 100644 sar402_verify_tmp/sar402_agent/normalizer.py create mode 100644 sar402_verify_tmp/sar402_agent/runner.py diff --git a/mesh/agents/sar402_verification_agent.py b/mesh/agents/sar402_verification_agent.py new file mode 100644 index 0000000..2ce4b24 --- /dev/null +++ b/mesh/agents/sar402_verification_agent.py @@ -0,0 +1,89 @@ +"""SAR-402 execution/service-delivery verification agent. + +This agent and its sibling module `sar402_verify_tool.py` import only from +`sar402_verify_tmp` (a small, self-contained, vendored package included in +this repository -- see `sar402_verify_tmp/README.md`) and the framework's +own `mesh.mesh_agent.MeshAgent`. Neither module has any dependency on an +external project or repository at runtime. + +`sar402_verify_tmp` is a verbatim-logic copy of the SAR-402 reference +implementation's evidence-normalization, receipt-construction, and +schema/authority-validation code, with only its schema file path and +internal import prefix adjusted for standalone operation -- no predicate, +constant, or verdict-vocabulary change. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from mesh.mesh_agent import MeshAgent # real framework import; only resolves +# when this file is placed inside a pinned-framework checkout. + +from .sar402_verify_tool import TOOL_SCHEMA, VERIFY_TOOL_NAME, verify_settlement_evidence + + +class Sar402VerificationAgent(MeshAgent): + """Deterministic SAR-402 execution/service-delivery verification agent. + + Evaluates supplied structured evidence (optionally alongside x402 + settlement context, treated as unverified supporting context) and + returns a deterministic PASS / FAIL / INDETERMINATE receipt with a + recomputable, digest-based integrity value. Never holds funds, custody, + or execution/release authority, and never claims payment settlement + finality, a cryptographic signature, or legal finality. + """ + + def __init__(self) -> None: + super().__init__() + self.metadata.update( + { + "name": "SAR-402 Execution Verification", + "version": "0.1.0", + "author": "SAR-402 contributors", + "author_address": "0x0000000000000000000000000000000000000000", + "description": ( + "Evaluates supplied execution/service-delivery evidence " + "(optionally with x402 settlement context as unverified " + "supporting input) and returns a deterministic " + "PASS/FAIL/INDETERMINATE receipt with reason codes and a " + "recomputable digest-based integrity value. Does not " + "verify payment settlement finality and does not produce " + "a cryptographic signature." + ), + "external_apis": [], + "tags": ["verification", "x402", "sar-402"], + "hidden": True, + "verified": False, + "recommended": False, + "examples": [ + "Verify a record-mode settlement + delivery evidence bundle", + "Verify a gate-mode payment-verified-pre-delivery bundle", + ], + } + ) + + def get_system_prompt(self) -> str: + return ( + "You evaluate structured execution/service-delivery evidence, " + "optionally alongside x402 settlement context supplied as " + "unverified context, and return a deterministic PASS, FAIL, or " + "INDETERMINATE receipt with reason codes. You never hold funds, " + "custody, or execution/release authority. You never claim to " + "verify payment settlement finality, and you never claim the " + "receipt's integrity value is a cryptographic signature -- it is " + "a recomputable digest only." + ) + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [TOOL_SCHEMA] + + async def _handle_tool_logic( + self, + tool_name: str, + function_args: Dict[str, Any], + session_context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if tool_name != VERIFY_TOOL_NAME: + return {"error": f"Unsupported tool: {tool_name}"} + return await verify_settlement_evidence(function_args, session_context) diff --git a/mesh/agents/sar402_verify_tool.py b/mesh/agents/sar402_verify_tool.py new file mode 100644 index 0000000..73145cf --- /dev/null +++ b/mesh/agents/sar402_verify_tool.py @@ -0,0 +1,180 @@ +"""The single tool this agent exposes: `verify_settlement_evidence`. + +This module contains no verification logic of its own. It: + + 1. accepts a Heurist Mesh-shaped `tool_arguments` dict (structured + execution/service-delivery evidence, with an optional `x402_context` + passthrough block), + 2. maps it onto the `sar402_verify_tmp.sar402_agent` manual evidence shape + (reusing the vendored normalizer, builder, and validator verbatim), + 3. fails closed (returns a Mesh-style `{"error": ...}` payload, never a + guessed verdict) on malformed, incomplete, or authority-violating input, + 4. returns a Mesh-style `{"status": "success", "data": {...}}` payload + wrapping the full SAR-402 receipt (PASS | FAIL | INDETERMINATE) plus a + `reproduction` block that lets an independent party recompute the + receipt's integrity digest without trusting this adapter's own verdict. + +x402 context (`x402_context` in the input) is carried through into the +receipt's `notes`/`links` only as supporting/contextual information. This +adapter never claims that a receipt independently proves x402 payment +settlement or finality -- it only evaluates the five committed Continuity +predicates over the evidence it was given. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sar402_verify_tmp.sar402 import compute_integrity, validate_receipt +from sar402_verify_tmp.sar402_agent import EvidenceError, normalize_manual +from sar402_verify_tmp.sar402_agent.runner import build_receipt + +VERIFY_TOOL_NAME = "verify_settlement_evidence" + +TOOL_SCHEMA: Dict[str, Any] = { + "type": "function", + "function": { + "name": VERIFY_TOOL_NAME, + "description": ( + "Verify structured execution/service-delivery evidence (optionally " + "with x402 settlement context) and return a deterministic " + "PASS | FAIL | INDETERMINATE SAR-402 receipt, reproducible by an " + "independent verifier. Never releases funds, resources, or holds " + "execution authority." + ), + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["record", "gate"], + "description": ( + "record = post-delivery (requires delivery evidence); " + "gate = payment-verified pre-delivery (no delivery yet)." + ), + }, + "payment": { + "type": "object", + "description": "Quote/settlement constraints (resource, quote_id, price, asset, chain, recipient, payer, payment_ref, ...).", + }, + "identity": { + "type": "object", + "description": "Optional agent/wallet/authorized_payers context.", + }, + "timestamps": { + "type": "object", + "description": "quoted_at, verified_at, issued_at (required); paid_at, delivered_at, quote_expires_at (optional).", + }, + "delivery": { + "type": "object", + "description": "Delivery evidence (record mode only): delivered_resource, evidence_type, evidence_digest, status_code, delivered_at, failed.", + }, + "authority": { + "type": "object", + "description": "acting_party (record mode) or gate_controller/release_policy (gate mode). The verifier itself can never be named as gate_controller.", + }, + "x402_context": { + "type": "object", + "description": ( + "Optional supporting x402 facilitator/settlement reference " + "(e.g. facilitator id, tx hash). Carried through as context " + "only -- this tool does not independently re-verify x402 " + "payment finality." + ), + }, + }, + "required": ["mode", "payment", "timestamps"], + }, + }, +} + + +def _evidence_doc(function_args: Dict[str, Any]) -> Dict[str, Any]: + """Strip the Mesh-only `x402_context` passthrough key before handing the + rest to the committed `normalize_manual` shape (which does not know about + it). x402_context, when present, is folded into `notes` for traceability.""" + doc = {k: v for k, v in function_args.items() if k != "x402_context"} + return doc + + +def _x402_note(function_args: Dict[str, Any]) -> Optional[str]: + ctx = function_args.get("x402_context") + if not ctx: + return None + parts = [f"{k}={v}" for k, v in sorted(ctx.items())] + return "x402_context(supporting, not independently reverified): " + ", ".join(parts) + + +def independent_reproduction(receipt: Dict[str, Any]) -> Dict[str, Any]: + """Recompute the receipt's integrity digest independently of the adapter + that produced it, and re-run the committed schema/authority validator. + + This is the "independent verifier" step: it does not trust the adapter's + own `sar_verdict` claim -- it recomputes the digest from the receipt body + (minus the `integrity` block) using the same committed canonicalization the + builder used, and compares it against the receipt's stated digest. A + tampered or fabricated receipt will fail this check. + """ + stated_integrity = receipt.get("integrity") + body = {k: v for k, v in receipt.items() if k != "integrity"} + recomputed = compute_integrity(body) + digest_match = stated_integrity == recomputed + + schema_errors = [] + try: + validate_receipt(receipt) + schema_valid = True + except Exception as exc: # committed validator raises on any failure + schema_valid = False + schema_errors.append(str(exc)) + + return { + "digest_match": digest_match, + "stated_integrity": stated_integrity, + "recomputed_integrity": recomputed, + "schema_valid": schema_valid, + "schema_errors": schema_errors, + "reproducible": digest_match and schema_valid, + } + + +async def verify_settlement_evidence( + function_args: Dict[str, Any], + session_context: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Mesh-shaped tool handler: normalize -> build (committed) -> validate -> + attach independent-reproduction evidence. Fails closed on any evidence or + authority error (never guesses a verdict).""" + try: + doc = _evidence_doc(function_args) + normalized = normalize_manual(doc) + receipt = build_receipt(normalized) + validate_receipt(receipt) # defense in depth, mirrors sar402_agent.runner + except EvidenceError as exc: + return { + "status": "rejected", + "error": f"evidence rejected ({type(exc).__name__}): {exc}", + } + except Exception as exc: # schema/build errors: fail closed, do not guess + return { + "status": "rejected", + "error": f"verification failed closed ({type(exc).__name__}): {exc}", + } + + note = _x402_note(function_args) + if note: + receipt = dict(receipt) + receipt["notes"] = (receipt.get("notes") + " | " + note) if receipt.get("notes") else note + # Notes were appended after the committed builder computed integrity; + # recompute so the returned receipt's own digest stays self-consistent. + body = {k: v for k, v in receipt.items() if k != "integrity"} + receipt["integrity"] = compute_integrity(body) + validate_receipt(receipt) + + return { + "status": "success", + "data": { + "receipt": receipt, + "reproduction": independent_reproduction(receipt), + }, + } diff --git a/mesh/tests/sar402_fixtures/fail_constraint_drift.json b/mesh/tests/sar402_fixtures/fail_constraint_drift.json new file mode 100644 index 0000000..92dd401 --- /dev/null +++ b/mesh/tests/sar402_fixtures/fail_constraint_drift.json @@ -0,0 +1,40 @@ +{ + "mode": "record", + "payment": { + "resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "quote_id": "q_heu_002", + "price": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "amount_paid": { "amount": "4000", "asset": "USDC", "decimals": 6 }, + "asset": "USDC", + "chain": "eip155:8453", + "recipient": "0x9aE2b7c1F0d4E5a6B8c3D2e1F09A8b7C6d5E4F30", + "payer": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "payment_ref": "0xbf42d1e0c3e958f7b2d6f4013c8e5f9d2a7b1c0e3f4d5e6f708192a3b4c5d6e", + "facilitator": "facilitator.heurist.xyz" + }, + "identity": { + "agent": "mesh.heurist.ai/weather-forecast-agent", + "wallet": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "authorized_payers": ["0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F"] + }, + "timestamps": { + "quoted_at": "2026-07-31T14:10:00Z", + "paid_at": "2026-07-31T14:10:20Z", + "verified_at": "2026-07-31T14:10:25Z", + "delivered_at": "2026-07-31T14:10:30Z", + "issued_at": "2026-07-31T14:10:31Z", + "quote_expires_at": "2026-07-31T14:15:00Z" + }, + "delivery": { + "delivered_resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "evidence_type": "http_response", + "evidence_digest": "sha256:2b3c4d5e6f708192a3b4c5d6e7f80911223344556677889900aabbccddeeff", + "status_code": 200, + "delivered_at": "2026-07-31T14:10:30Z" + }, + "authority": { + "acting_party": "resource_server", + "verifier_has_execution_authority": false + }, + "_fixture_note": "amount_paid (4000) drifted below the quoted price (5000) -> constraint_continuity FAIL -> sar_verdict FAIL." +} diff --git a/mesh/tests/sar402_fixtures/fail_evidence_mismatch.json b/mesh/tests/sar402_fixtures/fail_evidence_mismatch.json new file mode 100644 index 0000000..ab6d637 --- /dev/null +++ b/mesh/tests/sar402_fixtures/fail_evidence_mismatch.json @@ -0,0 +1,40 @@ +{ + "mode": "record", + "payment": { + "resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "quote_id": "q_heu_003", + "price": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "amount_paid": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "asset": "USDC", + "chain": "eip155:8453", + "recipient": "0x9aE2b7c1F0d4E5a6B8c3D2e1F09A8b7C6d5E4F30", + "payer": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "payment_ref": "0xc053e2f1d4fa69f8c3e7f5124d9f6e0a3b8c2d1f4e5f6f708192a3b4c5d6e7f", + "facilitator": "facilitator.heurist.xyz" + }, + "identity": { + "agent": "mesh.heurist.ai/weather-forecast-agent", + "wallet": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "authorized_payers": ["0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F"] + }, + "timestamps": { + "quoted_at": "2026-07-31T14:20:00Z", + "paid_at": "2026-07-31T14:20:20Z", + "verified_at": "2026-07-31T14:20:25Z", + "delivered_at": "2026-07-31T14:20:30Z", + "issued_at": "2026-07-31T14:20:31Z", + "quote_expires_at": "2026-07-31T14:25:00Z" + }, + "delivery": { + "delivered_resource": "https://mesh.example/agents/wrong-resource?region=us-east", + "evidence_type": "http_response", + "evidence_digest": "sha256:3c4d5e6f708192a3b4c5d6e7f80911223344556677889900aabbccddeeff01", + "status_code": 200, + "delivered_at": "2026-07-31T14:20:30Z" + }, + "authority": { + "acting_party": "resource_server", + "verifier_has_execution_authority": false + }, + "_fixture_note": "delivered_resource does not match the requested/paid-for resource -> object_continuity FAIL, executor_continuity FAIL -> sar_verdict FAIL." +} diff --git a/mesh/tests/sar402_fixtures/indeterminate_unknown_authority.json b/mesh/tests/sar402_fixtures/indeterminate_unknown_authority.json new file mode 100644 index 0000000..b39d21f --- /dev/null +++ b/mesh/tests/sar402_fixtures/indeterminate_unknown_authority.json @@ -0,0 +1,39 @@ +{ + "mode": "record", + "payment": { + "resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "quote_id": "q_heu_004", + "price": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "amount_paid": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "asset": "USDC", + "chain": "eip155:8453", + "recipient": "0x9aE2b7c1F0d4E5a6B8c3D2e1F09A8b7C6d5E4F30", + "payer": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "payment_ref": "0xd164f3021f5b70a9d4f8062350a7f1b4c9d3e2f5f6f708192a3b4c5d6e7f809", + "facilitator": "facilitator.heurist.xyz" + }, + "identity": { + "agent": "mesh.heurist.ai/weather-forecast-agent", + "wallet": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F" + }, + "timestamps": { + "quoted_at": "2026-07-31T14:30:00Z", + "paid_at": "2026-07-31T14:30:20Z", + "verified_at": "2026-07-31T14:30:25Z", + "delivered_at": "2026-07-31T14:30:30Z", + "issued_at": "2026-07-31T14:30:31Z", + "quote_expires_at": "2026-07-31T14:35:00Z" + }, + "delivery": { + "delivered_resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "evidence_type": "http_response", + "evidence_digest": "sha256:4d5e6f708192a3b4c5d6e7f80911223344556677889900aabbccddeeff0112", + "status_code": 200, + "delivered_at": "2026-07-31T14:30:30Z" + }, + "authority": { + "acting_party": "resource_server", + "verifier_has_execution_authority": false + }, + "_fixture_note": "identity.authorized_payers is omitted (unknown, not asserted) -> authority_continuity INDETERMINATE (explicit reason code in the receipt's continuity block) -> sar_verdict INDETERMINATE, even though delivery/constraint checks pass." +} diff --git a/mesh/tests/sar402_fixtures/malformed_missing_timestamp.json b/mesh/tests/sar402_fixtures/malformed_missing_timestamp.json new file mode 100644 index 0000000..6b0f188 --- /dev/null +++ b/mesh/tests/sar402_fixtures/malformed_missing_timestamp.json @@ -0,0 +1,29 @@ +{ + "mode": "record", + "payment": { + "resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "quote_id": "q_heu_005", + "price": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "asset": "USDC", + "chain": "eip155:8453", + "recipient": "0x9aE2b7c1F0d4E5a6B8c3D2e1F09A8b7C6d5E4F30", + "payer": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "payment_ref": "0xe275041320f6c8a1e5f9173461a8e2c5d0e4f306071829a3b4c5d6e7f809192" + }, + "identity": { + "agent": "mesh.heurist.ai/weather-forecast-agent" + }, + "timestamps": { + "quoted_at": "2026-07-31T14:40:00Z", + "issued_at": "2026-07-31T14:40:31Z" + }, + "delivery": { + "delivered_resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "evidence_type": "http_response", + "status_code": 200 + }, + "authority": { + "acting_party": "resource_server" + }, + "_fixture_note": "timestamps.verified_at is missing (required) -> normalize_manual raises EvidenceValidationError -> adapter fails closed with status='rejected', no verdict guessed." +} diff --git a/mesh/tests/sar402_fixtures/pass_record_mode.json b/mesh/tests/sar402_fixtures/pass_record_mode.json new file mode 100644 index 0000000..966bb7b --- /dev/null +++ b/mesh/tests/sar402_fixtures/pass_record_mode.json @@ -0,0 +1,43 @@ +{ + "mode": "record", + "payment": { + "resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "quote_id": "q_heu_001", + "price": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "amount_paid": { "amount": "5000", "asset": "USDC", "decimals": 6 }, + "asset": "USDC", + "chain": "eip155:8453", + "recipient": "0x9aE2b7c1F0d4E5a6B8c3D2e1F09A8b7C6d5E4F30", + "payer": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "payment_ref": "0xae31c0f9b2d847e6a1c5f3902b7d4e8c19f6a0b3c2d4e5f60718293a4b5c6d7e", + "facilitator": "facilitator.heurist.xyz" + }, + "identity": { + "agent": "mesh.heurist.ai/weather-forecast-agent", + "wallet": "0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F", + "authorized_payers": ["0x1f4C8d2A9b3E6c7D0a5B4e3F2c1D0a9B8e7C6d5F"] + }, + "timestamps": { + "quoted_at": "2026-07-31T14:00:00Z", + "paid_at": "2026-07-31T14:00:20Z", + "verified_at": "2026-07-31T14:00:25Z", + "delivered_at": "2026-07-31T14:00:30Z", + "issued_at": "2026-07-31T14:00:31Z", + "quote_expires_at": "2026-07-31T14:05:00Z" + }, + "delivery": { + "delivered_resource": "https://mesh.example/agents/weather-forecast?region=eu-west", + "evidence_type": "http_response", + "evidence_digest": "sha256:1a2b3c4d5e6f708192a3b4c5d6e7f80911223344556677889900aabbccddee", + "status_code": 200, + "delivered_at": "2026-07-31T14:00:30Z" + }, + "authority": { + "acting_party": "resource_server", + "verifier_has_execution_authority": false + }, + "x402_context": { + "facilitator": "facilitator.heurist.xyz", + "settlement_ref": "0xae31c0f9b2d847e6a1c5f3902b7d4e8c19f6a0b3c2d4e5f60718293a4b5c6d7e" + } +} diff --git a/mesh/tests/test_sar402_verification_agent.py b/mesh/tests/test_sar402_verification_agent.py new file mode 100644 index 0000000..b6960a7 --- /dev/null +++ b/mesh/tests/test_sar402_verification_agent.py @@ -0,0 +1,148 @@ +"""Smoke test for the SAR-402 verification agent. + +A plain `unittest` module under `mesh/tests/` that imports +`mesh.agents.sar402_verification_agent` and `mesh.mesh_manager` directly, with +no runtime dependency outside this repository. + +Covers: module discovery, class loading, no-argument instantiation, +deterministic PASS / FAIL / INDETERMINATE, malformed-input fail-closed, +missing-required-input fail-closed, unsupported-tool bounding, mocked +normal-query dispatch, and absence of any dependency outside this repository. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import types +import unittest +from pathlib import Path + +FIXTURES_DIR = Path(__file__).resolve().parent / "sar402_fixtures" + + +def _load(name: str): + with open(FIXTURES_DIR / name) as f: + return json.load(f) + + +class Sar402VerificationAgentSmokeTest(unittest.TestCase): + def test_module_discovery_and_class_loading(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + self.assertTrue(hasattr(Sar402VerificationAgent, "_handle_tool_logic")) + + def test_agent_loader_discovers_and_registers(self): + from mesh.mesh_manager import AgentLoader, Config + + agents = AgentLoader(Config()).load_agents() + self.assertIn("Sar402VerificationAgent", agents) + + def test_no_argument_instantiation(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + self.assertEqual(agent.metadata["name"], "SAR-402 Execution Verification") + tool_names = [t["function"]["name"] for t in agent.get_tool_schemas()] + self.assertEqual(tool_names, ["verify_settlement_evidence"]) + + def test_deterministic_pass(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run( + agent.call_agent( + {"tool": "verify_settlement_evidence", "tool_arguments": _load("pass_record_mode.json")} + ) + ) + self.assertEqual(result["data"]["data"]["receipt"]["sar_verdict"], "PASS") + + def test_deterministic_fail(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run( + agent.call_agent( + {"tool": "verify_settlement_evidence", "tool_arguments": _load("fail_constraint_drift.json")} + ) + ) + self.assertEqual(result["data"]["data"]["receipt"]["sar_verdict"], "FAIL") + + def test_deterministic_indeterminate(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run( + agent.call_agent( + { + "tool": "verify_settlement_evidence", + "tool_arguments": _load("indeterminate_unknown_authority.json"), + } + ) + ) + self.assertEqual(result["data"]["data"]["receipt"]["sar_verdict"], "INDETERMINATE") + + def test_malformed_input_fails_closed(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run( + agent.call_agent( + { + "tool": "verify_settlement_evidence", + "tool_arguments": _load("malformed_missing_timestamp.json"), + } + ) + ) + self.assertEqual(result["data"]["status"], "rejected") + + def test_missing_required_input_fails_closed(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run( + agent.call_agent({"tool": "verify_settlement_evidence", "tool_arguments": {"mode": "record"}}) + ) + self.assertEqual(result["data"]["status"], "rejected") + + def test_unsupported_tool_is_bounded(self): + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + agent = Sar402VerificationAgent() + result = asyncio.run(agent.call_agent({"tool": "nonexistent_tool", "tool_arguments": {}})) + self.assertIn("Unsupported tool", result["data"]["error"]) + + def test_mocked_normal_query_dispatch(self): + import mesh.mesh_agent as mesh_agent_mod + from mesh.agents.sar402_verification_agent import Sar402VerificationAgent + + class FakeToolCall: + def __init__(self, name, arguments): + self.id = "fake_tool_call_1" + self.function = types.SimpleNamespace(name=name, arguments=json.dumps(arguments)) + + async def fake_llm(*args, **kwargs): + return {"tool_calls": FakeToolCall("verify_settlement_evidence", _load("pass_record_mode.json"))} + + original = mesh_agent_mod.call_gemini_with_tools_async + mesh_agent_mod.call_gemini_with_tools_async = fake_llm + try: + agent = Sar402VerificationAgent() + result = asyncio.run(agent.call_agent({"query": "verify this", "raw_data_only": True})) + finally: + mesh_agent_mod.call_gemini_with_tools_async = original + + self.assertEqual(result["data"]["data"]["receipt"]["sar_verdict"], "PASS") + + def test_no_morpheus_dependency_available(self): + """This candidate's dispatch path must never import `morpheus`. Run + this file with cwd = a disposable checkout that has no Morpheus path + anywhere on sys.path/PYTHONPATH for the strongest form of this claim + (verified separately, see the evidence report); the in-process + assertion here checks that nothing above this line pulled it in.""" + self.assertNotIn("morpheus", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/sar402_verify_tmp/EXCLUDED.md b/sar402_verify_tmp/EXCLUDED.md new file mode 100644 index 0000000..7f83066 --- /dev/null +++ b/sar402_verify_tmp/EXCLUDED.md @@ -0,0 +1,34 @@ +# Excluded from sar402_verify_tmp (considered, not copied) + +Traced from the actual imports of `mesh_candidate/mesh/agents/sar402_verify_tool.py` +and `sar402_verification_agent.py` (`compute_integrity`, `validate_receipt`, +`EvidenceError`, `normalize_manual`, `build_receipt`), transitively resolved. + +| File / symbol | Why excluded | +|---|---| +| `morpheus/sar402_agent/storage.py` (whole file) | File-persistence / `preserve_run` logic. Never invoked by the Mesh tool (the tool returns a receipt in-memory; it never writes to disk). Flagged as unnecessary scope creep by the 2026-07-31 architecture-decision-gate review. | +| `morpheus/sar402/samples.py` (whole file) | Test/demo-only fixture data. Not needed for normalization, evaluation, receipt construction, or validation. Flagged by the same architecture-decision-gate review. | +| `sar402_agent/runner.py: run_evidence_doc` | Depends on `storage.preserve_run`; local-persistence CLI helper, not needed by the Mesh tool. | +| `sar402_agent/runner.py: run_evidence_file` | CLI-only entry point; not needed by the Mesh tool. | +| `sar402_agent/runner.py: main` / CLI arg parser | CLI-only entry point; not needed by the Mesh tool. | +| `sar402_agent/runner.py: RunResult` dataclass | Only used by the excluded CLI/run_* entry points above. | +| `sar402_agent/normalizer.py: normalize_demo` | Demo-endpoint ingestion shape (Option B). The Mesh tool's input contract is the manual/fixture shape (Option A) only; not exported from `sar402_agent/__init__.py` in this copy (function body kept in the already-required `normalizer.py` file but unused/unreferenced). | +| Signing code, signer selection, key discovery, production registries | None exist in the canonical `morpheus/sar402*` modules being copied; not applicable. | +| Endpoint/server code, wallets, payment execution | None exist in the canonical `morpheus/sar402*` modules being copied; not applicable. `defaultverifier.com` is never referenced. | +| Reference-implementation orchestration, state files, production configuration, deployment logic | None exist in the canonical sar402 modules being copied; not applicable. | +| Unrelated builders/utilities outside `sar402`/`sar402_agent` | Not traced as a dependency of the Mesh tool; not copied. | + +## Kept (and why each is essential) + +| File | Why kept | +|---|---| +| `sar402/models.py` | `Amount`, `DeliveryEvidence`, `SettlementEvidence`, `parse_timestamp` — input normalization data model, used by `normalizer.py` and `builder.py`. | +| `sar402/constants.py` | Verdict/mode/schema-path constants used by `predicates.py`, `schema.py`, `validate.py`. | +| `sar402/predicates.py` | `derive_verdict`, `evaluate_continuity` — the five committed Continuity predicates (deterministic evaluation), used by `builder.py`. | +| `sar402/schema.py` | Schema loading/backend used by `validate.py`. | +| `sar402/schema_data/*.json` | The committed SAR-402 JSON Schema itself, loaded by `schema.py`. | +| `sar402/builder.py` | `build_record_mode_receipt`, `build_gate_mode_receipt`, `compute_integrity` — receipt construction and integrity recomputation, used by `runner.py` and `sar402_verify_tool.py`. | +| `sar402/validate.py` | `validate_receipt`, `is_forbidden_gate_controller`, `AuthorityBoundaryError` — receipt/authority validation, used by `builder.py`, `evidence.py`, and `sar402_verify_tool.py`. | +| `sar402_agent/evidence.py` | `NormalizedEvidence`, `EvidenceError`, mode constants, authority-boundary check — normalization model and errors, used by `normalizer.py` and `runner.py`. | +| `sar402_agent/normalizer.py` | `normalize_manual` — the input-normalization entry point the Mesh tool calls directly. | +| `sar402_agent/runner.py` (trimmed) | `build_receipt` — the mode-dispatch call into the committed builder, the only symbol the Mesh tool needs from the canonical runner. | diff --git a/sar402_verify_tmp/README.md b/sar402_verify_tmp/README.md new file mode 100644 index 0000000..824e7ce --- /dev/null +++ b/sar402_verify_tmp/README.md @@ -0,0 +1,29 @@ +# sar402_verify_tmp + +A small, self-contained, vendored copy of the SAR-402 reference +implementation's evidence-normalization, receipt-construction, and +schema/authority-validation logic. It exists so `mesh/agents/sar402_verify_tool.py` +and `sar402_verification_agent.py` can run with zero dependency on any +external repository being present. + +## Provenance + +- Source: SAR-402 reference implementation, `sar402/` and `sar402_agent/` + modules (evidence normalization, receipt construction, schema/authority + validation, digest-based integrity). +- Only the schema file path and internal import prefix were adjusted for + standalone operation -- no predicate, constant, or verdict-vocabulary + change. + +## What was excluded and why + +See `EXCLUDED.md` for the full list. In short: file-persistence code +(`storage.py`, never invoked by the Mesh tool) and test/demo-only sample +fixtures (`samples.py`) were dropped, along with the CLI/local-persistence +entry points in `runner.py` (`run_evidence_doc`, `run_evidence_file`, `main`) +that depended on `storage.py`. Only `build_receipt` was kept from `runner.py`. + +## Not a separately published package + +This is vendored source under this repository, not published or installed +from a package registry. diff --git a/sar402_verify_tmp/sar402/__init__.py b/sar402_verify_tmp/sar402/__init__.py new file mode 100644 index 0000000..5dc37de --- /dev/null +++ b/sar402_verify_tmp/sar402/__init__.py @@ -0,0 +1,94 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/__init__.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# +# TRIMMED relative to the canonical __init__.py: `samples` (test/demo-only +# fixtures, never invoked by the Mesh tool) is intentionally NOT imported or +# re-exported here. See sar402_verify_tmp/EXCLUDED.md for the full exclusion +# list and rationale. +# ----------------------------------------------------------------------------- +"""SAR-402 -- local, network-free, NON-CANONICAL temporary copy. + +SAR means Settlement Attestation Receipt. SAR-402 is an x402-specific *profile* +of SAR, not the whole Default Settlement system and not a new primitive. + +This is a disposable, unpublished, local copy of the minimum SAR-402 +verification surface needed by a Heurist Mesh candidate tool. It is NOT a new +standard, NOT an authoritative SAR-402 implementation, and NOT a package +release. The canonical implementation is the SAR-402 reference implementation's `sar402/` package. + +Authority boundary (non-negotiable): the verifier never holds custody, moves +funds, releases resources, executes actions, or enforces decisions. +verifier_has_execution_authority is always false. In gate mode the verifier +returns a result; the named external gate_controller decides whether to act. +""" + +from __future__ import annotations + +from . import constants, predicates +from .builder import ( + build_gate_authority_binding, + build_gate_mode_receipt, + build_record_mode_receipt, + canonical_json, + compute_integrity, + derive_agent_id, +) +from .constants import FAIL, INDETERMINATE, PASS +from .models import Amount, DeliveryEvidence, SettlementEvidence, parse_timestamp +from .predicates import derive_verdict, evaluate_continuity +from .schema import active_backend, load_schema, schema_errors +from .validate import ( + AuthorityBoundaryError, + SAR402ValidationError, + is_forbidden_gate_controller, + is_valid, + iter_errors, + validate_fixture, + validate_receipt, +) + +__all__ = [ + "constants", + "predicates", + # verdicts + "PASS", + "FAIL", + "INDETERMINATE", + # models + "Amount", + "DeliveryEvidence", + "SettlementEvidence", + "parse_timestamp", + # predicates + "evaluate_continuity", + "derive_verdict", + # builder + "build_record_mode_receipt", + "build_gate_mode_receipt", + "build_gate_authority_binding", + "derive_agent_id", + "canonical_json", + "compute_integrity", + # schema + "load_schema", + "schema_errors", + "active_backend", + # validate + "validate_receipt", + "validate_fixture", + "iter_errors", + "is_valid", + "is_forbidden_gate_controller", + "SAR402ValidationError", + "AuthorityBoundaryError", +] diff --git a/sar402_verify_tmp/sar402/builder.py b/sar402_verify_tmp/sar402/builder.py new file mode 100644 index 0000000..4065ac1 --- /dev/null +++ b/sar402_verify_tmp/sar402/builder.py @@ -0,0 +1,325 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/builder.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""SAR-402 receipt builder. + +Constructs schema-shaped SAR-402 receipt objects from normalized +`SettlementEvidence`. The builder: + + * sets schema_id / profile / sar_type from the committed schema consts, + * keeps sar_verdict in PASS | FAIL | INDETERMINATE (derived from the five + Continuity predicates unless explicitly overridden), + * populates payment_state, delivery_state, settlement_state separately, + * evaluates and includes the canonical five continuity predicates, + * always includes authority_binding with verifier_has_execution_authority=false, + * requires gate_controller + release_policy for gate mode and refuses any + forbidden gate controller (authority boundary), + * requires delivery evidence for post-delivery / post-settlement-audit, + * refuses executor_continuity=PASS pre-delivery without delivery evidence, + * self-validates output before returning. + +It builds receipts; it never releases resources, moves funds, or executes. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Dict, List, Optional + +from . import constants +from .models import SettlementEvidence +from .predicates import derive_verdict, evaluate_continuity +from .validate import is_forbidden_gate_controller, validate_receipt, AuthorityBoundaryError + + +# --------------------------------------------------------------------------- +# Identity derivation +# --------------------------------------------------------------------------- + +def derive_agent_id(chain: str, payer: str) -> str: + """Deterministic settlement-derived agent id: agent:x402::.""" + return f"agent:x402:{chain}:{payer}" + + +# --------------------------------------------------------------------------- +# Canonicalization / integrity +# --------------------------------------------------------------------------- + +def canonical_json(obj) -> str: + """Deterministic JSON: sorted keys, compact separators. Not RFC 8785 JCS.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def compute_integrity(receipt_without_integrity: dict) -> dict: + digest = hashlib.sha256( + canonical_json(receipt_without_integrity).encode("utf-8") + ).hexdigest() + return { + "digest_alg": "sha256", + "canonicalization": constants.CANONICALIZATION, + "digest": f"sha256:{digest}", + } + + +# --------------------------------------------------------------------------- +# Sub-object construction +# --------------------------------------------------------------------------- + +def _build_payment(ev: SettlementEvidence) -> dict: + payment = { + "resource": ev.resource, + "quote_id": ev.quote_id, + "price": ev.price.as_dict(), + } + payment["amount_paid"] = ev.effective_amount_paid.as_dict() + payment["asset"] = ev.asset + payment["chain"] = ev.chain + payment["recipient"] = ev.recipient + payment["payer"] = ev.payer + payment["payment_ref"] = ev.payment_ref + if ev.facilitator: + payment["facilitator"] = ev.facilitator + return payment + + +def _build_identity(ev: SettlementEvidence, identity_status: str) -> dict: + identity = {"payer": ev.payer} + if ev.agent: + identity["agent"] = ev.agent + if ev.wallet: + identity["wallet"] = ev.wallet + identity["derived_identity"] = { + "registration_mode": constants.REGISTRATION_MODE_DERIVED, + "derived_agent_id": derive_agent_id(ev.chain, ev.payer), + "identity_status": identity_status, + } + return identity + + +def _build_timestamps(ev: SettlementEvidence) -> dict: + missing = [ + name + for name in ("quoted_at", "verified_at", "issued_at") + if not getattr(ev, name) + ] + if missing: + raise ValueError( + f"SettlementEvidence missing required timestamp(s): {', '.join(missing)}" + ) + ts = {"quoted_at": ev.quoted_at} + if ev.paid_at: + ts["paid_at"] = ev.paid_at + ts["verified_at"] = ev.verified_at + if ev.delivered_at: + ts["delivered_at"] = ev.delivered_at + ts["issued_at"] = ev.issued_at + if ev.quote_expires_at: + ts["quote_expires_at"] = ev.quote_expires_at + return ts + + +def _assemble( + *, + ev: SettlementEvidence, + verification_point: str, + verification_mode: str, + authority_binding: dict, + payment_state: str, + delivery_state: str, + settlement_state: str, + continuity: Dict[str, str], + sar_verdict: str, + issuer: dict, + environment: Optional[str], + identity_status: str, + notes: Optional[str], + links: Optional[List[str]], + prior_sar_digest: Optional[str], + include_delivery: bool, + validate: bool, +) -> dict: + issuer_block = dict(issuer) + if environment and "environment" not in issuer_block: + issuer_block["environment"] = environment + + receipt: dict = { + "schema_id": constants.SCHEMA_ID, + "profile": constants.PROFILE, + "sar_type": constants.SAR_TYPE, + "sar_verdict": sar_verdict, + "verification_point": verification_point, + "verification_mode": verification_mode, + "authority_binding": authority_binding, + "payment_state": payment_state, + "delivery_state": delivery_state, + "settlement_state": settlement_state, + "continuity": {name: continuity[name] for name in constants.CONTINUITY_PREDICATES}, + "payment": _build_payment(ev), + } + + if include_delivery: + if ev.delivery is None: + raise ValueError("delivery evidence required but SettlementEvidence.delivery is None") + receipt["delivery"] = ev.delivery.as_dict() + + receipt["identity"] = _build_identity(ev, identity_status) + receipt["timestamps"] = _build_timestamps(ev) + receipt["issuer"] = issuer_block + + if notes: + receipt["notes"] = notes + if links: + receipt["links"] = list(links) + if prior_sar_digest: + receipt["prior_sar_digest"] = prior_sar_digest + + receipt["integrity"] = compute_integrity(receipt) + + if validate: + validate_receipt(receipt) + return receipt + + +# --------------------------------------------------------------------------- +# Public builders +# --------------------------------------------------------------------------- + +def build_record_mode_receipt( + ev: SettlementEvidence, + *, + acting_party: str = "resource_server", + issuer: Optional[dict] = None, + environment: Optional[str] = "test", + identity_status: str = "derived", + notes: Optional[str] = None, + links: Optional[List[str]] = None, + prior_sar_digest: Optional[str] = None, + sar_verdict: Optional[str] = None, + validate: bool = True, +) -> dict: + """Build a record-mode, post-delivery SAR-402 receipt (the low-friction path). + + Requires delivery evidence. Continuity is evaluated from the evidence and the + verdict derived from it unless `sar_verdict` is explicitly supplied.""" + if ev.delivery is None: + raise ValueError("record-mode post-delivery receipt requires ev.delivery") + + continuity = evaluate_continuity(ev) + verification_point = "post_delivery" + verdict = sar_verdict or derive_verdict(continuity, verification_point) + + executor = continuity["executor_continuity"] + if ev.delivery.failed or executor == constants.FAIL: + delivery_state = "failed" + settlement_state = "not_delivered" + elif executor == constants.PASS: + delivery_state = "confirmed" + settlement_state = "delivered" + else: + delivery_state = "indeterminate" + settlement_state = "indeterminate" + + payment_state = "verified" + + authority_binding = { + "acting_party": acting_party, + "verifier_has_execution_authority": False, + } + + return _assemble( + ev=ev, + verification_point=verification_point, + verification_mode="record", + authority_binding=authority_binding, + payment_state=payment_state, + delivery_state=delivery_state, + settlement_state=settlement_state, + continuity=continuity, + sar_verdict=verdict, + issuer=issuer or constants.DEFAULT_ISSUER, + environment=environment, + identity_status=identity_status, + notes=notes, + links=links, + prior_sar_digest=prior_sar_digest, + include_delivery=True, + validate=validate, + ) + + +def build_gate_authority_binding(gate_controller: str, release_policy: str) -> dict: + """Construct (and guard) a gate-mode authority_binding. + + Refuses any gate controller that implies the verifier / Default Settlement / + Morpheus / SettlementWitness / this SAR-402 implementation.""" + if not gate_controller: + raise AuthorityBoundaryError("gate_controller is required in gate mode") + if not release_policy: + raise AuthorityBoundaryError("release_policy is required in gate mode") + if is_forbidden_gate_controller(gate_controller): + raise AuthorityBoundaryError( + f"gate_controller {gate_controller!r} implies a forbidden identity and " + "cannot hold release authority" + ) + return { + "gate_controller": gate_controller, + "release_policy": release_policy, + "verifier_has_execution_authority": False, + } + + +def build_gate_mode_receipt( + ev: SettlementEvidence, + *, + gate_controller: str, + release_policy: str = "release_on_PASS_escalate_on_INDETERMINATE_withhold_on_FAIL", + issuer: Optional[dict] = None, + environment: Optional[str] = "test", + identity_status: str = "derived", + notes: Optional[str] = None, + links: Optional[List[str]] = None, + prior_sar_digest: Optional[str] = None, + sar_verdict: Optional[str] = None, + validate: bool = True, +) -> dict: + """Build a gate-mode, payment-verified-pre-delivery SAR-402 receipt. + + The verifier returns a result; the named gate_controller decides whether to + release under its own policy. executor_continuity is normally INDETERMINATE + here (nothing delivered yet). Delivery evidence is not included at this seam.""" + authority_binding = build_gate_authority_binding(gate_controller, release_policy) + + continuity = evaluate_continuity(ev) + verification_point = "payment_verified_pre_delivery" + verdict = sar_verdict or derive_verdict(continuity, verification_point) + + return _assemble( + ev=ev, + verification_point=verification_point, + verification_mode="gate", + authority_binding=authority_binding, + payment_state="verified", + delivery_state="not_applicable", + settlement_state="pending", + continuity=continuity, + sar_verdict=verdict, + issuer=issuer or constants.DEFAULT_ISSUER, + environment=environment, + identity_status=identity_status, + notes=notes, + links=links, + prior_sar_digest=prior_sar_digest, + include_delivery=False, + validate=validate, + ) diff --git a/sar402_verify_tmp/sar402/constants.py b/sar402_verify_tmp/sar402/constants.py new file mode 100644 index 0000000..f03d1ef --- /dev/null +++ b/sar402_verify_tmp/sar402/constants.py @@ -0,0 +1,131 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/constants.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Canonical SAR-402 constants (vendored, standalone copy). + +This is a verbatim-logic copy of Default Settlement's committed +`morpheus/sar402/constants.py`, adapted only so the schema it cross-checks +against is loaded from a package-vendored copy +(`sar402_verify_tmp/sar402/schema_data/sar-402-settlement-v0.1.schema.json`) +instead of a path inside the reference-implementation repository. No predicate, constant +value, or verdict vocabulary was changed. The canonical/authoritative +location of this schema remains +`knowledge-assets/profiles/sar-402/schema/sar-402-settlement-v0.1.schema.json` +in the reference-implementation repository; this package carries a point-in-time copy for +standalone operation and is not itself the authoritative source. + +`schema.py` cross-checks the most important of these against the loaded +schema at import time so this module cannot silently drift from the vendored +copy it ships with. +""" + +from __future__ import annotations + +from pathlib import Path + +# --------------------------------------------------------------------------- +# Vendored asset locations (package-relative, no repository coupling) +# --------------------------------------------------------------------------- + +_PACKAGE_DIR = Path(__file__).resolve().parent +SCHEMA_PATH = _PACKAGE_DIR / "schema_data" / "sar-402-settlement-v0.1.schema.json" + +# --------------------------------------------------------------------------- +# Fixed receipt identity (schema consts) +# --------------------------------------------------------------------------- + +SCHEMA_ID = "sar_402_settlement_v0.1" +PROFILE = "sar-402" +SAR_TYPE = "Settlement Attestation Receipt" + +# --------------------------------------------------------------------------- +# Verdict vocabulary — the ONLY verdict vocabulary. Do not fork. +# --------------------------------------------------------------------------- + +PASS = "PASS" +FAIL = "FAIL" +INDETERMINATE = "INDETERMINATE" +VERDICTS = (PASS, FAIL, INDETERMINATE) + +# --------------------------------------------------------------------------- +# Three axes +# --------------------------------------------------------------------------- + +VERIFICATION_POINTS = ( + "pre_authorization", + "payment_verified_pre_delivery", + "post_delivery", + "post_settlement_audit", +) + +VERIFICATION_MODES = ("observe", "gate", "record", "audit") + +# Verification points where executor_continuity is legitimately not yet +# knowable (nothing delivered yet). At these points an INDETERMINATE +# executor predicate is expected and does not, by itself, block a PASS verdict. +PRE_DELIVERY_POINTS = ("pre_authorization", "payment_verified_pre_delivery") + +# --------------------------------------------------------------------------- +# State fields (separate from sar_verdict and from each other) +# --------------------------------------------------------------------------- + +PAYMENT_STATES = ("verified", "unverified", "failed", "indeterminate") +DELIVERY_STATES = ("confirmed", "claimed", "failed", "not_applicable", "indeterminate") +SETTLEMENT_STATES = ("delivered", "not_delivered", "pending", "unverified", "indeterminate") + +# --------------------------------------------------------------------------- +# Continuity predicates — the canonical five. Never add, never fork per chain. +# --------------------------------------------------------------------------- + +CONTINUITY_PREDICATES = ( + "object_continuity", + "constraint_continuity", + "temporal_continuity", + "authority_continuity", + "executor_continuity", +) + +# --------------------------------------------------------------------------- +# Authority boundary +# --------------------------------------------------------------------------- + +# The universal rule: the verifier never holds execution authority. +VERIFIER_HAS_EXECUTION_AUTHORITY = False + +# A gate controller must be the external consuming system that controls +# release. It must never be the verifier, the trust system, this node, the +# witness, or this SAR-402 implementation itself. We do NOT rely on a single +# denylisted literal: gate-controller values are normalized (lowercased, +# stripped to [a-z0-9]) and rejected if they contain any forbidden identity +# token. See validate.is_forbidden_gate_controller. +FORBIDDEN_GATE_CONTROLLER_TOKENS = ( + "defaultverifier", + "defaultsettlement", + "morpheus", + "settlementwitness", + "sar402", +) + +# Settlement-derived identity +REGISTRATION_MODE_DERIVED = "derived_from_settlement" +IDENTITY_STATUSES = ("derived", "claimed", "verified", "linked") + +DEFAULT_ISSUER = { + "verifier": "DefaultVerifier", + "verifier_version": "0.1.0", +} + +# Canonicalization label for the local digest. This is honest about what the +# builder actually does (sorted-key compact JSON), and is intentionally NOT +# claimed to be RFC 8785 JCS, which remains out of scope for v0.1. +CANONICALIZATION = "sorted_keys_compact_v0" diff --git a/sar402_verify_tmp/sar402/models.py b/sar402_verify_tmp/sar402/models.py new file mode 100644 index 0000000..973a206 --- /dev/null +++ b/sar402_verify_tmp/sar402/models.py @@ -0,0 +1,172 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/models.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Normalized input models for SAR-402. + +These are the local, network-free representation of the evidence a future +SAR-402 agent will collect from an x402 flow: + + * the 402 quote / challenge constraints, + * the verified payment / settlement actuals, + * optional delivery evidence. + +Nothing here parses live x402 payloads or touches a chain. The agent is +expected to normalize its raw evidence into a `SettlementEvidence` and hand it +to the predicate evaluators and the builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional, Sequence + + +@dataclass(frozen=True) +class Amount: + """An on-chain amount carried as an integer string with explicit decimals + to avoid float drift.""" + + amount: str + asset: str + decimals: int + + def as_dict(self) -> dict: + return {"amount": self.amount, "asset": self.asset, "decimals": self.decimals} + + def matches(self, other: "Amount") -> bool: + return ( + self.amount == other.amount + and self.asset == other.asset + and self.decimals == other.decimals + ) + + +@dataclass(frozen=True) +class DeliveryEvidence: + """Evidence that the resource/action was (or was not) delivered. + + `failed=True` records an attempted-but-failed delivery. Absence of a + DeliveryEvidence object altogether means delivery has not happened or has + not been observed (pre-delivery).""" + + delivered_resource: str + evidence_type: str + evidence_digest: Optional[str] = None + status_code: Optional[int] = None + delivered_at: Optional[str] = None + failed: bool = False + + def as_dict(self) -> dict: + out = { + "delivered_resource": self.delivered_resource, + "evidence_type": self.evidence_type, + } + if self.evidence_digest is not None: + out["evidence_digest"] = self.evidence_digest + if self.status_code is not None: + out["status_code"] = self.status_code + if self.delivered_at is not None: + out["delivered_at"] = self.delivered_at + return out + + +@dataclass +class SettlementEvidence: + """Normalized evidence for one x402 settlement. + + Quote-side fields (resource, quote_id, price, asset, chain, recipient) + describe what the 402 challenge authorized. Settlement-side overrides + (amount_paid, settled_asset, settled_chain, settled_recipient) describe the + actuals; when an override is None it is treated as equal to the quote side + (i.e. no drift). This lets the predicate evaluators detect constraint drift + without forcing every caller to restate matching values. + """ + + # Quote / authorized constraints + resource: str + quote_id: str + price: Amount + asset: str + chain: str # CAIP-2 style, e.g. "eip155:8453". Chain-agnostic. Not Base-only. + recipient: str + payer: str + payment_ref: str + + # Settlement actuals (default to the quote side when None) + amount_paid: Optional[Amount] = None + settled_asset: Optional[str] = None + settled_chain: Optional[str] = None + settled_recipient: Optional[str] = None + + # Identity / authority context + facilitator: Optional[str] = None + agent: Optional[str] = None + wallet: Optional[str] = None + # Wallets/agents authorized for this settlement context. None => unknown + # (authority_continuity is INDETERMINATE rather than guessed). + authorized_payers: Optional[Sequence[str]] = None + + # Timestamps (ISO 8601). quoted_at / verified_at / issued_at are required + # by the schema; the rest are optional context. + quoted_at: Optional[str] = None + paid_at: Optional[str] = None + verified_at: Optional[str] = None + delivered_at: Optional[str] = None + issued_at: Optional[str] = None + quote_expires_at: Optional[str] = None + + # Delivery evidence (None => pre-delivery / not observed) + delivery: Optional[DeliveryEvidence] = None + + # ---- derived accessors ------------------------------------------------- + + @property + def effective_amount_paid(self) -> Amount: + return self.amount_paid if self.amount_paid is not None else self.price + + @property + def effective_settled_asset(self) -> str: + return self.settled_asset if self.settled_asset is not None else self.asset + + @property + def effective_settled_chain(self) -> str: + return self.settled_chain if self.settled_chain is not None else self.chain + + @property + def effective_settled_recipient(self) -> str: + return ( + self.settled_recipient + if self.settled_recipient is not None + else self.recipient + ) + + +def parse_timestamp(value: Optional[str]) -> Optional[datetime]: + """Best-effort ISO-8601 parse. Returns None if value is falsy or unparsable. + + Accepts a trailing 'Z'. Naive datetimes are treated as UTC so that + comparisons across the evidence are consistent.""" + + if not value: + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt diff --git a/sar402_verify_tmp/sar402/predicates.py b/sar402_verify_tmp/sar402/predicates.py new file mode 100644 index 0000000..f4b7dd4 --- /dev/null +++ b/sar402_verify_tmp/sar402/predicates.py @@ -0,0 +1,167 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/predicates.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Pure local evaluators for the canonical five Continuity predicates. + +Each evaluator takes normalized `SettlementEvidence` and returns exactly one of +PASS | FAIL | INDETERMINATE. There is no second verdict vocabulary and there are +no x402-specific predicates. Insufficient evidence yields INDETERMINATE; it is +never guessed. + +Semantic mapping (from the SAR-402 profile, §15): + + object_continuity paid resource/action matches delivered resource/action + constraint_continuity quote vs settlement: amount, asset, chain, recipient, quote id + temporal_continuity payment/delivery within the authorized quote window + authority_continuity payer/agent/wallet authorized for the settlement context + executor_continuity resource/action actually delivered; pre-delivery => INDETERMINATE +""" + +from __future__ import annotations + +from typing import Dict + +from .constants import ( + FAIL, + INDETERMINATE, + PASS, + PRE_DELIVERY_POINTS, +) +from .models import SettlementEvidence, parse_timestamp + + +def object_continuity(ev: SettlementEvidence) -> str: + """Paid for resource A, received resource A — not resource B.""" + if not ev.resource: + return INDETERMINATE + if ev.delivery is None: + # Pre-delivery: the object identity is preserved up to settlement + # (the payment references the quoted resource). Delivery is evaluated + # by executor_continuity once it exists. + return PASS + delivered = ev.delivery.delivered_resource + if not delivered: + return INDETERMINATE + return PASS if delivered == ev.resource else FAIL + + +def constraint_continuity(ev: SettlementEvidence) -> str: + """Price, asset, recipient, chain, and quote id did not drift between the + 402 challenge and settlement.""" + if not ev.quote_id: + return INDETERMINATE + if ev.price is None: + return INDETERMINATE + paid = ev.effective_amount_paid + checks = ( + ev.price.amount == paid.amount, + ev.price.decimals == paid.decimals, + ev.asset == paid.asset, + ev.asset == ev.effective_settled_asset, + ev.chain == ev.effective_settled_chain, + ev.recipient == ev.effective_settled_recipient, + ) + return PASS if all(checks) else FAIL + + +def temporal_continuity(ev: SettlementEvidence) -> str: + """Settlement and delivery occurred inside the valid quote/payment window.""" + window_end = parse_timestamp(ev.quote_expires_at) + if window_end is None: + return INDETERMINATE + window_start = parse_timestamp(ev.quoted_at) + + observed = [] + for ts in (ev.paid_at, ev.delivered_at): + parsed = parse_timestamp(ts) + if parsed is not None: + observed.append(parsed) + if not observed: + return INDETERMINATE + + for moment in observed: + if moment > window_end: + return FAIL + if window_start is not None and moment < window_start: + return FAIL + return PASS + + +def authority_continuity(ev: SettlementEvidence) -> str: + """The paying wallet / delegated agent was permitted to settle and receive.""" + if ev.authorized_payers is None: + return INDETERMINATE + if not ev.payer: + return INDETERMINATE + allowed = {a.lower() for a in ev.authorized_payers} + candidates = {ev.payer.lower()} + if ev.agent: + candidates.add(ev.agent.lower()) + if ev.wallet: + candidates.add(ev.wallet.lower()) + return PASS if candidates & allowed else FAIL + + +def executor_continuity(ev: SettlementEvidence) -> str: + """The resource server actually performed the authorized delivery. + + Pre-delivery (no delivery evidence) is INDETERMINATE: it is not yet + knowable, not a failure.""" + if ev.delivery is None: + return INDETERMINATE + if ev.delivery.failed: + return FAIL + delivered = ev.delivery.delivered_resource + if not delivered: + return INDETERMINATE + return PASS if delivered == ev.resource else FAIL + + +_EVALUATORS = { + "object_continuity": object_continuity, + "constraint_continuity": constraint_continuity, + "temporal_continuity": temporal_continuity, + "authority_continuity": authority_continuity, + "executor_continuity": executor_continuity, +} + + +def evaluate_continuity(ev: SettlementEvidence) -> Dict[str, str]: + """Evaluate all five predicates. Returns a dict in canonical predicate order.""" + return {name: fn(ev) for name, fn in _EVALUATORS.items()} + + +def derive_verdict(continuity: Dict[str, str], verification_point: str) -> str: + """Aggregate the five predicates into a single sar_verdict. + + Rules: + * Any FAIL -> FAIL. + * Otherwise, executor_continuity == INDETERMINATE is *expected* at + pre-delivery seams and is not, by itself, verdict-blocking (this is why + a gate-mode payment_verified_pre_delivery receipt can be PASS while + executor_continuity is INDETERMINATE). + * Any remaining INDETERMINATE -> INDETERMINATE. + * Else PASS. + """ + values = dict(continuity) + if any(v == FAIL for v in values.values()): + return FAIL + + ignorable = set() + if verification_point in PRE_DELIVERY_POINTS: + ignorable.add("executor_continuity") + + for name, value in values.items(): + if value == INDETERMINATE and name not in ignorable: + return INDETERMINATE + return PASS diff --git a/sar402_verify_tmp/sar402/schema.py b/sar402_verify_tmp/sar402/schema.py new file mode 100644 index 0000000..f6dd83f --- /dev/null +++ b/sar402_verify_tmp/sar402/schema.py @@ -0,0 +1,243 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/schema.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Schema loading and structural validation for SAR-402. + +The committed schema is authoritative: + knowledge-assets/profiles/sar-402/schema/sar-402-settlement-v0.1.schema.json + +Validation backend selection: + 1. If `jsonschema` exposes a Draft 2020-12 validator, use it (authoritative). + 2. Otherwise fall back to a local structural validator that interprets the + subset of JSON Schema this document uses (type, const, enum, required, + properties, additionalProperties:false, pattern, $ref, allOf/if/then, + not/const, items, minimum). + +The fallback is NOT a silent skip: it actively enforces the same constraints. +`active_backend()` reports which one ran so callers and reports can be explicit +about coverage. +""" + +from __future__ import annotations + +import json +import re +from typing import List, Optional + +from . import constants + +# --------------------------------------------------------------------------- +# Schema loading +# --------------------------------------------------------------------------- + +_SCHEMA_CACHE: Optional[dict] = None + + +def load_schema() -> dict: + """Load and cache the committed SAR-402 schema.""" + global _SCHEMA_CACHE + if _SCHEMA_CACHE is None: + with open(constants.SCHEMA_PATH, "r", encoding="utf-8") as handle: + _SCHEMA_CACHE = json.load(handle) + _assert_constants_match_schema(_SCHEMA_CACHE) + return _SCHEMA_CACHE + + +def _assert_constants_match_schema(schema: dict) -> None: + """Defensive: keep constants.py from drifting away from the committed schema.""" + props = schema["properties"] + expectations = { + "schema_id const": (props["schema_id"]["const"], constants.SCHEMA_ID), + "profile const": (props["profile"]["const"], constants.PROFILE), + "sar_type const": (props["sar_type"]["const"], constants.SAR_TYPE), + "verification_point enum": ( + tuple(props["verification_point"]["enum"]), + constants.VERIFICATION_POINTS, + ), + "verification_mode enum": ( + tuple(props["verification_mode"]["enum"]), + constants.VERIFICATION_MODES, + ), + "payment_state enum": ( + tuple(props["payment_state"]["enum"]), + constants.PAYMENT_STATES, + ), + "delivery_state enum": ( + tuple(props["delivery_state"]["enum"]), + constants.DELIVERY_STATES, + ), + "settlement_state enum": ( + tuple(props["settlement_state"]["enum"]), + constants.SETTLEMENT_STATES, + ), + "continuity predicates": ( + tuple(schema["$defs"]["continuity"]["required"]), + constants.CONTINUITY_PREDICATES, + ), + "verdict enum": ( + tuple(schema["$defs"]["verdict"]["enum"]), + constants.VERDICTS, + ), + } + for label, (in_schema, in_constants) in expectations.items(): + if in_schema != in_constants: + raise RuntimeError( + f"SAR-402 constants drifted from committed schema ({label}): " + f"schema={in_schema!r} constants={in_constants!r}" + ) + + +# --------------------------------------------------------------------------- +# Backend selection +# --------------------------------------------------------------------------- + +def _jsonschema_2020_validator(): + """Return a jsonschema Draft 2020-12 validator class, or None if unavailable.""" + try: + from jsonschema import Draft202012Validator # type: ignore + except Exception: + return None + return Draft202012Validator + + +def active_backend() -> str: + return "jsonschema-draft2020-12" if _jsonschema_2020_validator() else "local-structural" + + +# --------------------------------------------------------------------------- +# Public structural validation entry point +# --------------------------------------------------------------------------- + +def schema_errors(instance: dict) -> List[str]: + """Return a list of structural schema violations (empty list == valid).""" + schema = load_schema() + validator_cls = _jsonschema_2020_validator() + if validator_cls is not None: + validator = validator_cls(schema) + return [ + f"{'/'.join(str(p) for p in err.path) or ''}: {err.message}" + for err in sorted(validator.iter_errors(instance), key=lambda e: list(e.path)) + ] + errors: List[str] = [] + _LocalValidator(schema).validate(schema, instance, "", errors) + return errors + + +# --------------------------------------------------------------------------- +# Local structural validator (JSON Schema subset interpreter) +# --------------------------------------------------------------------------- + +_TYPE_CHECKS = { + "object": lambda v: isinstance(v, dict), + "array": lambda v: isinstance(v, list), + "string": lambda v: isinstance(v, str), + "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), + "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), + "boolean": lambda v: isinstance(v, bool), + "null": lambda v: v is None, +} + + +class _LocalValidator: + """Interprets the subset of JSON Schema used by the SAR-402 schema.""" + + def __init__(self, root: dict): + self.root = root + + def _resolve(self, node: dict) -> dict: + if "$ref" in node: + ref = node["$ref"] + if not ref.startswith("#/"): + raise RuntimeError(f"unsupported $ref: {ref}") + target = self.root + for part in ref[2:].split("/"): + target = target[part] + merged = dict(target) + for key, value in node.items(): + if key != "$ref": + merged[key] = value + return merged + return node + + def matches(self, node: dict, instance) -> bool: + probe: List[str] = [] + self.validate(node, instance, "", probe) + return not probe + + def validate(self, node: dict, instance, path: str, errors: List[str]) -> None: + node = self._resolve(node) + + if "const" in node: + if instance != node["const"]: + errors.append(f"{path}: expected const {node['const']!r}, got {instance!r}") + + if "enum" in node: + if instance not in node["enum"]: + errors.append(f"{path}: {instance!r} not in enum {node['enum']!r}") + + if "type" in node: + checker = _TYPE_CHECKS.get(node["type"]) + if checker and not checker(instance): + errors.append(f"{path}: expected type {node['type']}, got {type(instance).__name__}") + # If the basic type is wrong, deeper checks are noise. + return + + if "not" in node: + sub = self._resolve(node["not"]) + if self.matches(sub, instance): + errors.append(f"{path}: value {instance!r} is forbidden by 'not'") + + if "pattern" in node and isinstance(instance, str): + if re.search(node["pattern"], instance) is None: + errors.append(f"{path}: {instance!r} does not match pattern {node['pattern']!r}") + + if "minimum" in node and isinstance(instance, (int, float)) and not isinstance(instance, bool): + if instance < node["minimum"]: + errors.append(f"{path}: {instance} < minimum {node['minimum']}") + + if isinstance(instance, dict): + self._validate_object(node, instance, path, errors) + + if isinstance(instance, list) and "items" in node: + for idx, item in enumerate(instance): + self.validate(node["items"], item, f"{path}[{idx}]", errors) + + for sub in node.get("allOf", []): + self._validate_conditional(sub, instance, path, errors) + + def _validate_object(self, node: dict, instance: dict, path: str, errors: List[str]) -> None: + properties = node.get("properties", {}) + for required in node.get("required", []): + if required not in instance: + errors.append(f"{path}: missing required property '{required}'") + + if node.get("additionalProperties", True) is False: + allowed = set(properties) + for key in instance: + if key not in allowed: + errors.append(f"{path}: additional property '{key}' is not allowed") + + for key, value in instance.items(): + if key in properties: + self.validate(properties[key], value, f"{path}/{key}", errors) + + def _validate_conditional(self, sub: dict, instance, path: str, errors: List[str]) -> None: + sub = self._resolve(sub) + if "if" in sub: + if self.matches(sub["if"], instance): + if "then" in sub: + self.validate(sub["then"], instance, path, errors) + elif "else" in sub: + self.validate(sub["else"], instance, path, errors) + else: + self.validate(sub, instance, path, errors) diff --git a/sar402_verify_tmp/sar402/schema_data/sar-402-settlement-v0.1.schema.json b/sar402_verify_tmp/sar402/schema_data/sar-402-settlement-v0.1.schema.json new file mode 100644 index 0000000..8c0b0c0 --- /dev/null +++ b/sar402_verify_tmp/sar402/schema_data/sar-402-settlement-v0.1.schema.json @@ -0,0 +1,318 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://defaultverifier.local/schemas/profiles/sar-402/sar-402-settlement-v0.1.schema.json", + "title": "SAR-402 Settlement Attestation Receipt v0.1", + "description": "x402-specific profile of the SAR primitive. Attests what an x402 payment authorized, whether delivery matched, what evidence remains, which trust seam was verified, how the verifier result was used, who could act on it, and whether the five Continuity predicates held. Subordinate to verification-timeline-seams-v0.2. The verifier never holds execution authority.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_id", + "profile", + "sar_type", + "sar_verdict", + "verification_point", + "verification_mode", + "authority_binding", + "payment_state", + "delivery_state", + "settlement_state", + "continuity", + "payment", + "identity", + "timestamps", + "issuer", + "integrity" + ], + "properties": { + "schema_id": { "const": "sar_402_settlement_v0.1" }, + "profile": { "const": "sar-402" }, + "sar_type": { "const": "Settlement Attestation Receipt" }, + + "sar_verdict": { "$ref": "#/$defs/verdict" }, + + "verification_point": { + "type": "string", + "enum": [ + "pre_authorization", + "payment_verified_pre_delivery", + "post_delivery", + "post_settlement_audit" + ] + }, + "verification_mode": { + "type": "string", + "enum": ["observe", "gate", "record", "audit"] + }, + + "authority_binding": { "$ref": "#/$defs/authority_binding" }, + + "payment_state": { + "type": "string", + "enum": ["verified", "unverified", "failed", "indeterminate"] + }, + "delivery_state": { + "type": "string", + "enum": ["confirmed", "claimed", "failed", "not_applicable", "indeterminate"] + }, + "settlement_state": { + "type": "string", + "enum": ["delivered", "not_delivered", "pending", "unverified", "indeterminate"] + }, + + "continuity": { "$ref": "#/$defs/continuity" }, + + "payment": { "$ref": "#/$defs/payment" }, + "delivery": { "$ref": "#/$defs/delivery" }, + + "identity": { "$ref": "#/$defs/identity" }, + + "timestamps": { "$ref": "#/$defs/timestamps" }, + "issuer": { "$ref": "#/$defs/issuer" }, + "integrity": { "$ref": "#/$defs/integrity" }, + + "quote_raw": { "type": "object" }, + "challenge_raw": { "type": "object" }, + "escalation": { "type": "object" }, + "links": { + "type": "array", + "items": { "type": "string" } + }, + "prior_sar_digest": { "type": "string" }, + "notes": { "type": "string" } + }, + + "allOf": [ + { + "$comment": "Gate mode requires explicit gate controller and release policy.", + "if": { + "properties": { "verification_mode": { "const": "gate" } } + }, + "then": { + "properties": { + "authority_binding": { + "required": ["gate_controller", "release_policy", "verifier_has_execution_authority"] + } + } + } + }, + { + "$comment": "Delivery evidence required once something has been (or should have been) delivered.", + "if": { + "properties": { + "verification_point": { + "enum": ["post_delivery", "post_settlement_audit"] + } + } + }, + "then": { "required": ["delivery"] } + } + ], + + "$defs": { + "verdict": { + "type": "string", + "enum": ["PASS", "FAIL", "INDETERMINATE"] + }, + + "authority_binding": { + "type": "object", + "additionalProperties": false, + "required": ["verifier_has_execution_authority"], + "description": "Who or what, if anyone, was allowed to act on the verifier result. verifier_has_execution_authority must always be false. Gate mode requires gate_controller and release_policy.", + "properties": { + "acting_party": { + "type": "string", + "description": "Non-gate: the party that controlled the action (verifier had no authority)." + }, + "gate_controller": { + "type": "string", + "description": "Gate: the consuming system that controlled release. Never default_verifier.", + "not": { "const": "default_verifier" } + }, + "release_policy": { + "type": "string", + "description": "Gate: explicit policy mapping verdicts to release/withhold/escalate." + }, + "verifier_has_execution_authority": { + "const": false + } + } + }, + + "continuity": { + "type": "object", + "additionalProperties": false, + "required": [ + "object_continuity", + "constraint_continuity", + "temporal_continuity", + "authority_continuity", + "executor_continuity" + ], + "properties": { + "object_continuity": { "$ref": "#/$defs/verdict" }, + "constraint_continuity": { "$ref": "#/$defs/verdict" }, + "temporal_continuity": { "$ref": "#/$defs/verdict" }, + "authority_continuity": { "$ref": "#/$defs/verdict" }, + "executor_continuity": { "$ref": "#/$defs/verdict" } + } + }, + + "amount": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "asset", "decimals"], + "properties": { + "amount": { + "type": "string", + "description": "Integer amount as a string to avoid float drift.", + "pattern": "^[0-9]+$" + }, + "asset": { "type": "string" }, + "decimals": { "type": "integer", "minimum": 0 } + } + }, + + "payment": { + "type": "object", + "additionalProperties": false, + "required": ["resource", "quote_id", "price", "asset", "chain", "recipient", "payer", "payment_ref"], + "properties": { + "resource": { + "type": "string", + "description": "Resource/action paid for. object_continuity anchor." + }, + "quote_id": { "type": "string", "description": "402 quote/challenge id." }, + "price": { "$ref": "#/$defs/amount" }, + "amount_paid": { "$ref": "#/$defs/amount" }, + "asset": { "type": "string" }, + "chain": { + "type": "string", + "description": "Chain-agnostic identifier (CAIP-2 style, e.g. eip155:8453). Not Base-only.", + "pattern": "^[-a-z0-9]+:[-a-zA-Z0-9]+$" + }, + "recipient": { "type": "string" }, + "payer": { "type": "string" }, + "payment_ref": { + "type": "string", + "description": "Settlement/transaction reference (tx hash, settlement id)." + }, + "facilitator": { "type": "string" } + } + }, + + "delivery": { + "type": "object", + "additionalProperties": false, + "required": ["delivered_resource", "evidence_type"], + "properties": { + "delivered_resource": { + "type": "string", + "description": "What was actually delivered. Compared to payment.resource for object_continuity." + }, + "evidence_type": { + "type": "string", + "description": "e.g. http_response, signed_artifact, content_hash, tool_result." + }, + "evidence_digest": { + "type": "string", + "description": "Digest of the delivered artifact for replay." + }, + "status_code": { "type": "integer" }, + "delivered_at": { "type": "string", "format": "date-time" } + } + }, + + "identity": { + "type": "object", + "additionalProperties": false, + "required": ["payer"], + "properties": { + "payer": { + "type": "string", + "description": "Wallet/account that paid." + }, + "agent": { + "type": "string", + "description": "Delegated agent identifier, if distinct from the wallet." + }, + "wallet": { "type": "string" }, + "derived_identity": { "$ref": "#/$defs/derived_identity" } + } + }, + + "derived_identity": { + "type": "object", + "additionalProperties": false, + "required": ["registration_mode", "derived_agent_id", "identity_status"], + "properties": { + "registration_mode": { "const": "derived_from_settlement" }, + "derived_agent_id": { + "type": "string", + "description": "Deterministic from settlement, e.g. agent:x402::.", + "pattern": "^agent:x402:[-a-z0-9]+:[-a-zA-Z0-9]+:.+$" + }, + "identity_status": { + "type": "string", + "enum": ["derived", "claimed", "verified", "linked"] + } + } + }, + + "timestamps": { + "type": "object", + "additionalProperties": false, + "required": ["quoted_at", "verified_at", "issued_at"], + "properties": { + "quoted_at": { "type": "string", "format": "date-time" }, + "paid_at": { "type": "string", "format": "date-time" }, + "verified_at": { "type": "string", "format": "date-time" }, + "delivered_at": { "type": "string", "format": "date-time" }, + "issued_at": { "type": "string", "format": "date-time" }, + "quote_expires_at": { "type": "string", "format": "date-time" } + } + }, + + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["verifier", "verifier_version"], + "properties": { + "verifier": { + "type": "string", + "description": "Verification engine identity, e.g. DefaultVerifier." + }, + "verifier_version": { "type": "string" }, + "environment": { + "type": "string", + "enum": ["production", "staging", "test", "local"] + } + } + }, + + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["digest_alg", "digest"], + "description": "Digest over the canonical receipt excluding the integrity block. Signature optional in v0.1.", + "properties": { + "digest_alg": { "type": "string", "enum": ["sha256", "sha512"] }, + "digest": { "type": "string" }, + "canonicalization": { + "type": "string", + "description": "e.g. jcs (RFC 8785)." + }, + "signature": { + "type": "object", + "additionalProperties": false, + "properties": { + "alg": { "type": "string" }, + "key_id": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["alg", "value"] + } + } + } + } +} diff --git a/sar402_verify_tmp/sar402/validate.py b/sar402_verify_tmp/sar402/validate.py new file mode 100644 index 0000000..dea385a --- /dev/null +++ b/sar402_verify_tmp/sar402/validate.py @@ -0,0 +1,169 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 73bc7529929fdc00e0fdf09f5463338e34fc519d +# original source file path : sar402_reference/sar402/validate.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""SAR-402 validation: schema + authority boundary + continuity semantics. + +`validate_receipt` is the entry point a future SAR-402 agent calls. It layers +three checks that together preserve the governing architecture in code: + + 1. Structural schema validation (schema.py; jsonschema or local fallback). + 2. Authority-boundary guard (this module). Broader than the schema's single + `default_verifier` denylist: a normalized forbidden-identity check that + rejects any gate controller implying DefaultVerifier, Default Settlement, + Morpheus, SettlementWitness, or this SAR-402 implementation itself, and + enforces verifier_has_execution_authority == false everywhere. + 3. Continuity-semantics guard: executor_continuity cannot be PASS at a + pre-delivery seam without delivery evidence. + +The verifier never holds execution authority. A PASS does not release anything. +""" + +from __future__ import annotations + +import json +import re +from typing import List + +from . import constants +from .schema import active_backend, schema_errors + + +class SAR402ValidationError(ValueError): + """A receipt failed SAR-402 validation.""" + + def __init__(self, errors): + if isinstance(errors, str): + errors = [errors] + self.errors = list(errors) + super().__init__("; ".join(self.errors)) + + +class AuthorityBoundaryError(SAR402ValidationError): + """A receipt violated the SAR-402 authority boundary.""" + + +# --------------------------------------------------------------------------- +# Authority-boundary guard +# --------------------------------------------------------------------------- + +def _normalize_identity(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def is_forbidden_gate_controller(value) -> bool: + """True if `value` names or implies an identity that must never be the gate + controller (the verifier / trust system / this node / witness / this + implementation). Normalized substring match, not a single literal denylist.""" + if not isinstance(value, str) or not value.strip(): + # An empty / non-string gate controller is not a *forbidden identity* + # here; the missing-field case is handled by required-field checks. + return False + normalized = _normalize_identity(value) + return any(token in normalized for token in constants.FORBIDDEN_GATE_CONTROLLER_TOKENS) + + +def authority_boundary_errors(receipt: dict) -> List[str]: + errors: List[str] = [] + binding = receipt.get("authority_binding") + if not isinstance(binding, dict): + errors.append("authority_binding: missing or not an object") + return errors + + if binding.get("verifier_has_execution_authority") is not False: + errors.append( + "authority_binding.verifier_has_execution_authority must be exactly false " + "(the verifier never holds execution authority)" + ) + + if receipt.get("verification_mode") == "gate": + controller = binding.get("gate_controller") + if not controller: + errors.append("authority_binding.gate_controller is required in gate mode") + elif is_forbidden_gate_controller(controller): + errors.append( + f"authority_binding.gate_controller {controller!r} implies a forbidden " + "identity (verifier / Default Settlement / Morpheus / SettlementWitness / " + "SAR-402 implementation) and cannot hold release authority" + ) + if not binding.get("release_policy"): + errors.append("authority_binding.release_policy is required in gate mode") + + return errors + + +# --------------------------------------------------------------------------- +# Continuity-semantics guard +# --------------------------------------------------------------------------- + +def continuity_semantics_errors(receipt: dict) -> List[str]: + errors: List[str] = [] + continuity = receipt.get("continuity") + if not isinstance(continuity, dict): + return errors # structural validation reports the shape problem + point = receipt.get("verification_point") + has_delivery = isinstance(receipt.get("delivery"), dict) + if ( + point in constants.PRE_DELIVERY_POINTS + and not has_delivery + and continuity.get("executor_continuity") == constants.PASS + ): + errors.append( + "continuity.executor_continuity cannot be PASS at a pre-delivery seam " + f"({point}) without delivery evidence" + ) + return errors + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + +def iter_errors(receipt: dict) -> List[str]: + """Return all SAR-402 validation errors (schema + authority + semantics).""" + errors = list(schema_errors(receipt)) + errors.extend(authority_boundary_errors(receipt)) + errors.extend(continuity_semantics_errors(receipt)) + return errors + + +def is_valid(receipt: dict) -> bool: + return not iter_errors(receipt) + + +def validate_receipt(receipt: dict) -> dict: + """Validate a receipt; raise SAR402ValidationError on any violation. + + Authority-boundary violations are raised as AuthorityBoundaryError (a + subclass) when they are the cause, so callers can distinguish them.""" + schema_errs = schema_errors(receipt) + authority_errs = authority_boundary_errors(receipt) + semantic_errs = continuity_semantics_errors(receipt) + all_errs = schema_errs + authority_errs + semantic_errs + if not all_errs: + return receipt + + # Authority-boundary classification takes precedence: if the receipt + # violates the authority boundary, raise the specific error type even when + # the schema backend independently flags the same field (e.g. the local + # validator also catches gate_controller=default_verifier). + if authority_errs: + raise AuthorityBoundaryError(all_errs) + raise SAR402ValidationError(all_errs) + + +def validate_fixture(path) -> dict: + """Load a fixture file and validate it. Returns the parsed receipt.""" + with open(path, "r", encoding="utf-8") as handle: + receipt = json.load(handle) + validate_receipt(receipt) + return receipt diff --git a/sar402_verify_tmp/sar402_agent/__init__.py b/sar402_verify_tmp/sar402_agent/__init__.py new file mode 100644 index 0000000..67587c4 --- /dev/null +++ b/sar402_verify_tmp/sar402_agent/__init__.py @@ -0,0 +1,66 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0 +# original source file path : sar402_reference/sar402_agent/__init__.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# +# TRIMMED relative to the canonical __init__.py: `storage.preserve_run` +# (file-persistence logic, never invoked by the Mesh tool) and +# `normalize_demo` (demo-endpoint ingestion, not used by the manual-evidence +# Mesh tool contract) are intentionally NOT imported or re-exported here. +# `runner` here is the trimmed local copy (see runner.py's own provenance +# header for its own exclusion list). See +# sar402_verify_tmp/EXCLUDED.md for the full exclusion list and rationale. +# ----------------------------------------------------------------------------- +"""SAR-402 evidence-ingestion agent layer -- local, network-free, NON-CANONICAL +temporary copy. + +This is a disposable, unpublished, local copy of the minimum evidence +normalization + receipt-construction-dispatch surface needed by a Heurist +Mesh candidate tool. It does NOT reinvent receipt construction, define a new +schema, or change the governing SAR-402 architecture. The canonical +implementation is the SAR-402 reference implementation's `sar402_agent/` package. + +Authority boundary (non-negotiable, inherited from the canonical package): +the verifier never holds execution authority. In gate mode the named +external gate_controller -- never DefaultVerifier, Default Settlement, +Morpheus, SettlementWitness, or this copy -- decides release under its own +policy. Verification is never execution. +""" + +from __future__ import annotations + +from .evidence import ( + AuthorityViolationError, + EvidenceError, + EvidenceValidationError, + GATE_MODE, + NormalizedEvidence, + RECORD_MODE, + SUPPORTED_MODES, +) +from .normalizer import normalize_manual +from .runner import build_receipt + +__all__ = [ + # errors + "EvidenceError", + "EvidenceValidationError", + "AuthorityViolationError", + # model / modes + "NormalizedEvidence", + "RECORD_MODE", + "GATE_MODE", + "SUPPORTED_MODES", + # normalizers + "normalize_manual", + # runner + "build_receipt", +] diff --git a/sar402_verify_tmp/sar402_agent/evidence.py b/sar402_verify_tmp/sar402_agent/evidence.py new file mode 100644 index 0000000..f0893aa --- /dev/null +++ b/sar402_verify_tmp/sar402_agent/evidence.py @@ -0,0 +1,122 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0 +# original source file path : sar402_reference/sar402_agent/evidence.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Agent-facing normalized evidence model for SAR-402 ingestion. + +This is the *single internal model* that every ingestion source (manual JSON, +the controlled demo-endpoint shape, and any future source) normalizes into +before the receipt is built. It is deliberately a thin, explicit container: + + * a `mode` ("record" | "gate") naming the seam/builder to use, + * a fully-built `sar402_verify_tmp.sar402.SettlementEvidence` (the authoritative + normalized input the committed builder/validator already understand), + * gate-mode authority parameters (gate_controller / release_policy) when and + only when mode == "gate", + * optional record-mode acting_party for clarity. + +The ingestion layer never reinvents receipt construction and never defines a new +schema. It collects/normalizes evidence and hands a `SettlementEvidence` to the +committed `sar402_verify_tmp.sar402` builder, which self-validates against the committed +schema. This module only owns the *agent-facing* normalized shape and the +authority pre-checks that let invalid evidence be rejected cleanly before the +builder is ever called. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from sar402_verify_tmp.sar402 import SettlementEvidence +from sar402_verify_tmp.sar402.validate import is_forbidden_gate_controller + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class EvidenceError(ValueError): + """Base class for ingestion-layer evidence errors.""" + + +class EvidenceValidationError(EvidenceError): + """Evidence was missing, malformed, or insufficient to build a receipt.""" + + +class AuthorityViolationError(EvidenceError): + """Evidence attempted to violate a SAR-402 authority boundary. + + Raised, for example, when the input asserts the verifier holds execution + authority, or names a forbidden gate controller (the verifier / Default + Settlement / Morpheus / SettlementWitness / this SAR-402 implementation).""" + + +# Recognized ingestion modes -> the seam each one targets. +RECORD_MODE = "record" +GATE_MODE = "gate" +SUPPORTED_MODES = (RECORD_MODE, GATE_MODE) + + +@dataclass +class NormalizedEvidence: + """The internal normalized evidence model produced by every normalizer. + + `settlement` is the authoritative `SettlementEvidence` the committed builder + consumes. `mode` selects which committed builder to call. Gate parameters are + present only for gate mode.""" + + mode: str + settlement: SettlementEvidence + source_kind: str = "manual" + # record-mode clarity (who actually controlled the action). Never authority. + acting_party: str = "resource_server" + # gate-mode authority binding inputs. + gate_controller: Optional[str] = None + release_policy: Optional[str] = None + + def __post_init__(self) -> None: + if self.mode not in SUPPORTED_MODES: + raise EvidenceValidationError( + f"unsupported mode {self.mode!r}; expected one of {SUPPORTED_MODES}" + ) + if self.mode == RECORD_MODE and self.settlement.delivery is None: + # Post-delivery record mode is meaningless without delivery evidence. + raise EvidenceValidationError( + "record mode is post-delivery and requires delivery evidence" + ) + if self.mode == GATE_MODE: + if not self.gate_controller: + raise EvidenceValidationError( + "gate mode requires a gate_controller (the external system " + "that controls release); the verifier never controls release" + ) + if is_forbidden_gate_controller(self.gate_controller): + raise AuthorityViolationError( + f"gate_controller {self.gate_controller!r} implies a forbidden " + "identity (verifier / Default Settlement / Morpheus / " + "SettlementWitness / SAR-402 implementation) and cannot hold " + "release authority" + ) + if not self.release_policy: + raise EvidenceValidationError( + "gate mode requires an explicit release_policy" + ) + # Gate seam (payment_verified_pre_delivery) does not permit delivery + # evidence: it must not be used to force executor_continuity to PASS + # before anything has been delivered. + if self.settlement.delivery is not None: + raise AuthorityViolationError( + "gate-mode (pre-delivery) evidence must not carry delivery " + "evidence; executor_continuity stays INDETERMINATE until a " + "later post-delivery receipt resolves it" + ) diff --git a/sar402_verify_tmp/sar402_agent/normalizer.py b/sar402_verify_tmp/sar402_agent/normalizer.py new file mode 100644 index 0000000..8296b20 --- /dev/null +++ b/sar402_verify_tmp/sar402_agent/normalizer.py @@ -0,0 +1,318 @@ +# --- PROVENANCE (do not remove) -------------------------------------------- +# canonical source repository : SAR-402 reference implementation +# exact source commit SHA : 84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0 +# original source file path : sar402_reference/sar402_agent/normalizer.py +# date copied : 2026-07-31 +# scope : verification-only Heurist adapter support +# status : NON-CANONICAL TEMPORARY COPY +# Canonical logic remains in the SAR-402 reference implementation at the path/commit above. Shared-core +# packaging was deferred pending actual Heurist interest or review -- this is +# a disposable local copy, not a package release. Future maintenance MUST +# diff this file against the recorded reference-implementation source commit before +# modification or any submission. +# ----------------------------------------------------------------------------- +"""Normalizers: ingestion JSON -> internal `NormalizedEvidence`. + +Two ingestion shapes are supported in this pass: + + * Option A — the manual / fixture-driven shape (`normalize_manual`): a + hand-constructed JSON evidence object describing an x402 payment + delivery + event in agent-friendly field names. + * Option B — the controlled demo-endpoint shape (`normalize_demo`): a fixed + local shape for a future `/pay/url-summary`-style demo endpoint, using the + endpoint's own field names. It normalizes into the *same* internal model. + +Both produce a `NormalizedEvidence` wrapping a committed +`sar402_verify_tmp.sar402.SettlementEvidence`. Neither calls a network or a chain; both +operate purely on a local dict. Invalid evidence and authority-boundary +violations are rejected here (cleanly) before the committed builder is invoked. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from sar402_verify_tmp.sar402 import Amount, DeliveryEvidence, SettlementEvidence + +from .evidence import ( + GATE_MODE, + RECORD_MODE, + SUPPORTED_MODES, + AuthorityViolationError, + EvidenceValidationError, + NormalizedEvidence, +) + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + +def _require_mapping(obj: Any, where: str) -> Mapping: + if not isinstance(obj, Mapping): + raise EvidenceValidationError(f"{where}: expected an object, got {type(obj).__name__}") + return obj + + +def _require(obj: Mapping, key: str, where: str) -> Any: + if key not in obj or obj[key] in (None, ""): + raise EvidenceValidationError(f"{where}: missing required field {key!r}") + return obj[key] + + +def _amount(obj: Any, where: str) -> Amount: + obj = _require_mapping(obj, where) + return Amount( + amount=str(_require(obj, "amount", where)), + asset=str(_require(obj, "asset", where)), + decimals=int(_require(obj, "decimals", where)), + ) + + +def _check_authority_block(authority: Mapping, *, where: str) -> None: + """Reject an authority block that asserts verifier execution authority.""" + vhea = authority.get("verifier_has_execution_authority") + if vhea is not None and vhea is not False: + raise AuthorityViolationError( + f"{where}.verifier_has_execution_authority must be false (or omitted); " + "the verifier never holds execution authority" + ) + + +# --------------------------------------------------------------------------- +# Option A — manual / fixture-driven shape +# --------------------------------------------------------------------------- + +def normalize_manual(doc: Any) -> NormalizedEvidence: + """Normalize a manual evidence JSON object into `NormalizedEvidence`. + + Expected shape (record mode adds `delivery`; gate mode adds `authority`): + + { + "mode": "record" | "gate", + "payment": { resource, quote_id, price{amount,asset,decimals}, + amount_paid{...}?, asset, chain, recipient, payer, + payment_ref, facilitator? }, + "identity": { agent?, wallet?, authorized_payers?[] }, + "timestamps": { quoted_at, paid_at?, verified_at, delivered_at?, + issued_at, quote_expires_at? }, + "delivery": { delivered_resource, evidence_type, evidence_digest?, + status_code?, delivered_at?, failed? }, # record mode + "authority": { gate_controller, release_policy?, + acting_party?, verifier_has_execution_authority? } + } + """ + doc = _require_mapping(doc, "evidence") + mode = str(_require(doc, "mode", "evidence")).strip().lower() + if mode not in SUPPORTED_MODES: + raise EvidenceValidationError( + f"evidence.mode {mode!r} unsupported; expected one of {SUPPORTED_MODES}" + ) + + payment = _require_mapping(_require(doc, "payment", "evidence"), "payment") + identity = doc.get("identity") or {} + identity = _require_mapping(identity, "identity") + timestamps = _require_mapping(_require(doc, "timestamps", "evidence"), "timestamps") + authority = doc.get("authority") or {} + authority = _require_mapping(authority, "authority") + _check_authority_block(authority, where="authority") + + price = _amount(_require(payment, "price", "payment"), "payment.price") + amount_paid = ( + _amount(payment["amount_paid"], "payment.amount_paid") + if payment.get("amount_paid") is not None + else None + ) + + delivery = _build_delivery(doc.get("delivery"), where="delivery") + + settlement = SettlementEvidence( + resource=str(_require(payment, "resource", "payment")), + quote_id=str(_require(payment, "quote_id", "payment")), + price=price, + asset=str(_require(payment, "asset", "payment")), + chain=str(_require(payment, "chain", "payment")), + recipient=str(_require(payment, "recipient", "payment")), + payer=str(_require(payment, "payer", "payment")), + payment_ref=str(_require(payment, "payment_ref", "payment")), + amount_paid=amount_paid, + settled_asset=payment.get("settled_asset"), + settled_chain=payment.get("settled_chain"), + settled_recipient=payment.get("settled_recipient"), + facilitator=payment.get("facilitator"), + agent=identity.get("agent"), + wallet=identity.get("wallet"), + authorized_payers=identity.get("authorized_payers"), + quoted_at=_require(timestamps, "quoted_at", "timestamps"), + paid_at=timestamps.get("paid_at"), + verified_at=_require(timestamps, "verified_at", "timestamps"), + delivered_at=timestamps.get("delivered_at"), + issued_at=_require(timestamps, "issued_at", "timestamps"), + quote_expires_at=timestamps.get("quote_expires_at"), + delivery=delivery, + ) + + return _assemble_normalized( + mode=mode, + settlement=settlement, + authority=authority, + source_kind="manual", + ) + + +# --------------------------------------------------------------------------- +# Option B — controlled demo-endpoint shape (/pay/url-summary style) +# --------------------------------------------------------------------------- + +def normalize_demo(doc: Any) -> NormalizedEvidence: + """Normalize a controlled demo-endpoint evidence object into the same model. + + This is the fixed local shape a future `/pay/url-summary`-style demo endpoint + would emit. It uses the endpoint's own field names (quote / payment / delivery + sub-objects) and is mapped here onto the identical internal model. No live + endpoint is called in this pass. + + { + "endpoint": "/pay/url-summary", + "mode": "record" | "gate", + "request": { "target_url": "..." }, + "x402": { + "quote": { id, resource_url, price{value,currency,decimals}, + pay_to, network, quoted_at, expires_at }, + "payment": { from, tx, paid{value,currency,decimals}?, paid_at, + verified_at, facilitator?, authorized_from?[] }, + "delivery":{ url, content_digest?, http_status?, served_at? }? # record + }, + "issuer_agent": "...", + "issued_at": "...", + "authority": { gate_controller, release_policy?, acting_party?, + verifier_has_execution_authority? } + } + """ + doc = _require_mapping(doc, "demo_evidence") + mode = str(_require(doc, "mode", "demo_evidence")).strip().lower() + if mode not in SUPPORTED_MODES: + raise EvidenceValidationError( + f"demo_evidence.mode {mode!r} unsupported; expected one of {SUPPORTED_MODES}" + ) + + x402 = _require_mapping(_require(doc, "x402", "demo_evidence"), "x402") + quote = _require_mapping(_require(x402, "quote", "x402"), "x402.quote") + payment = _require_mapping(_require(x402, "payment", "x402"), "x402.payment") + authority = doc.get("authority") or {} + authority = _require_mapping(authority, "authority") + _check_authority_block(authority, where="authority") + + price_obj = _require_mapping(_require(quote, "price", "x402.quote"), "x402.quote.price") + price = Amount( + amount=str(_require(price_obj, "value", "x402.quote.price")), + asset=str(_require(price_obj, "currency", "x402.quote.price")), + decimals=int(_require(price_obj, "decimals", "x402.quote.price")), + ) + paid_obj = payment.get("paid") + amount_paid: Optional[Amount] = None + if paid_obj is not None: + paid_obj = _require_mapping(paid_obj, "x402.payment.paid") + amount_paid = Amount( + amount=str(_require(paid_obj, "value", "x402.payment.paid")), + asset=str(_require(paid_obj, "currency", "x402.payment.paid")), + decimals=int(_require(paid_obj, "decimals", "x402.payment.paid")), + ) + + resource = str(_require(quote, "resource_url", "x402.quote")) + + delivery = None + delivery_doc = x402.get("delivery") + if delivery_doc is not None: + delivery_doc = _require_mapping(delivery_doc, "x402.delivery") + delivery = DeliveryEvidence( + delivered_resource=str(_require(delivery_doc, "url", "x402.delivery")), + evidence_type=str(delivery_doc.get("evidence_type", "http_response")), + evidence_digest=delivery_doc.get("content_digest"), + status_code=delivery_doc.get("http_status"), + delivered_at=delivery_doc.get("served_at"), + failed=bool(delivery_doc.get("failed", False)), + ) + + settlement = SettlementEvidence( + resource=resource, + quote_id=str(_require(quote, "id", "x402.quote")), + price=price, + asset=str(_require(price_obj, "currency", "x402.quote.price")), + chain=str(_require(quote, "network", "x402.quote")), + recipient=str(_require(quote, "pay_to", "x402.quote")), + payer=str(_require(payment, "from", "x402.payment")), + payment_ref=str(_require(payment, "tx", "x402.payment")), + amount_paid=amount_paid, + facilitator=payment.get("facilitator"), + agent=doc.get("issuer_agent"), + wallet=payment.get("from"), + authorized_payers=payment.get("authorized_from"), + quoted_at=_require(quote, "quoted_at", "x402.quote"), + paid_at=payment.get("paid_at"), + verified_at=_require(payment, "verified_at", "x402.payment"), + delivered_at=(delivery.delivered_at if delivery else None), + issued_at=_require(doc, "issued_at", "demo_evidence"), + quote_expires_at=quote.get("expires_at"), + delivery=delivery, + ) + + return _assemble_normalized( + mode=mode, + settlement=settlement, + authority=authority, + source_kind="demo_url_summary", + ) + + +# --------------------------------------------------------------------------- +# Shared assembly +# --------------------------------------------------------------------------- + +def _build_delivery(doc: Any, *, where: str) -> Optional[DeliveryEvidence]: + if doc is None: + return None + doc = _require_mapping(doc, where) + return DeliveryEvidence( + delivered_resource=str(_require(doc, "delivered_resource", where)), + evidence_type=str(_require(doc, "evidence_type", where)), + evidence_digest=doc.get("evidence_digest"), + status_code=doc.get("status_code"), + delivered_at=doc.get("delivered_at"), + failed=bool(doc.get("failed", False)), + ) + + +def _assemble_normalized( + *, + mode: str, + settlement: SettlementEvidence, + authority: Mapping, + source_kind: str, +) -> NormalizedEvidence: + acting_party = authority.get("acting_party") or "resource_server" + gate_controller = authority.get("gate_controller") if mode == GATE_MODE else None + release_policy = authority.get("release_policy") if mode == GATE_MODE else None + if mode == GATE_MODE and not release_policy: + # Provide the canonical default policy so gate evidence that names a + # controller but omits the policy string is still explicit, not blank. + release_policy = "release_on_PASS_escalate_on_INDETERMINATE_withhold_on_FAIL" + + # NormalizedEvidence.__post_init__ performs the authority / seam invariants. + return NormalizedEvidence( + mode=mode, + settlement=settlement, + source_kind=source_kind, + acting_party=acting_party, + gate_controller=gate_controller, + release_policy=release_policy, + ) + + +def normalize(doc: Any, *, source: str = "manual") -> NormalizedEvidence: + """Dispatch to the right normalizer by source ("manual" | "demo").""" + if source == "manual": + return normalize_manual(doc) + if source == "demo": + return normalize_demo(doc) + raise EvidenceValidationError(f"unknown evidence source {source!r}") diff --git a/sar402_verify_tmp/sar402_agent/runner.py b/sar402_verify_tmp/sar402_agent/runner.py new file mode 100644 index 0000000..179c5a7 --- /dev/null +++ b/sar402_verify_tmp/sar402_agent/runner.py @@ -0,0 +1,65 @@ +"""Receipt-construction dispatch: normalized evidence -> committed builder call. + +TRIMMED COPY NOTICE (read before modifying): + canonical source repository : SAR-402 reference implementation + exact source commit SHA : 84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0 + original source file path : sar402_reference/sar402_agent/runner.py + date copied : 2026-07-31 + scope : verification-only Heurist adapter support + status : NON-CANONICAL TEMPORARY COPY + canonical logic remains in the SAR-402 reference implementation at the path/commit above. + shared-core packaging (a real installable package) was deferred pending + actual Heurist interest or review -- this is a disposable local copy, not + a package release. + Future maintenance MUST diff this file against + morpheus/sar402_agent/runner.py @ 84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0 + before modification or any submission. + + This file is a DELIBERATELY TRIMMED subset of the canonical runner.py: it + keeps only `build_receipt` (the mode-dispatch call into the committed + builder), which is the single symbol the Heurist Mesh tool needs. The + canonical file's `run_evidence_doc`, `run_evidence_file`, and CLI `main` + are CLI/local-persistence entry points that depend on + `sar402_agent.storage.preserve_run` (file-persistence logic never invoked + by the Mesh tool) and are intentionally NOT copied here. See + `PROVENANCE_EXCLUDED_SYMBOLS` below. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sar402_verify_tmp.sar402 import build_gate_mode_receipt, build_record_mode_receipt + +from .evidence import EvidenceError, GATE_MODE, RECORD_MODE + +if TYPE_CHECKING: + from .evidence import NormalizedEvidence + +PROVENANCE_SOURCE_COMMIT = "84d9060b57bf81bae8ebaf0b3ec438ab6f7732a0" +PROVENANCE_SOURCE_PATH = "morpheus/sar402_agent/runner.py" +PROVENANCE_EXCLUDED_SYMBOLS = ( + "run_evidence_doc", # depends on storage.preserve_run; not needed by the Mesh tool + "run_evidence_file", # CLI-only entry point; not needed by the Mesh tool + "main", # CLI-only entry point; not needed by the Mesh tool + "RunResult", # dataclass only used by the excluded CLI/run_* entry points +) + + +def build_receipt(normalized: "NormalizedEvidence") -> dict: + """Call the committed builder for the normalized evidence's mode. + + The builder self-validates; we do not bypass or duplicate it.""" + if normalized.mode == RECORD_MODE: + return build_record_mode_receipt( + normalized.settlement, + acting_party=normalized.acting_party, + ) + if normalized.mode == GATE_MODE: + return build_gate_mode_receipt( + normalized.settlement, + gate_controller=normalized.gate_controller, + release_policy=normalized.release_policy, + ) + # NormalizedEvidence already guards mode, but be explicit. + raise EvidenceError(f"unsupported mode {normalized.mode!r}")