From 7e18a2ee52185c2447ed83f6f032addc2bf0b573 Mon Sep 17 00:00:00 2001 From: Kevin Morrison <16977371+nccrypto@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:05:52 -0400 Subject: [PATCH] add virtuals-acp-evidence --- CHANGELOG.md | 1 + DESIGN.md | 4 + README.md | 7 +- ROADMAP.md | 10 +- docs/README.md | 1 + docs/virtuals-acp-evidence-service.md | 137 +++ examples/README.md | 1 + examples/virtuals-acp-evidence/README.md | 31 + .../offering-v1.example.json | 49 ++ .../receipt-v1.example.json | 127 +++ .../request-v1.example.json | 137 +++ schemas/README.md | 2 + schemas/acp-evidence-request-v1.schema.json | 37 + src/agentic_commerce/acp_evidence.py | 778 ++++++++++++++++++ src/agentic_commerce/cli.py | 62 +- tests/test_acp_evidence_service.py | 222 +++++ tests/test_schema_contract.py | 19 + 17 files changed, 1613 insertions(+), 12 deletions(-) create mode 100644 docs/virtuals-acp-evidence-service.md create mode 100644 examples/virtuals-acp-evidence/README.md create mode 100644 examples/virtuals-acp-evidence/offering-v1.example.json create mode 100644 examples/virtuals-acp-evidence/receipt-v1.example.json create mode 100644 examples/virtuals-acp-evidence/request-v1.example.json create mode 100644 schemas/acp-evidence-request-v1.schema.json create mode 100644 src/agentic_commerce/acp_evidence.py create mode 100644 tests/test_acp_evidence_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a225144..41ddc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Add a bounded agent-job result schema, documentation, and synthetic conforming example linked to source-manifest provenance. - Add backward-compatible cost, timeout, and freshness metadata to agent-job results. - Add a default-deny action-control schema, deterministic evaluator, documentation, and synthetic dry-run and authorized-action examples. +- Add a deterministic local Virtuals ACP public-evidence verification service with a bounded request schema, conforming job-result receipts, hidden offering metadata, timeout/retry behavior, and synthetic buyer/provider lifecycle fixtures. ### Security diff --git a/DESIGN.md b/DESIGN.md index d3b0914..36874be 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,3 +88,7 @@ Cost, timeout, and freshness are optional, strictly bounded objects in the agent ### D10. Separate authorization from execution and default to denial The action-control evaluator computes a stable digest over a bounded public action and emits only a dry-run, authorized, or rejected decision. It never executes an action. Execute requests require an unexpired approval whose action identifier, type, and digest match exactly; missing, malformed, expired, rejected, future-issued, or mismatched approvals deny execution. A separate adapter must re-check authorization at its execution boundary. + +### D11. Use public-evidence verification as the first Virtuals ACP job + +The first ACP reference provider verifies a source manifest and agent-job result, checks their provenance linkage and public HTTPS locations, and returns the existing agent-job result contract as its receipt. This fills a portable checker gap without duplicating the official CLI, SDK, or on-chain evaluator showcases. Local mode models the documented buyer/provider lifecycle with zero measured cost and bounded timeout/retries, but performs no authentication, wallet, payment, signing, inference, network, or live ACP operation. A future live adapter remains separate and approval-gated. diff --git a/README.md b/README.md index acdcdf8..ab36f4e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Open-source tools, adapters, schemas, and operational patterns for agent-native commerce, starting with Reppo and Virtuals ACP. -> **Status:** v0.1.0 — read-only Reppo ecosystem inspector. +> **Status:** v0.1.0 released; the deterministic local Virtuals ACP reference service is currently unreleased. ## Mission @@ -65,6 +65,9 @@ agentic-commerce reppo status --pretty agentic-commerce reppo datanets --limit 20 agentic-commerce reppo pods --limit 20 agentic-commerce reppo snapshot --limit 10 --pretty +agentic-commerce virtuals-acp verify-evidence \ + --request examples/virtuals-acp-evidence/request-v1.example.json \ + --pretty ``` Runtime code uses only Python's standard library. Install the development-only schema and build tools before running the complete verification suite: @@ -83,6 +86,8 @@ For portable public-source provenance records and bounded structured job results For deterministic dry-run and default-deny approval decisions, see [docs/action-controls.md](docs/action-controls.md), `schemas/action-control-v1.schema.json`, and `agentic_commerce.action_control`. The evaluator authorizes or rejects bounded actions but never executes them. +For the selected Phase 3 Virtuals ACP job, see [docs/virtuals-acp-evidence-service.md](docs/virtuals-acp-evidence-service.md), `schemas/acp-evidence-request-v1.schema.json`, and `agentic_commerce.acp_evidence`. Local mode simulates a buyer/provider evidence-verification lifecycle, emits a conforming job-result receipt, and performs no wallet, payment, signing, inference, or network operation. + For silent compatibility drift detection and bounded weekly project evidence, see [docs/automation.md](docs/automation.md). These helpers are read-only and never perform GitHub mutations. At the 2026-07-11 compatibility check, the datanet and pod catalogs were live. The documented public stats route returned HTTP 404, so `status` and `snapshot` correctly returned partial result code `2` while preserving catalog data. The upstream pods route also ignored its requested page size; the client applies the requested limit after a capped download. diff --git a/ROADMAP.md b/ROADMAP.md index 8953a94..7859fd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,11 +38,11 @@ A phase is complete only when its artifacts are exercised and its verification g ## Phase 3 — Virtuals ACP reference service (`v0.3.0`) -- [ ] Select one narrowly useful ACP job -- [ ] Implement local/test mode before paid inference -- [ ] Add bounded cost, timeout, and retry behavior -- [ ] Return sources, limitations, and machine-readable receipts -- [ ] Test buyer-to-provider flow +- [x] Select one narrowly useful ACP job. +- [x] Build a local/test mode before paid inference. +- [x] Make cost, timeout, and retry behavior explicit. +- [x] Expose sources, limitations, and machine-readable receipts. +- [x] Test buyer-to-provider flow end to end with synthetic fixtures. **Gate:** end-to-end test job returns a sourced result with observable cost and failure behavior. diff --git a/docs/README.md b/docs/README.md index 83079c1..ad13a64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [Provenance schemas](provenance-schemas.md) - [Agent job result schema](provenance-schemas.md#agent-job-result-v1) - [Dry-run and approval controls](action-controls.md) +- [Virtuals ACP public-evidence reference service](virtuals-acp-evidence-service.md) - [Read-only maintenance automation](automation.md) - [Release process](releasing.md) diff --git a/docs/virtuals-acp-evidence-service.md b/docs/virtuals-acp-evidence-service.md new file mode 100644 index 0000000..f0a267f --- /dev/null +++ b/docs/virtuals-acp-evidence-service.md @@ -0,0 +1,137 @@ +# Virtuals ACP public-evidence reference service + +## Selected Phase 3 job + +**Job type:** `virtuals-acp.public-evidence-verification` + +A buyer supplies two public artifacts: + +1. a candidate `source-manifest-v1` document; +2. a candidate `agent-job-result-v1` document. + +The provider checks both contracts, verifies manifest and source-identifier linkage, applies a public HTTPS source-location policy, and returns an `agent-job-result-v1` receipt. This is narrowly useful for buyers that need a portable checker before accepting or composing an agent deliverable. + +The first implementation is deterministic **local mode**. It performs no ACP authentication, agent registration, wallet operation, signing, transaction, escrow funding, payment, inference call, or network request. + +## Why this job + +The job reuses the toolkit's provenance and result contracts instead of creating a parallel vocabulary. Its output can be checked independently, does not need inference, and remains useful without a grant, token, or hosted agent. + +It complements rather than duplicates current official examples: + +- the active ACP CLI supports offerings with JSON-schema requirements and deliverables; +- the ACP Node SDK v2 documents an event-driven buyer/provider lifecycle and role-gated actions; +- the official aiport showcase recomputes on-chain actions in ACP's evaluator role, whereas this service checks portable public evidence as a provider deliverable; +- the official Agent Supply Chain showcase demonstrates deterministic local buyer-side planning, but not this evidence contract. + +## Public upstream observations + +Observed at `2026-07-15T12:09:27Z` from public Virtuals Protocol repositories: + +| Source | Observation | +|---|---| +| [`Virtual-Protocol/acp-cli`](https://github.com/Virtual-Protocol/acp-cli) | Active package version observed as `1.0.24`; supports JSON output, offering requirement/deliverable schemas, job queries, and the documented job lifecycle. | +| [`Virtual-Protocol/acp-node-v2`](https://github.com/Virtual-Protocol/acp-node-v2) | Current Node v2 SDK documents event-driven `AcpAgent`/`JobSession` behavior and role-gated job tools. | +| [`Virtual-Protocol/acp-cli-demos`](https://github.com/Virtual-Protocol/acp-cli-demos) | Public showcases include evaluator verification and deterministic local orchestration patterns. | +| [`Virtual-Protocol/acp-python`](https://github.com/Virtual-Protocol/acp-python) | Public Python SDK remains available, but its documented live examples require wallet and agent configuration that local mode intentionally avoids. | + +The official repositories identify `openclaw-acp` and the older `acp-node` surface as deprecated. This reference therefore documents future integration against `acp-cli`/`acp-node-v2`, not those retired interfaces. Upstream behavior can drift; the offering example remains hidden and is not registered automatically. + +## Contracts + +### Request + +`schemas/acp-evidence-request-v1.schema.json` defines the bounded transport envelope: + +- stable request ID and job type; +- explicit `local` or future `virtuals-acp` mode; +- candidate source manifest; +- candidate agent-job result; +- no undeclared transport fields. + +The candidate documents are intentionally accepted as bounded objects at the transport boundary. The provider must be able to return useful contract findings for a malformed candidate rather than having the marketplace reject it before verification. + +The runtime also enforces a 1,000,000-byte canonical request limit. + +### Receipt + +The provider reuses `schemas/agent-job-result-v1.schema.json`. A successful service run records: + +- `verdict`: `pass` or `fail`; +- four named checks and bounded findings; +- sanitized public source references; +- lifecycle states; +- attempt count; +- zero measured local cost; +- timeout and freshness observations; +- explicit limitations. + +A `fail` verdict means verification completed and found a contract, linkage, or location-policy problem. It is not a provider crash. Provider timeout or retry exhaustion instead returns `status: failed`, `result: null`, and a stable error code. + +## Run local mode + +From the repository root, without installation: + +```bash +PYTHONPATH=src python3.11 -m agentic_commerce \ + virtuals-acp verify-evidence \ + --request examples/virtuals-acp-evidence/request-v1.example.json \ + --pretty +``` + +The synthetic fixture returns exit code `0` and matches `receipt-v1.example.json` apart from ordinary clock/elapsed fields when a real clock is used. + +Exit codes: + +| Code | Meaning | +|---:|---| +| `0` | Provider completed verification and every check passed. | +| `1` | Input could not be read, or the provider timed out/failed. | +| `2` | Provider completed verification and one or more checks failed. | + +## Local buyer/provider lifecycle + +The passing fixture records: + +```text +open → budget_set → funded → submitted → completed +``` + +These are simulated protocol states. `funded` means only that the synthetic buyer advanced through the local state machine; no funds are created or moved. A service-level failure ends at `rejected`. + +The provider retries only `TransientProviderError`, at most three attempts by default and never more than ten. Other exceptions fail immediately and their private exception text is not copied into the public receipt. Timeout is measured across all attempts. + +## Offering mapping + +`examples/virtuals-acp-evidence/offering-v1.example.json` is shaped for the current ACP CLI offering fields: + +- name and description; +- fixed public price; +- five-minute SLA; +- JSON-schema requirements and deliverable summaries; +- `requiredFunds: false`; +- `isHidden: true`. + +The `0.01` price is illustrative metadata for a future test offering. Local execution always reports zero cost. The example has not been submitted to Virtuals ACP and must not be treated as a live listing. + +A future live adapter must remain separate from the verifier and require explicit approval before registration, job creation, funding, submission, completion, or rejection. It must not collect private keys or print authentication material. + +## Verification scope and limitations + +The provider checks: + +- bounded v1 contract shape and field semantics; +- RFC 3339 timestamps and result timing order; +- provenance manifest-ID equality; +- referenced source IDs exist in the manifest; +- source and manifest locations are public HTTPS URLs without user info, private IP literals, local hostnames, or non-HTTPS ports. + +A pass does **not** prove: + +- that a publisher's claims are true; +- that a URL remains available; +- that content has not changed unless a separately verified digest is present; +- that an ACP payment or settlement occurred; +- that a real provider, buyer, or evaluator participated. + +No live ACP operation should be inferred from the synthetic receipt. diff --git a/examples/README.md b/examples/README.md index 3cf8d45..f01c248 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,5 +4,6 @@ - [Source manifest](source-manifest/README.md) - [Agent job result](agent-job-result/README.md) - [Dry-run and approval controls](action-control/README.md) +- [Virtuals ACP public-evidence service](virtuals-acp-evidence/README.md) Tested, public-source-only examples live here. diff --git a/examples/virtuals-acp-evidence/README.md b/examples/virtuals-acp-evidence/README.md new file mode 100644 index 0000000..dd18896 --- /dev/null +++ b/examples/virtuals-acp-evidence/README.md @@ -0,0 +1,31 @@ +# Virtuals ACP public-evidence examples + +These synthetic fixtures exercise the Phase 3 local buyer/provider reference flow: + +- `request-v1.example.json` bundles the existing public source-manifest and agent-job result examples. +- `receipt-v1.example.json` is the deterministic provider output at `2026-07-15T12:15:00Z` with zero elapsed time. +- `offering-v1.example.json` maps the job to the current public ACP CLI offering fields but remains hidden and unregistered. + +Regenerate the receipt from the request: + +```bash +PYTHONPATH=src python3.11 - <<'PY' +import json +from pathlib import Path +from agentic_commerce.acp_evidence import run_local_evidence_job + +request = json.loads( + Path("examples/virtuals-acp-evidence/request-v1.example.json").read_text() +) +receipt = run_local_evidence_job( + request, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, +) +print(json.dumps(receipt, indent=2)) +PY +``` + +The committed request conforms to `schemas/acp-evidence-request-v1.schema.json`. The receipt conforms to the existing `schemas/agent-job-result-v1.schema.json`. + +The fixtures contain no ACP authentication, wallet identifiers, signatures, transactions, account data, paid inference, private runtime state, or claims of live marketplace execution. diff --git a/examples/virtuals-acp-evidence/offering-v1.example.json b/examples/virtuals-acp-evidence/offering-v1.example.json new file mode 100644 index 0000000..8b0c24b --- /dev/null +++ b/examples/virtuals-acp-evidence/offering-v1.example.json @@ -0,0 +1,49 @@ +{ + "name": "Evidence Verify", + "description": "Validate a public source manifest and agent-job result, verify provenance linkage and public HTTPS source policy, and return a machine-readable receipt.", + "priceType": "fixed", + "priceValue": 0.01, + "slaMinutes": 5, + "requirements": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "requestId", + "jobType", + "mode", + "sourceManifest", + "jobResult" + ], + "properties": { + "schemaVersion": {"const": "1.0"}, + "requestId": {"type": "string", "minLength": 3, "maxLength": 100}, + "jobType": {"const": "virtuals-acp.public-evidence-verification"}, + "mode": {"enum": ["local", "virtuals-acp"]}, + "sourceManifest": {"type": "object", "minProperties": 1, "maxProperties": 20}, + "jobResult": {"type": "object", "minProperties": 1, "maxProperties": 30} + } + }, + "deliverable": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaVersion", + "jobId", + "jobType", + "status", + "request", + "result", + "provenance", + "errors", + "limitations" + ], + "properties": { + "schemaVersion": {"const": "1.0"}, + "jobType": {"const": "virtuals-acp.public-evidence-verification"} + } + }, + "requiredFunds": false, + "isHidden": true +} diff --git a/examples/virtuals-acp-evidence/receipt-v1.example.json b/examples/virtuals-acp-evidence/receipt-v1.example.json new file mode 100644 index 0000000..19b0198 --- /dev/null +++ b/examples/virtuals-acp-evidence/receipt-v1.example.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": "1.0", + "jobId": "receipt:example:public-evidence:2026-07-15", + "jobType": "virtuals-acp.public-evidence-verification", + "status": "succeeded", + "startedAt": "2026-07-15T12:15:00Z", + "completedAt": "2026-07-15T12:15:00Z", + "request": { + "summary": "Verify a bounded public evidence bundle in the local Virtuals ACP reference flow.", + "inputs": [ + { + "name": "requestId", + "value": "example:public-evidence:2026-07-15" + }, + { + "name": "manifestId", + "value": "example:reppo-public-api:2026-07-11" + }, + { + "name": "sourceJobId", + "value": "example:reppo-inspection:2026-07-14" + }, + { + "name": "mode", + "value": "local" + }, + { + "name": "maxAttempts", + "value": 3 + } + ] + }, + "result": { + "summary": "All public evidence checks passed.", + "data": { + "verdict": "pass", + "mode": "local", + "attempts": 1, + "checks": [ + { + "checkId": "source-manifest-contract", + "status": "pass", + "summary": "The candidate conforms to source-manifest-v1.", + "findings": [] + }, + { + "checkId": "agent-job-result-contract", + "status": "pass", + "summary": "The candidate conforms to agent-job-result-v1.", + "findings": [] + }, + { + "checkId": "provenance-linkage", + "status": "pass", + "summary": "Manifest and source identifiers are consistently linked.", + "findings": [] + }, + { + "checkId": "public-source-policy", + "status": "pass", + "summary": "All referenced source locations use allowed public HTTPS URLs.", + "findings": [] + } + ], + "sources": [ + { + "sourceId": "reppo-docs-api-reference", + "url": "https://docs.reppo.xyz/" + }, + { + "sourceId": "reppo-public-datanets-endpoint", + "url": "https://api.reppo.xyz/public/subnets" + } + ], + "lifecycle": [ + { + "status": "open", + "at": "2026-07-15T12:15:00Z" + }, + { + "status": "budget_set", + "at": "2026-07-15T12:15:00Z" + }, + { + "status": "funded", + "at": "2026-07-15T12:15:00Z" + }, + { + "status": "submitted", + "at": "2026-07-15T12:15:00Z" + }, + { + "status": "completed", + "at": "2026-07-15T12:15:00Z" + } + ] + } + }, + "provenance": { + "manifestId": "example:reppo-public-api:2026-07-11", + "sourceIds": [ + "reppo-docs-api-reference", + "reppo-public-datanets-endpoint" + ] + }, + "cost": { + "amount": "0", + "currency": "USD", + "basis": "measured" + }, + "timeout": { + "limitMs": 5000, + "elapsedMs": 0, + "timedOut": false + }, + "freshness": { + "evaluatedAt": "2026-07-15T12:15:00Z", + "status": "stale", + "dataAsOf": "2026-07-11T00:00:00Z", + "maxAgeSeconds": 86400 + }, + "errors": [], + "limitations": [ + "Local mode simulates the ACP buyer/provider lifecycle without creating, funding, or settling a live Virtuals ACP job.", + "A passing receipt confirms contract, linkage, and public-location checks; it does not attest that source claims are true or current." + ] +} diff --git a/examples/virtuals-acp-evidence/request-v1.example.json b/examples/virtuals-acp-evidence/request-v1.example.json new file mode 100644 index 0000000..dd934d0 --- /dev/null +++ b/examples/virtuals-acp-evidence/request-v1.example.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": "1.0", + "requestId": "example:public-evidence:2026-07-15", + "jobType": "virtuals-acp.public-evidence-verification", + "mode": "local", + "sourceManifest": { + "schemaVersion": "1.0", + "manifestId": "example:reppo-public-api:2026-07-11", + "generatedAt": "2026-07-11T00:00:00Z", + "subject": { + "kind": "protocol-source", + "name": "Reppo public API inspector inputs", + "version": "v0.1.0", + "description": "Synthetic example manifest for public sources used by the read-only Reppo inspector." + }, + "sources": [ + { + "sourceId": "reppo-docs-api-reference", + "title": "Reppo API reference", + "url": "https://docs.reppo.xyz/", + "sourceType": "documentation", + "publisher": "Reppo", + "accessedAt": "2026-07-11T00:00:00Z", + "usage": { + "purpose": "schema-reference", + "derivedFacts": [ + "The inspector should use public Reppo endpoints only.", + "Source URLs and fetch timing belong in the machine-readable output envelope." + ], + "limitations": [ + "Documentation can drift from live API behavior, so compatibility checks should record partial failures instead of fabricating replacement data." + ] + } + }, + { + "sourceId": "reppo-public-datanets-endpoint", + "title": "Reppo public datanet catalog endpoint", + "url": "https://api.reppo.xyz/public/subnets", + "sourceType": "api", + "publisher": "Reppo", + "accessedAt": "2026-07-11T00:00:00Z", + "usage": { + "purpose": "input", + "derivedFacts": [ + "Datanet catalog responses are preserved under the inspector envelope data field.", + "The endpoint is queried read-only and does not require authentication." + ], + "limitations": [ + "Availability and response shape can change upstream." + ] + } + } + ], + "notes": [ + "This example is synthetic and contains no credentials, private account data, or local runtime state." + ] + }, + "jobResult": { + "schemaVersion": "1.0", + "jobId": "example:reppo-inspection:2026-07-14", + "jobType": "reppo.public-inspection", + "status": "partial", + "startedAt": "2026-07-14T12:00:00Z", + "completedAt": "2026-07-14T12:00:02Z", + "request": { + "summary": "Inspect bounded public Reppo catalog endpoints using a synthetic request summary.", + "inputs": [ + { + "name": "catalogs", + "value": [ + "datanets", + "pods", + "stats" + ] + }, + { + "name": "limit", + "value": 10 + } + ] + }, + "result": { + "summary": "The public datanet and pod catalogs returned data; the documented stats route was unavailable.", + "data": { + "catalogs": { + "datanets": { + "available": true, + "itemsReturned": 2 + }, + "pods": { + "available": true, + "itemsReturned": 2 + }, + "stats": { + "available": false, + "httpStatus": 404 + } + } + } + }, + "provenance": { + "manifestId": "example:reppo-public-api:2026-07-11", + "manifestUrl": "https://raw.githubusercontent.com/nccrypto/agentic-commerce-toolkit/main/examples/source-manifest/reppo-public-api-manifest-v1.example.json", + "sourceIds": [ + "reppo-docs-api-reference", + "reppo-public-datanets-endpoint" + ] + }, + "cost": { + "amount": "0", + "currency": "USD", + "basis": "measured" + }, + "timeout": { + "limitMs": 5000, + "elapsedMs": 2000, + "timedOut": false + }, + "freshness": { + "evaluatedAt": "2026-07-14T12:00:02Z", + "status": "stale", + "dataAsOf": "2026-07-11T00:00:00Z", + "maxAgeSeconds": 86400 + }, + "errors": [ + { + "code": "HTTP_ERROR", + "message": "The documented public stats route returned HTTP 404.", + "retryable": true + } + ], + "limitations": [ + "This synthetic example does not represent a live compatibility check.", + "The provenance reference identifies public evidence but does not prove that an upstream source is still available." + ] + } +} diff --git a/schemas/README.md b/schemas/README.md index e5ae2b7..89b6dd3 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -8,5 +8,7 @@ - `../examples/agent-job-result/reppo-inspection-result-v1.example.json` — synthetic conforming agent-job result validated in CI. - `action-control-v1.schema.json` — default-deny dry-run and approval-control decision contract. - `../examples/action-control/` — synthetic dry-run and authorized-action examples validated in CI. +- `acp-evidence-request-v1.schema.json` — bounded request envelope for the Virtuals ACP public-evidence verification job. +- `../examples/virtuals-acp-evidence/` — synthetic request, receipt, and hidden offering examples validated in CI. Versioned JSON schemas for additional safety patterns will live here. diff --git a/schemas/acp-evidence-request-v1.schema.json b/schemas/acp-evidence-request-v1.schema.json new file mode 100644 index 0000000..1d30bf8 --- /dev/null +++ b/schemas/acp-evidence-request-v1.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/nccrypto/agentic-commerce-toolkit/main/schemas/acp-evidence-request-v1.schema.json", + "title": "Virtuals ACP Public Evidence Verification Request v1", + "description": "A bounded public-only request envelope for local or ACP-delivered verification of a source manifest and agent-job result.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "requestId", + "jobType", + "mode", + "sourceManifest", + "jobResult" + ], + "properties": { + "schemaVersion": {"const": "1.0"}, + "requestId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,99}$" + }, + "jobType": {"const": "virtuals-acp.public-evidence-verification"}, + "mode": {"enum": ["local", "virtuals-acp"]}, + "sourceManifest": { + "type": "object", + "minProperties": 1, + "maxProperties": 20, + "description": "Candidate source-manifest-v1 document. The provider reports contract findings instead of rejecting a nonconforming candidate at transport validation." + }, + "jobResult": { + "type": "object", + "minProperties": 1, + "maxProperties": 30, + "description": "Candidate agent-job-result-v1 document. The provider reports contract findings instead of rejecting a nonconforming candidate at transport validation." + } + } +} diff --git a/src/agentic_commerce/acp_evidence.py b/src/agentic_commerce/acp_evidence.py new file mode 100644 index 0000000..72e374e --- /dev/null +++ b/src/agentic_commerce/acp_evidence.py @@ -0,0 +1,778 @@ +"""Deterministic local reference service for a Virtuals ACP evidence job. + +The module models one bounded provider offering: validate a public source +manifest and an agent-job result, check their provenance linkage, and emit an +``agent-job-result-v1`` receipt. Local mode performs no network, wallet, +signing, payment, inference, or ACP mutation. +""" + +from __future__ import annotations + +import ipaddress +import json +import math +import re +import time +from datetime import datetime, timezone +from typing import Any, Callable, Mapping, Sequence +from urllib.parse import urlsplit + +from .reppo import utc_now + +JOB_TYPE = "virtuals-acp.public-evidence-verification" +_MAX_REQUEST_BYTES = 1_000_000 +_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{2,127}$") +_SOURCE_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{1,127}$") +_KIND = re.compile(r"^[a-z][a-z0-9._-]{1,63}$") +_INPUT_NAME = re.compile(r"^[a-z][a-zA-Z0-9._-]{0,63}$") +_ERROR_CODE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$") +_DATE_TIME = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" +) +_SOURCE_TYPES = { + "api", + "documentation", + "repository", + "schema", + "example", + "issue", + "release", + "article", + "other", +} +_PURPOSES = { + "input", + "validation", + "compatibility-check", + "citation", + "fixture-basis", + "schema-reference", + "other", +} + + +class TransientProviderError(RuntimeError): + """A retryable local-provider failure used by adapters and tests.""" + + +def _is_mapping(value: Any) -> bool: + return isinstance(value, Mapping) + + +def _canonical_size(value: Any) -> int: + try: + return len( + json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ) + except (TypeError, ValueError): + return _MAX_REQUEST_BYTES + 1 + + +def _parse_datetime(value: Any) -> datetime | None: + if not isinstance(value, str) or not _DATE_TIME.fullmatch(value): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None + + +def _text(value: Any, minimum: int, maximum: int) -> bool: + return isinstance(value, str) and minimum <= len(value) <= maximum + + +def _exact_keys( + value: Any, + required: set[str], + optional: set[str], + path: str, + findings: list[str], +) -> bool: + if not _is_mapping(value): + findings.append(f"{path} must be an object.") + return False + keys = set(value) + missing = sorted(required - keys) + extra = sorted(keys - required - optional) + if missing: + findings.append(f"{path} is missing required fields: {', '.join(missing)}.") + if extra: + findings.append(f"{path} contains undeclared fields: {', '.join(extra)}.") + return not missing and not extra + + +def _string_array( + value: Any, + path: str, + findings: list[str], + *, + maximum_items: int, + maximum_length: int, +) -> bool: + if not isinstance(value, list) or len(value) > maximum_items: + findings.append(f"{path} must be an array with at most {maximum_items} items.") + return False + valid = True + for index, item in enumerate(value): + if not _text(item, 1, maximum_length): + findings.append( + f"{path}[{index}] must be a non-empty string of at most {maximum_length} characters." + ) + valid = False + return valid + + +def _public_scalar(value: Any) -> bool: + if value is None or isinstance(value, (str, bool)): + return not isinstance(value, str) or len(value) <= 1000 + if isinstance(value, int): + return True + if isinstance(value, float): + return math.isfinite(value) + return False + + +def _public_value(value: Any, depth: int = 0) -> bool: + if _public_scalar(value): + return True + if depth >= 3: + return False + if isinstance(value, list): + return len(value) <= 100 and all(_public_value(item, depth + 1) for item in value) + if _is_mapping(value): + return len(value) <= 100 and all( + isinstance(key, str) and _public_value(item, depth + 1) + for key, item in value.items() + ) + return False + + +def _public_https_url(value: Any) -> bool: + if not isinstance(value, str) or len(value) > 1000: + return False + try: + parsed = urlsplit(value) + host = parsed.hostname + port = parsed.port + except ValueError: + return False + if parsed.scheme != "https" or not host or parsed.username or parsed.password: + return False + if port not in (None, 443): + return False + lowered = host.rstrip(".").lower() + if lowered == "localhost" or lowered.endswith((".localhost", ".local", ".internal")): + return False + try: + address = ipaddress.ip_address(lowered.strip("[]")) + except ValueError: + return "." in lowered and not lowered.startswith(".") and not lowered.endswith(".") + return address.is_global + + +def _uri(value: Any) -> bool: + if not isinstance(value, str) or not value or len(value) > 2048 or any( + character.isspace() for character in value + ): + return False + try: + parsed = urlsplit(value) + except ValueError: + return False + if not parsed.scheme: + return False + return parsed.scheme not in {"http", "https"} or bool(parsed.netloc) + + +def _bounded_finding(value: str) -> str: + return value if len(value) <= 500 else value[:497] + "..." + + +def _validate_usage(value: Any, path: str, findings: list[str]) -> None: + if not _exact_keys( + value, + {"purpose", "derivedFacts"}, + {"limitations"}, + path, + findings, + ): + return + if value["purpose"] not in _PURPOSES: + findings.append(f"{path}.purpose is not an allowed value.") + _string_array( + value["derivedFacts"], + f"{path}.derivedFacts", + findings, + maximum_items=20, + maximum_length=300, + ) + if "limitations" in value: + _string_array( + value["limitations"], + f"{path}.limitations", + findings, + maximum_items=20, + maximum_length=300, + ) + + +def validate_source_manifest(value: Any) -> list[str]: + """Return bounded contract findings for a source-manifest-v1 candidate.""" + + findings: list[str] = [] + if not _exact_keys( + value, + {"schemaVersion", "manifestId", "generatedAt", "subject", "sources"}, + {"notes"}, + "sourceManifest", + findings, + ): + return findings[:50] + if value["schemaVersion"] != "1.0": + findings.append("sourceManifest.schemaVersion must equal 1.0.") + if not isinstance(value["manifestId"], str) or not _ID.fullmatch(value["manifestId"]): + findings.append("sourceManifest.manifestId is invalid.") + if _parse_datetime(value["generatedAt"]) is None: + findings.append("sourceManifest.generatedAt must be an RFC 3339 date-time.") + + subject = value["subject"] + if _exact_keys( + subject, + {"kind", "name"}, + {"version", "description"}, + "sourceManifest.subject", + findings, + ): + if not isinstance(subject["kind"], str) or not _KIND.fullmatch(subject["kind"]): + findings.append("sourceManifest.subject.kind is invalid.") + if not _text(subject["name"], 1, 160): + findings.append("sourceManifest.subject.name is invalid.") + if "version" in subject and not _text(subject["version"], 1, 80): + findings.append("sourceManifest.subject.version is invalid.") + if "description" in subject and not _text(subject["description"], 1, 500): + findings.append("sourceManifest.subject.description is invalid.") + + sources = value["sources"] + if not isinstance(sources, list) or not 1 <= len(sources) <= 100: + findings.append("sourceManifest.sources must contain between 1 and 100 records.") + else: + seen_ids: set[str] = set() + for index, source in enumerate(sources): + path = f"sourceManifest.sources[{index}]" + if not _exact_keys( + source, + {"sourceId", "title", "url", "sourceType", "publisher", "accessedAt", "usage"}, + {"retrievedAt", "license", "version", "contentDigest"}, + path, + findings, + ): + continue + source_id = source["sourceId"] + if not isinstance(source_id, str) or not _SOURCE_ID.fullmatch(source_id): + findings.append(f"{path}.sourceId is invalid.") + elif source_id in seen_ids: + findings.append(f"{path}.sourceId duplicates another source record.") + else: + seen_ids.add(source_id) + if not _text(source["title"], 1, 240): + findings.append(f"{path}.title is invalid.") + if not _uri(source["url"]): + findings.append(f"{path}.url is invalid.") + if source["sourceType"] not in _SOURCE_TYPES: + findings.append(f"{path}.sourceType is not allowed.") + if not _text(source["publisher"], 1, 160): + findings.append(f"{path}.publisher is invalid.") + if _parse_datetime(source["accessedAt"]) is None: + findings.append(f"{path}.accessedAt must be an RFC 3339 date-time.") + if "retrievedAt" in source and _parse_datetime(source["retrievedAt"]) is None: + findings.append(f"{path}.retrievedAt must be an RFC 3339 date-time.") + for field, limit in (("license", 120), ("version", 120)): + if field in source and not _text(source[field], 1, limit): + findings.append(f"{path}.{field} is invalid.") + if "contentDigest" in source: + digest = source["contentDigest"] + if _exact_keys(digest, {"algorithm", "value"}, set(), f"{path}.contentDigest", findings): + if digest["algorithm"] != "sha256" or not isinstance(digest["value"], str) or not re.fullmatch(r"[a-f0-9]{64}", digest["value"]): + findings.append(f"{path}.contentDigest is invalid.") + _validate_usage(source["usage"], f"{path}.usage", findings) + if "notes" in value: + _string_array( + value["notes"], + "sourceManifest.notes", + findings, + maximum_items=20, + maximum_length=500, + ) + return findings[:50] + + +def _validate_job_request(value: Any, findings: list[str]) -> None: + if not _exact_keys(value, {"summary", "inputs"}, set(), "jobResult.request", findings): + return + if not _text(value["summary"], 1, 500): + findings.append("jobResult.request.summary is invalid.") + inputs = value["inputs"] + if not isinstance(inputs, list) or len(inputs) > 50: + findings.append("jobResult.request.inputs must contain at most 50 records.") + return + for index, item in enumerate(inputs): + path = f"jobResult.request.inputs[{index}]" + if not _exact_keys(item, {"name", "value"}, set(), path, findings): + continue + if not isinstance(item["name"], str) or not _INPUT_NAME.fullmatch(item["name"]): + findings.append(f"{path}.name is invalid.") + input_value = item["value"] + valid = _public_scalar(input_value) or ( + isinstance(input_value, list) + and len(input_value) <= 100 + and all(_public_scalar(entry) for entry in input_value) + ) + if not valid: + findings.append(f"{path}.value is not a bounded public input value.") + + +def validate_agent_job_result(value: Any) -> list[str]: + """Return bounded contract findings for an agent-job-result-v1 candidate.""" + + findings: list[str] = [] + required = { + "schemaVersion", "jobId", "jobType", "status", "startedAt", "completedAt", + "request", "result", "provenance", "errors", "limitations", + } + optional = {"cost", "timeout", "freshness"} + if not _exact_keys(value, required, optional, "jobResult", findings): + return findings[:50] + if value["schemaVersion"] != "1.0": + findings.append("jobResult.schemaVersion must equal 1.0.") + if not isinstance(value["jobId"], str) or not _ID.fullmatch(value["jobId"]): + findings.append("jobResult.jobId is invalid.") + if not isinstance(value["jobType"], str) or not _KIND.fullmatch(value["jobType"]): + findings.append("jobResult.jobType is invalid.") + status = value["status"] + if status not in {"succeeded", "partial", "failed"}: + findings.append("jobResult.status is not allowed.") + started = _parse_datetime(value["startedAt"]) + completed = _parse_datetime(value["completedAt"]) + if started is None: + findings.append("jobResult.startedAt must be an RFC 3339 date-time.") + if completed is None: + findings.append("jobResult.completedAt must be an RFC 3339 date-time.") + if started is not None and completed is not None and completed < started: + findings.append("jobResult.completedAt precedes startedAt.") + _validate_job_request(value["request"], findings) + + result = value["result"] + if status == "failed": + if result is not None: + findings.append("jobResult.result must be null when status is failed.") + elif not _is_mapping(result): + findings.append("jobResult.result must be an object for succeeded or partial status.") + if isinstance(result, Mapping): + if _exact_keys(result, {"summary", "data"}, set(), "jobResult.result", findings): + if not _text(result["summary"], 1, 1000): + findings.append("jobResult.result.summary is invalid.") + data = result["data"] + if not _is_mapping(data) or len(data) > 100 or not all( + isinstance(key, str) and _public_value(item) for key, item in data.items() + ): + findings.append("jobResult.result.data is not a bounded public object.") + + provenance = value["provenance"] + if _exact_keys(provenance, {"manifestId"}, {"manifestUrl", "sourceIds"}, "jobResult.provenance", findings): + if not isinstance(provenance["manifestId"], str) or not _ID.fullmatch(provenance["manifestId"]): + findings.append("jobResult.provenance.manifestId is invalid.") + if "manifestUrl" in provenance and not _uri(provenance["manifestUrl"]): + findings.append("jobResult.provenance.manifestUrl is invalid.") + if "sourceIds" in provenance: + source_ids = provenance["sourceIds"] + valid_source_ids = ( + isinstance(source_ids, list) + and len(source_ids) <= 100 + and all( + isinstance(source_id, str) and _SOURCE_ID.fullmatch(source_id) + for source_id in source_ids + ) + ) + if valid_source_ids and len(source_ids) != len(set(source_ids)): + valid_source_ids = False + if not valid_source_ids: + findings.append("jobResult.provenance.sourceIds is invalid.") + + errors = value["errors"] + if not isinstance(errors, list) or len(errors) > 50: + findings.append("jobResult.errors must contain at most 50 records.") + else: + for index, error in enumerate(errors): + path = f"jobResult.errors[{index}]" + if not _exact_keys(error, {"code", "message"}, {"retryable"}, path, findings): + continue + if not isinstance(error["code"], str) or not _ERROR_CODE.fullmatch(error["code"]): + findings.append(f"{path}.code is invalid.") + if not _text(error["message"], 1, 500): + findings.append(f"{path}.message is invalid.") + if "retryable" in error and not isinstance(error["retryable"], bool): + findings.append(f"{path}.retryable must be boolean.") + if status == "failed" and not errors: + findings.append("jobResult.errors must be non-empty when status is failed.") + _string_array(value["limitations"], "jobResult.limitations", findings, maximum_items=50, maximum_length=500) + + if "cost" in value: + cost = value["cost"] + if _exact_keys(cost, {"amount", "currency", "basis"}, set(), "jobResult.cost", findings): + if not isinstance(cost["amount"], str) or not re.fullmatch(r"(0|[1-9][0-9]{0,17})(\.[0-9]{1,18})?", cost["amount"]): + findings.append("jobResult.cost.amount is invalid.") + if not isinstance(cost["currency"], str) or not re.fullmatch(r"[A-Z][A-Z0-9]{2,11}", cost["currency"]): + findings.append("jobResult.cost.currency is invalid.") + if cost["basis"] not in {"measured", "estimated"}: + findings.append("jobResult.cost.basis is invalid.") + if "timeout" in value: + timeout = value["timeout"] + if _exact_keys(timeout, {"limitMs", "elapsedMs", "timedOut"}, set(), "jobResult.timeout", findings): + for field in ("limitMs", "elapsedMs"): + minimum = 1 if field == "limitMs" else 0 + if isinstance(timeout[field], bool) or not isinstance(timeout[field], int) or not minimum <= timeout[field] <= 604800000: + findings.append(f"jobResult.timeout.{field} is invalid.") + if not isinstance(timeout["timedOut"], bool): + findings.append("jobResult.timeout.timedOut must be boolean.") + if "freshness" in value: + freshness = value["freshness"] + if _exact_keys(freshness, {"evaluatedAt", "status"}, {"dataAsOf", "maxAgeSeconds"}, "jobResult.freshness", findings): + if _parse_datetime(freshness["evaluatedAt"]) is None: + findings.append("jobResult.freshness.evaluatedAt is invalid.") + fresh_status = freshness["status"] + if fresh_status not in {"fresh", "stale", "unknown"}: + findings.append("jobResult.freshness.status is invalid.") + if fresh_status in {"fresh", "stale"} and not {"dataAsOf", "maxAgeSeconds"} <= set(freshness): + findings.append("jobResult.freshness needs dataAsOf and maxAgeSeconds when freshness is known.") + if "dataAsOf" in freshness and _parse_datetime(freshness["dataAsOf"]) is None: + findings.append("jobResult.freshness.dataAsOf is invalid.") + if "maxAgeSeconds" in freshness and ( + isinstance(freshness["maxAgeSeconds"], bool) + or not isinstance(freshness["maxAgeSeconds"], int) + or not 0 <= freshness["maxAgeSeconds"] <= 315360000 + ): + findings.append("jobResult.freshness.maxAgeSeconds is invalid.") + return findings[:50] + + +def _validate_request_envelope(request: Any) -> list[str]: + findings: list[str] = [] + if _canonical_size(request) > _MAX_REQUEST_BYTES: + findings.append("request exceeds the 1000000-byte canonical size limit.") + return findings + if not _exact_keys( + request, + {"schemaVersion", "requestId", "jobType", "mode", "sourceManifest", "jobResult"}, + set(), + "request", + findings, + ): + return findings + if request["schemaVersion"] != "1.0": + findings.append("request.schemaVersion must equal 1.0.") + if not isinstance(request["requestId"], str) or not re.fullmatch(r"[a-z0-9][a-z0-9._:-]{2,99}", request["requestId"]): + findings.append("request.requestId is invalid.") + if request["jobType"] != JOB_TYPE: + findings.append(f"request.jobType must equal {JOB_TYPE}.") + if request["mode"] != "local": + findings.append("Only local mode is implemented by this reference service.") + if not _is_mapping(request["sourceManifest"]) or not 1 <= len(request["sourceManifest"]) <= 20: + findings.append("request.sourceManifest must be an object with 1 to 20 properties.") + if not _is_mapping(request["jobResult"]) or not 1 <= len(request["jobResult"]) <= 30: + findings.append("request.jobResult must be an object with 1 to 30 properties.") + return findings[:50] + + +def _check(check_id: str, findings: Sequence[str], passed_summary: str) -> dict[str, Any]: + bounded = [_bounded_finding(finding) for finding in findings[:20]] + return { + "checkId": check_id, + "status": "pass" if not bounded else "fail", + "summary": passed_summary if not bounded else f"{len(bounded)} bounded finding(s) recorded.", + "findings": bounded, + } + + +def _verify_bundle(request: Mapping[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + manifest = request["sourceManifest"] + job_result = request["jobResult"] + manifest_findings = validate_source_manifest(manifest) + result_findings = validate_agent_job_result(job_result) + + linkage: list[str] = [] + if _is_mapping(manifest) and _is_mapping(job_result): + manifest_id = manifest.get("manifestId") + provenance = job_result.get("provenance") + if not _is_mapping(provenance): + linkage.append("jobResult.provenance is unavailable for linkage checking.") + else: + if provenance.get("manifestId") != manifest_id: + linkage.append("jobResult.provenance.manifestId does not match sourceManifest.manifestId.") + manifest_source_ids = { + source.get("sourceId") + for source in manifest.get("sources", []) + if _is_mapping(source) and isinstance(source.get("sourceId"), str) + } + referenced = provenance.get("sourceIds", []) + if isinstance(referenced, list): + missing = sorted( + source_id + for source_id in referenced + if isinstance(source_id, str) and source_id not in manifest_source_ids + ) + if missing: + linkage.append( + "jobResult.provenance.sourceIds contains IDs absent from the manifest: " + + ", ".join(missing[:10]) + + "." + ) + else: + linkage.append("Evidence documents are unavailable for linkage checking.") + + public_policy: list[str] = [] + sources: list[dict[str, str]] = [] + emitted_source_ids: set[str] = set() + if _is_mapping(manifest) and isinstance(manifest.get("sources"), list): + for index, source in enumerate(manifest["sources"][:100]): + if not _is_mapping(source): + continue + source_id = source.get("sourceId") + url = source.get("url") + if not _public_https_url(url): + public_policy.append( + f"sourceManifest.sources[{index}].url is not an allowed public HTTPS URL." + ) + elif ( + isinstance(source_id, str) + and _SOURCE_ID.fullmatch(source_id) + and source_id not in emitted_source_ids + ): + sources.append({"sourceId": source_id, "url": url}) + emitted_source_ids.add(source_id) + else: + public_policy.append("No source records are available for public URL policy checking.") + + if _is_mapping(job_result): + provenance = job_result.get("provenance") + manifest_url = provenance.get("manifestUrl") if _is_mapping(provenance) else None + if manifest_url is not None and not _public_https_url(manifest_url): + public_policy.append("jobResult.provenance.manifestUrl is not an allowed public HTTPS URL.") + + checks = [ + _check("source-manifest-contract", manifest_findings, "The candidate conforms to source-manifest-v1."), + _check("agent-job-result-contract", result_findings, "The candidate conforms to agent-job-result-v1."), + _check("provenance-linkage", linkage, "Manifest and source identifiers are consistently linked."), + _check("public-source-policy", public_policy, "All referenced source locations use allowed public HTTPS URLs."), + ] + return checks, sources + + +def _safe_identifier(request: Any, key: str, fallback: str, pattern: re.Pattern[str]) -> str: + if _is_mapping(request): + value = request.get(key) + if isinstance(value, str) and pattern.fullmatch(value): + return value + return fallback + + +def _request_metadata(request: Any, max_attempts: int) -> tuple[str, str, str, list[dict[str, Any]]]: + request_id = _safe_identifier(request, "requestId", "invalid-request", re.compile(r"[a-z0-9][a-z0-9._:-]{2,99}")) + manifest = request.get("sourceManifest") if _is_mapping(request) else None + job_result = request.get("jobResult") if _is_mapping(request) else None + manifest_id = _safe_identifier(manifest, "manifestId", "unknown-manifest", _ID) + source_job_id = _safe_identifier(job_result, "jobId", "unknown-source-job", _ID) + inputs = [ + {"name": "requestId", "value": request_id}, + {"name": "manifestId", "value": manifest_id}, + {"name": "sourceJobId", "value": source_job_id}, + {"name": "mode", "value": "local"}, + {"name": "maxAttempts", "value": max_attempts}, + ] + return request_id, manifest_id, source_job_id, inputs + + +def _freshness(manifest: Any, evaluated_at: str) -> dict[str, Any]: + timestamps: list[datetime] = [] + if _is_mapping(manifest) and isinstance(manifest.get("sources"), list): + for source in manifest["sources"]: + if _is_mapping(source): + parsed = _parse_datetime(source.get("accessedAt")) + if parsed is not None: + timestamps.append(parsed) + evaluated = _parse_datetime(evaluated_at) + if not timestamps or evaluated is None: + return {"evaluatedAt": evaluated_at, "status": "unknown"} + data_as_of = max(timestamps) + max_age = 86400 + age = max(0.0, (evaluated - data_as_of).total_seconds()) + return { + "evaluatedAt": evaluated_at, + "status": "fresh" if age <= max_age else "stale", + "dataAsOf": data_as_of.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), + "maxAgeSeconds": max_age, + } + + +def run_local_evidence_job( + request: Any, + *, + timeout_ms: int = 5000, + max_attempts: int = 3, + verifier: Callable[[Mapping[str, Any]], tuple[list[dict[str, Any]], list[dict[str, str]]]] | None = None, + clock: Callable[[], str] = utc_now, + monotonic: Callable[[], float] = time.monotonic, +) -> dict[str, Any]: + """Run a bounded, deterministic local buyer/provider simulation. + + ``verifier`` is injectable for retry/timeout tests and future read-only + adapters. Only :class:`TransientProviderError` is retried. + """ + + if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 1 <= timeout_ms <= 604800000: + raise ValueError("timeout_ms must be an integer from 1 through 604800000") + if isinstance(max_attempts, bool) or not isinstance(max_attempts, int) or not 1 <= max_attempts <= 10: + raise ValueError("max_attempts must be an integer from 1 through 10") + + started_at = clock() + started_tick = monotonic() + request_id, manifest_id, _source_job_id, inputs = _request_metadata(request, max_attempts) + lifecycle: list[dict[str, str]] = [{"status": "open", "at": started_at}] + envelope_findings = _validate_request_envelope(request) + attempts = 0 + checks: list[dict[str, Any]] = [] + sources: list[dict[str, str]] = [] + service_error: tuple[str, str, bool] | None = None + timed_out = False + + if envelope_findings: + checks = [_check("request-contract", envelope_findings, "The request envelope is valid.")] + else: + lifecycle.extend( + [ + {"status": "budget_set", "at": clock()}, + {"status": "funded", "at": clock()}, + ] + ) + provider = verifier or _verify_bundle + while attempts < max_attempts: + elapsed_before = max(0, int((monotonic() - started_tick) * 1000)) + if elapsed_before >= timeout_ms: + timed_out = True + service_error = ("TIMEOUT", "The local provider exceeded its bounded timeout.", True) + break + attempts += 1 + try: + checks, sources = provider(request) + elapsed_after = max(0, int((monotonic() - started_tick) * 1000)) + if elapsed_after >= timeout_ms: + timed_out = True + service_error = ("TIMEOUT", "The local provider exceeded its bounded timeout.", True) + break + except TransientProviderError: + if attempts >= max_attempts: + service_error = ( + "RETRY_EXHAUSTED", + "The local provider did not succeed within the bounded retry limit.", + True, + ) + except Exception: + service_error = ( + "PROVIDER_ERROR", + "The local provider failed without exposing private exception details.", + False, + ) + break + + completed_at = clock() + elapsed_ms = min(604800000, max(0, int((monotonic() - started_tick) * 1000))) + limitations = [ + "Local mode simulates the ACP buyer/provider lifecycle without creating, funding, or settling a live Virtuals ACP job.", + "A passing receipt confirms contract, linkage, and public-location checks; it does not attest that source claims are true or current.", + ] + provenance: dict[str, Any] = {"manifestId": manifest_id} + if sources: + provenance["sourceIds"] = [source["sourceId"] for source in sources] + + if service_error is not None: + lifecycle.append({"status": "rejected", "at": completed_at}) + code, message, retryable = service_error + return { + "schemaVersion": "1.0", + "jobId": f"receipt:{request_id}", + "jobType": JOB_TYPE, + "status": "failed", + "startedAt": started_at, + "completedAt": completed_at, + "request": { + "summary": "Verify a bounded public evidence bundle in the local Virtuals ACP reference flow.", + "inputs": inputs, + }, + "result": None, + "provenance": provenance, + "cost": {"amount": "0", "currency": "USD", "basis": "measured"}, + "timeout": {"limitMs": timeout_ms, "elapsedMs": elapsed_ms, "timedOut": timed_out}, + "freshness": _freshness(request.get("sourceManifest") if _is_mapping(request) else None, completed_at), + "errors": [{"code": code, "message": message, "retryable": retryable}], + "limitations": limitations, + } + + lifecycle.extend( + [ + {"status": "submitted", "at": completed_at}, + {"status": "completed", "at": completed_at}, + ] + ) + verdict = "pass" if checks and all(check["status"] == "pass" for check in checks) else "fail" + return { + "schemaVersion": "1.0", + "jobId": f"receipt:{request_id}", + "jobType": JOB_TYPE, + "status": "succeeded", + "startedAt": started_at, + "completedAt": completed_at, + "request": { + "summary": "Verify a bounded public evidence bundle in the local Virtuals ACP reference flow.", + "inputs": inputs, + }, + "result": { + "summary": ( + "All public evidence checks passed." + if verdict == "pass" + else "The provider completed verification and recorded one or more failed checks." + ), + "data": { + "verdict": verdict, + "mode": "local", + "attempts": attempts, + "checks": checks, + "sources": sources, + "lifecycle": lifecycle, + }, + }, + "provenance": provenance, + "cost": {"amount": "0", "currency": "USD", "basis": "measured"}, + "timeout": {"limitMs": timeout_ms, "elapsedMs": elapsed_ms, "timedOut": False}, + "freshness": _freshness(request.get("sourceManifest") if _is_mapping(request) else None, completed_at), + "errors": [], + "limitations": limitations, + } + + +def receipt_exit_code(receipt: Mapping[str, Any]) -> int: + """Map a receipt to CLI status: pass=0, findings=2, service failure=1.""" + + if receipt.get("status") == "failed": + return 1 + result = receipt.get("result") + if isinstance(result, Mapping): + data = result.get("data") + if isinstance(data, Mapping): + return 0 if data.get("verdict") == "pass" else 2 + return 1 diff --git a/src/agentic_commerce/cli.py b/src/agentic_commerce/cli.py index c17962f..952bf19 100644 --- a/src/agentic_commerce/cli.py +++ b/src/agentic_commerce/cli.py @@ -5,8 +5,11 @@ import argparse import json import sys +import time +from pathlib import Path from typing import Any, Callable, Sequence, TextIO +from .acp_evidence import receipt_exit_code, run_local_evidence_job from .reppo import DEFAULT_BASE_URL, Inspector, Transport, UrllibTransport, utc_now @@ -42,18 +45,70 @@ def build_parser() -> argparse.ArgumentParser: snapshot = commands.add_parser("snapshot", help="aggregate public ecosystem data") snapshot.add_argument("--limit", type=int, default=20) _common_options(snapshot) + + virtuals = ecosystems.add_parser( + "virtuals-acp", help="run bounded Virtuals ACP reference flows" + ) + virtuals_commands = virtuals.add_subparsers( + dest="virtuals_command", required=True + ) + evidence = virtuals_commands.add_parser( + "verify-evidence", help="verify a public evidence bundle in local mode" + ) + evidence.add_argument("--request", required=True, metavar="FILE") + evidence.add_argument("--timeout-ms", type=int, default=5000) + evidence.add_argument("--max-attempts", type=int, default=3) + evidence.add_argument("--pretty", action="store_true") return parser +def _print_json(value: Any, *, pretty: bool, output: TextIO) -> None: + dump_options: dict[str, Any] = {"sort_keys": True} + if pretty: + dump_options["indent"] = 2 + else: + dump_options["separators"] = (",", ":") + print(json.dumps(value, **dump_options), file=output) + + def main( argv: Sequence[str] | None = None, *, stdout: TextIO | None = None, transport: Transport | None = None, clock: Callable[[], str] = utc_now, + monotonic: Callable[[], float] = time.monotonic, ) -> int: args = build_parser().parse_args(argv) output = stdout or sys.stdout + if args.ecosystem == "virtuals-acp": + try: + request_path = Path(args.request) + if request_path.stat().st_size > 1_000_000: + raise ValueError("oversized input") + request = json.loads(request_path.read_text(encoding="utf-8")) + receipt = run_local_evidence_job( + request, + timeout_ms=args.timeout_ms, + max_attempts=args.max_attempts, + clock=clock, + monotonic=monotonic, + ) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + _print_json( + { + "error": { + "code": "INPUT_ERROR", + "message": "Unable to read a bounded local evidence request.", + } + }, + pretty=args.pretty, + output=output, + ) + return 1 + _print_json(receipt, pretty=args.pretty, output=output) + return receipt_exit_code(receipt) + inspector = Inspector( base_url=args.base_url, timeout=args.timeout, @@ -74,10 +129,5 @@ def main( ) else: result = inspector.snapshot(limit=args.limit) - dump_options: dict[str, Any] = {"sort_keys": True} - if args.pretty: - dump_options["indent"] = 2 - else: - dump_options["separators"] = (",", ":") - print(json.dumps(result.envelope, **dump_options), file=output) + _print_json(result.envelope, pretty=args.pretty, output=output) return result.exit_code diff --git a/tests/test_acp_evidence_service.py b/tests/test_acp_evidence_service.py new file mode 100644 index 0000000..ed15f83 --- /dev/null +++ b/tests/test_acp_evidence_service.py @@ -0,0 +1,222 @@ +import copy +import io +import json +import sys +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator, FormatChecker + + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from agentic_commerce.acp_evidence import ( + TransientProviderError, + receipt_exit_code, + run_local_evidence_job, +) +from agentic_commerce.cli import main + + +REQUEST_EXAMPLE = ROOT / "examples" / "virtuals-acp-evidence" / "request-v1.example.json" +RESULT_SCHEMA = ROOT / "schemas" / "agent-job-result-v1.schema.json" + + +def load_request(): + return json.loads(REQUEST_EXAMPLE.read_text(encoding="utf-8")) + + +class AcpEvidenceServiceTests(unittest.TestCase): + def test_valid_bundle_passes_all_checks_without_external_execution(self): + receipt = run_local_evidence_job( + load_request(), + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + data = receipt["result"]["data"] + self.assertEqual(receipt["status"], "succeeded") + self.assertEqual(data["verdict"], "pass") + self.assertEqual([item["status"] for item in data["checks"]], ["pass"] * 4) + self.assertEqual( + [item["status"] for item in data["lifecycle"]], + ["open", "budget_set", "funded", "submitted", "completed"], + ) + self.assertEqual(receipt["cost"], {"amount": "0", "currency": "USD", "basis": "measured"}) + self.assertFalse(receipt["timeout"]["timedOut"]) + self.assertEqual(receipt_exit_code(receipt), 0) + + def test_provenance_mismatch_is_a_completed_fail_verdict(self): + request = load_request() + request["jobResult"]["provenance"]["manifestId"] = "example:different-manifest" + + receipt = run_local_evidence_job( + request, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + self.assertEqual(receipt["status"], "succeeded") + self.assertEqual(receipt["result"]["data"]["verdict"], "fail") + linkage = next( + check + for check in receipt["result"]["data"]["checks"] + if check["checkId"] == "provenance-linkage" + ) + self.assertEqual(linkage["status"], "fail") + self.assertEqual(receipt_exit_code(receipt), 2) + + def test_private_or_credential_shaped_evidence_fails_safely(self): + request = load_request() + request["sourceManifest"]["sources"][0]["url"] = "https://127.0.0.1/private" + request["jobResult"]["privateRuntimeState"] = "not allowed" + + receipt = run_local_evidence_job( + request, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + checks = {item["checkId"]: item for item in receipt["result"]["data"]["checks"]} + self.assertEqual(checks["agent-job-result-contract"]["status"], "fail") + self.assertEqual(checks["public-source-policy"]["status"], "fail") + rendered = json.dumps(receipt) + self.assertNotIn("127.0.0.1", rendered) + self.assertNotIn("not allowed", rendered) + + def test_adversarial_candidate_still_produces_a_conforming_bounded_receipt(self): + request = load_request() + request["jobResult"]["provenance"]["sourceIds"] = [{"not": "hashable"}] + request["jobResult"]["x" * 2000] = "undeclared" + request["sourceManifest"]["sources"].append( + copy.deepcopy(request["sourceManifest"]["sources"][0]) + ) + + receipt = run_local_evidence_job( + request, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + validator = Draft202012Validator( + json.loads(RESULT_SCHEMA.read_text(encoding="utf-8")), + format_checker=FormatChecker(), + ) + + self.assertTrue(validator.is_valid(receipt)) + self.assertEqual(receipt["result"]["data"]["verdict"], "fail") + self.assertEqual(len(receipt["provenance"]["sourceIds"]), 2) + findings = [ + finding + for check in receipt["result"]["data"]["checks"] + for finding in check["findings"] + ] + self.assertTrue(all(len(finding) <= 500 for finding in findings)) + + def test_transient_provider_failure_retries_within_bound(self): + request = load_request() + calls = [] + + def provider(candidate): + calls.append(candidate["requestId"]) + if len(calls) == 1: + raise TransientProviderError("synthetic transient failure") + baseline = run_local_evidence_job( + copy.deepcopy(candidate), + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + data = baseline["result"]["data"] + return data["checks"], data["sources"] + + receipt = run_local_evidence_job( + request, + verifier=provider, + max_attempts=2, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + self.assertEqual(len(calls), 2) + self.assertEqual(receipt["result"]["data"]["attempts"], 2) + self.assertEqual(receipt["result"]["data"]["verdict"], "pass") + + def test_retry_exhaustion_returns_sanitized_failed_receipt(self): + def provider(_candidate): + raise TransientProviderError("private provider detail") + + receipt = run_local_evidence_job( + load_request(), + verifier=provider, + max_attempts=2, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + self.assertEqual(receipt["status"], "failed") + self.assertIsNone(receipt["result"]) + self.assertEqual(receipt["errors"][0]["code"], "RETRY_EXHAUSTED") + self.assertNotIn("private provider detail", json.dumps(receipt)) + self.assertEqual(receipt_exit_code(receipt), 1) + + def test_timeout_after_provider_call_returns_failed_receipt(self): + ticks = iter([0.0, 0.0, 0.006, 0.006]) + + def provider(_candidate): + return [], [] + + receipt = run_local_evidence_job( + load_request(), + verifier=provider, + timeout_ms=5, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: next(ticks), + ) + + self.assertEqual(receipt["status"], "failed") + self.assertTrue(receipt["timeout"]["timedOut"]) + self.assertEqual(receipt["errors"][0]["code"], "TIMEOUT") + + def test_unsupported_live_mode_is_default_deny(self): + request = load_request() + request["mode"] = "virtuals-acp" + + receipt = run_local_evidence_job( + request, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + self.assertEqual(receipt["result"]["data"]["verdict"], "fail") + self.assertEqual(receipt["result"]["data"]["checks"][0]["checkId"], "request-contract") + self.assertEqual(receipt["result"]["data"]["lifecycle"], [ + {"status": "open", "at": "2026-07-15T12:15:00Z"}, + {"status": "submitted", "at": "2026-07-15T12:15:00Z"}, + {"status": "completed", "at": "2026-07-15T12:15:00Z"}, + ]) + + def test_cli_runs_local_fixture_and_prints_only_json(self): + stdout = io.StringIO() + + exit_code = main( + [ + "virtuals-acp", + "verify-evidence", + "--request", + str(REQUEST_EXAMPLE), + "--pretty", + ], + stdout=stdout, + clock=lambda: "2026-07-15T12:15:00Z", + monotonic=lambda: 100.0, + ) + + payload = json.loads(stdout.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["result"]["data"]["verdict"], "pass") + self.assertTrue(stdout.getvalue().startswith("{\n")) + self.assertNotIn(str(ROOT), stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_schema_contract.py b/tests/test_schema_contract.py index f058e08..27b026c 100644 --- a/tests/test_schema_contract.py +++ b/tests/test_schema_contract.py @@ -17,6 +17,10 @@ ACTION_CONTROL_SCHEMA = ROOT / "schemas" / "action-control-v1.schema.json" ACTION_CONTROL_DRY_RUN_EXAMPLE = ROOT / "examples" / "action-control" / "dry-run-v1.example.json" ACTION_CONTROL_AUTHORIZED_EXAMPLE = ROOT / "examples" / "action-control" / "authorized-action-v1.example.json" +ACP_EVIDENCE_REQUEST_SCHEMA = ROOT / "schemas" / "acp-evidence-request-v1.schema.json" +ACP_EVIDENCE_REQUEST_EXAMPLE = ROOT / "examples" / "virtuals-acp-evidence" / "request-v1.example.json" +ACP_EVIDENCE_RECEIPT_EXAMPLE = ROOT / "examples" / "virtuals-acp-evidence" / "receipt-v1.example.json" +ACP_EVIDENCE_OFFERING_EXAMPLE = ROOT / "examples" / "virtuals-acp-evidence" / "offering-v1.example.json" def load_json(path): @@ -47,6 +51,21 @@ def test_action_control_examples_conform_to_action_control_v1(self): self.assert_conforms(ACTION_CONTROL_SCHEMA, ACTION_CONTROL_DRY_RUN_EXAMPLE) self.assert_conforms(ACTION_CONTROL_SCHEMA, ACTION_CONTROL_AUTHORIZED_EXAMPLE) + def test_acp_evidence_examples_conform_to_public_contracts(self): + self.assert_conforms(ACP_EVIDENCE_REQUEST_SCHEMA, ACP_EVIDENCE_REQUEST_EXAMPLE) + self.assert_conforms(AGENT_JOB_RESULT_SCHEMA, ACP_EVIDENCE_RECEIPT_EXAMPLE) + + def test_acp_offering_example_is_bounded_and_nonexecuting(self): + offering = load_json(ACP_EVIDENCE_OFFERING_EXAMPLE) + + self.assertEqual(offering["name"], "Evidence Verify") + self.assertEqual(offering["priceType"], "fixed") + self.assertGreater(offering["priceValue"], 0) + self.assertGreaterEqual(offering["slaMinutes"], 5) + self.assertFalse(offering["requiredFunds"]) + self.assertTrue(offering["isHidden"]) + self.assertFalse(offering["requirements"].get("additionalProperties", True)) + def test_action_control_schema_enforces_default_deny(self): schema = load_json(ACTION_CONTROL_SCHEMA) validator = Draft202012Validator(schema, format_checker=FormatChecker())