diff --git a/.clusterfuzzlite/_fuzz_seeds.py b/.clusterfuzzlite/_fuzz_seeds.py index 1bdc43a..ae126b2 100644 --- a/.clusterfuzzlite/_fuzz_seeds.py +++ b/.clusterfuzzlite/_fuzz_seeds.py @@ -23,7 +23,7 @@ 'result_digest': {'algorithm': 'sha256', 'value': 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'}, 'run_id': 'run-governed-sdlc-001', - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'target': {'id': 'example/repository', 'kind': 'git.repository'}, 'time_unix_nano': '1787079650000000000', 'workflow_id': 'workflow-build-feature'}, @@ -39,7 +39,7 @@ 'requested_at_unix_nano': '1787079500000000000', 'run_id': 'run-governed-sdlc-001', 'span_id': '00f067aa0ba902b7', - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'time_unix_nano': '1787079580000000000', 'trace_id': '4bf92f3577b34da6a3ce929d0e0e4736'}, 'data-flow': {'agent_id': 'spiffe://example.test/agent/architect', @@ -58,7 +58,7 @@ 'run_id': 'run-governed-sdlc-001', 'size_bytes': 18342, 'source': {'id': 'requirements-graph', 'kind': 'knowledge_graph'}, - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'time_unix_nano': '1787079400000000000', 'transformation': 'metadata_only'}, 'evidence': {'capture_profile': 'metadata_only', @@ -67,10 +67,10 @@ 'completeness': 'complete', 'event_id': '018f0f7d-7a13-7cc2-8000-000000000005', 'event_type': 'evidence.run_closed', - 'producer': {'name': 'agentrust-telemetry', 'version': '0.1.0-alpha.5'}, + 'producer': {'name': 'agentrust-telemetry', 'version': '0.1.0-alpha.6'}, 'run_id': 'run-governed-sdlc-001', 'sequence': 14, - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'time_unix_nano': '1787079700000000000'}, 'policy-decision': {'action_type': 'repository.write', 'agent_id': 'spiffe://example.test/agent/coder', @@ -89,7 +89,7 @@ 'resource_type': 'git.repository', 'run_id': 'run-governed-sdlc-001', 'span_id': '00f067aa0ba902b7', - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'time_unix_nano': '1787079600000000000', 'trace_id': '4bf92f3577b34da6a3ce929d0e0e4736', 'workflow_id': 'workflow-build-feature'}, @@ -108,7 +108,7 @@ 'request_model': 'example-model-2', 'run_id': 'run-governed-sdlc-001', 'scope': 'model_call', - 'spec_version': '0.1.0-alpha.5', + 'spec_version': '0.1.0-alpha.6', 'time_unix_nano': '1787079590000000000', 'workflow_id': 'workflow-build-feature'}} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc5fdb..e5ca137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +## 0.1.0-alpha.6 - 2026-10-01 + +### Fixed + +- TRACE finalization in both SDKs now checks each executed, approval-gated action + against the approval it ran under. Before, a run whose `approval.approved` event + bound one `action_digest` appraised `affirming` even when the `action.executed` + event that followed carried a different digest. An action whose approval + carries a different `action_digest` or `policy_event_id`, or was resolved after + the action completed, now appraises `contraindicated`; a gated action with no + bound approval appraises `warning`. Five run-level cases in + `conformance/appraisal/` are shared by both SDK suites, and the conformance + runner schema-checks their events. + ## 0.1.0-alpha.5 - 2026-09-26 ### Fixed diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 61fe309..6a39418 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -1,6 +1,6 @@ # Limitations -Current `0.1.0-alpha.5` limitations: +Current `0.1.0-alpha.6` limitations: - The contract and SDK are experimental and may change incompatibly. - A Python reference SDK and a TypeScript reference SDK (`packages/typescript/`) diff --git a/README.md b/README.md index b477d7a..1901779 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Portable governance telemetry and verifiable evidence for AI-agent runtimes. This repository defines a backend-neutral contract for policy decisions, approval lifecycles, usage, classified data flows, and evidence lifecycle events. It composes with OpenTelemetry; it is not a tracing backend, policy engine, agent framework, or dashboard. -> **Status:** alpha contract `0.1.0-alpha.5`. No stable SDK API or compatibility guarantee exists yet. +> **Status:** alpha contract `0.1.0-alpha.6`. No stable SDK API or compatibility guarantee exists yet. ## Why diff --git a/SECURITY.md b/SECURITY.md index 87502e7..2e73a63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ Include the affected contract/SDK version, an executable reproducer where safe, ## Supported versions -No stable release exists. Only the latest `0.1.0-alpha.5` revision will receive fixes until the first published release. +No stable release exists. Only the latest `0.1.0-alpha.6` revision will receive fixes until the first published release. ## Runtime boundary diff --git a/VERSIONING.md b/VERSIONING.md index 52be9d9..30ea1a9 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -6,9 +6,9 @@ The telemetry contract uses semantic versioning independently from SDK versions, - Minor: backward-compatible optional field, event, or enum addition with defined older-consumer behavior. - Major: removal, changed meaning, new required field, or incompatible canonicalization. -The current contract version is `0.1.0-alpha.5`. Python publishes the equivalent -PEP 440 version `0.1.0a5`; npm and the Git tag use `0.1.0-alpha.5` and -`v0.1.0-alpha.5`. Pre-1.0 changes may be incompatible and must be called out +The current contract version is `0.1.0-alpha.6`. Python publishes the equivalent +PEP 440 version `0.1.0a6`; npm and the Git tag use `0.1.0-alpha.6` and +`v0.1.0-alpha.6`. Pre-1.0 changes may be incompatible and must be called out explicitly. Published GitHub releases are the sole release trigger. The release tag must diff --git a/compatibility/golden/event-factory.json b/compatibility/golden/event-factory.json index b7a707e..651f08a 100644 --- a/compatibility/golden/event-factory.json +++ b/compatibility/golden/event-factory.json @@ -1 +1 @@ -{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000042","event_type":"usage.recorded","input_tokens":7,"operation":"chat","producer":{"name":"parity-test","version":"1.0.0"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079000000000000","workflow_id":"workflow-1"} +{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000042","event_type":"usage.recorded","input_tokens":7,"operation":"chat","producer":{"name":"parity-test","version":"1.0.0"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079000000000000","workflow_id":"workflow-1"} diff --git a/compatibility/golden/evidence-chain.json b/compatibility/golden/evidence-chain.json index 424e7d9..068ff6e 100644 --- a/compatibility/golden/evidence-chain.json +++ b/compatibility/golden/evidence-chain.json @@ -6,13 +6,13 @@ "fixture": "policy-decision.json", "sequence": 0, "previous_digest": null, - "digest": "b48e0e0b0fe31fc276d78f6ff7dd8b620e27329f675d9971176d14a09f13b044" + "digest": "c3fd2bb7704407815e6cb1ee5b5f3d50508dd497356b526342ea36577e17ae29" }, { "fixture": "usage.json", "sequence": 1, - "previous_digest": "b48e0e0b0fe31fc276d78f6ff7dd8b620e27329f675d9971176d14a09f13b044", - "digest": "0c0f14fcf22fb176dbfc52ba6dfc38c8258cfbfabaedb26569718fd5e1341d1e" + "previous_digest": "c3fd2bb7704407815e6cb1ee5b5f3d50508dd497356b526342ea36577e17ae29", + "digest": "e64ea4ae430587db42355be9885d47e338c3a2ac1d90a15a6f8b815a2a96a31b" } ] } diff --git a/conformance/appraisal/approved-action-completed-before-approval.json b/conformance/appraisal/approved-action-completed-before-approval.json new file mode 100644 index 0000000..b413e7f --- /dev/null +++ b/conformance/appraisal/approved-action-completed-before-approval.json @@ -0,0 +1,156 @@ +{ + "description": "The executed action completed before its approval was resolved.", + "expected_appraisal": "contraindicated", + "events": [ + { + "action_type": "repository.write", + "agent_id": "spiffe://example.test/agent/coder", + "decision": "challenge", + "enforcement_mode": "enforce", + "evaluation_duration_ns": 182000, + "event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "event_type": "policy.decision", + "policy": { + "bundle_digest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "engine": "cedar", + "engine_version": "4.3.0", + "policy_id": "repo-write" + }, + "producer": { + "name": "example-policy-adapter", + "version": "0.1.0" + }, + "reason_codes": [ + "BRANCH_PROTECTED" + ], + "resource_type": "git.repository", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079600000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "workflow_id": "workflow-build-feature" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000010", + "event_type": "approval.requested", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079500000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_id": "reviewer-7d91", + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000002", + "event_type": "approval.approved", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079580000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "agent_id": "spiffe://example.test/agent/architect", + "classification": { + "producer": "example-dlp", + "taxonomy": "example.enterprise.v1", + "value": "confidential" + }, + "content_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "destination": { + "id": "architecture-agent", + "kind": "agent" + }, + "direction": "read", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000004", + "event_type": "data_flow.observed", + "policy_decision": "allow", + "producer": { + "name": "example-dataflow-adapter", + "version": "0.1.0" + }, + "purpose": "generate_architecture", + "run_id": "run-governed-sdlc-001", + "size_bytes": 18342, + "source": { + "id": "requirements-graph", + "kind": "knowledge_graph" + }, + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079400000000000", + "transformation": "metadata_only" + }, + { + "spec_version": "0.1.0-alpha.6", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", + "event_type": "action.executed", + "time_unix_nano": "1787079570000000000", + "run_id": "run-governed-sdlc-001", + "workflow_id": "workflow-build-feature", + "agent_id": "spiffe://example.test/agent/coder", + "producer": { + "name": "example-action-adapter", + "version": "0.1.0" + }, + "action_id": "tool-call-001", + "action_kind": "mcp", + "action_name": "github.create_pull_request", + "operation": "create", + "outcome": "success", + "duration_ns": 42000000, + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "result_digest": { + "algorithm": "sha256", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "target": { + "kind": "git.repository", + "id": "example/repository" + }, + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "approval_id": "approval-001" + } + ] +} diff --git a/conformance/appraisal/approved-action-digest-mismatch.json b/conformance/appraisal/approved-action-digest-mismatch.json new file mode 100644 index 0000000..b7d88d1 --- /dev/null +++ b/conformance/appraisal/approved-action-digest-mismatch.json @@ -0,0 +1,156 @@ +{ + "description": "The executed action names the approval but carries a different action_digest from the one approved.", + "expected_appraisal": "contraindicated", + "events": [ + { + "action_type": "repository.write", + "agent_id": "spiffe://example.test/agent/coder", + "decision": "challenge", + "enforcement_mode": "enforce", + "evaluation_duration_ns": 182000, + "event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "event_type": "policy.decision", + "policy": { + "bundle_digest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "engine": "cedar", + "engine_version": "4.3.0", + "policy_id": "repo-write" + }, + "producer": { + "name": "example-policy-adapter", + "version": "0.1.0" + }, + "reason_codes": [ + "BRANCH_PROTECTED" + ], + "resource_type": "git.repository", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079600000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "workflow_id": "workflow-build-feature" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000010", + "event_type": "approval.requested", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079500000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_id": "reviewer-7d91", + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000002", + "event_type": "approval.approved", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079580000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "agent_id": "spiffe://example.test/agent/architect", + "classification": { + "producer": "example-dlp", + "taxonomy": "example.enterprise.v1", + "value": "confidential" + }, + "content_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "destination": { + "id": "architecture-agent", + "kind": "agent" + }, + "direction": "read", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000004", + "event_type": "data_flow.observed", + "policy_decision": "allow", + "producer": { + "name": "example-dataflow-adapter", + "version": "0.1.0" + }, + "purpose": "generate_architecture", + "run_id": "run-governed-sdlc-001", + "size_bytes": 18342, + "source": { + "id": "requirements-graph", + "kind": "knowledge_graph" + }, + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079400000000000", + "transformation": "metadata_only" + }, + { + "spec_version": "0.1.0-alpha.6", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", + "event_type": "action.executed", + "time_unix_nano": "1787079650000000000", + "run_id": "run-governed-sdlc-001", + "workflow_id": "workflow-build-feature", + "agent_id": "spiffe://example.test/agent/coder", + "producer": { + "name": "example-action-adapter", + "version": "0.1.0" + }, + "action_id": "tool-call-001", + "action_kind": "mcp", + "action_name": "github.create_pull_request", + "operation": "create", + "outcome": "success", + "duration_ns": 42000000, + "action_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "result_digest": { + "algorithm": "sha256", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "target": { + "kind": "git.repository", + "id": "example/repository" + }, + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "approval_id": "approval-001" + } + ] +} diff --git a/conformance/appraisal/approved-action-matches.json b/conformance/appraisal/approved-action-matches.json new file mode 100644 index 0000000..7e1dccd --- /dev/null +++ b/conformance/appraisal/approved-action-matches.json @@ -0,0 +1,156 @@ +{ + "description": "A challenged action runs with the action_digest and policy_event_id its bound, in-date approval approved.", + "expected_appraisal": "affirming", + "events": [ + { + "action_type": "repository.write", + "agent_id": "spiffe://example.test/agent/coder", + "decision": "challenge", + "enforcement_mode": "enforce", + "evaluation_duration_ns": 182000, + "event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "event_type": "policy.decision", + "policy": { + "bundle_digest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "engine": "cedar", + "engine_version": "4.3.0", + "policy_id": "repo-write" + }, + "producer": { + "name": "example-policy-adapter", + "version": "0.1.0" + }, + "reason_codes": [ + "BRANCH_PROTECTED" + ], + "resource_type": "git.repository", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079600000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "workflow_id": "workflow-build-feature" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000010", + "event_type": "approval.requested", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079500000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_id": "reviewer-7d91", + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000002", + "event_type": "approval.approved", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079580000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "agent_id": "spiffe://example.test/agent/architect", + "classification": { + "producer": "example-dlp", + "taxonomy": "example.enterprise.v1", + "value": "confidential" + }, + "content_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "destination": { + "id": "architecture-agent", + "kind": "agent" + }, + "direction": "read", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000004", + "event_type": "data_flow.observed", + "policy_decision": "allow", + "producer": { + "name": "example-dataflow-adapter", + "version": "0.1.0" + }, + "purpose": "generate_architecture", + "run_id": "run-governed-sdlc-001", + "size_bytes": 18342, + "source": { + "id": "requirements-graph", + "kind": "knowledge_graph" + }, + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079400000000000", + "transformation": "metadata_only" + }, + { + "spec_version": "0.1.0-alpha.6", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", + "event_type": "action.executed", + "time_unix_nano": "1787079650000000000", + "run_id": "run-governed-sdlc-001", + "workflow_id": "workflow-build-feature", + "agent_id": "spiffe://example.test/agent/coder", + "producer": { + "name": "example-action-adapter", + "version": "0.1.0" + }, + "action_id": "tool-call-001", + "action_kind": "mcp", + "action_name": "github.create_pull_request", + "operation": "create", + "outcome": "success", + "duration_ns": 42000000, + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "result_digest": { + "algorithm": "sha256", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "target": { + "kind": "git.repository", + "id": "example/repository" + }, + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "approval_id": "approval-001" + } + ] +} diff --git a/conformance/appraisal/approved-action-names-unknown-approval.json b/conformance/appraisal/approved-action-names-unknown-approval.json new file mode 100644 index 0000000..807b746 --- /dev/null +++ b/conformance/appraisal/approved-action-names-unknown-approval.json @@ -0,0 +1,156 @@ +{ + "description": "The executed action names an approval_id that no approval.approved event in the run carries.", + "expected_appraisal": "warning", + "events": [ + { + "action_type": "repository.write", + "agent_id": "spiffe://example.test/agent/coder", + "decision": "challenge", + "enforcement_mode": "enforce", + "evaluation_duration_ns": 182000, + "event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "event_type": "policy.decision", + "policy": { + "bundle_digest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "engine": "cedar", + "engine_version": "4.3.0", + "policy_id": "repo-write" + }, + "producer": { + "name": "example-policy-adapter", + "version": "0.1.0" + }, + "reason_codes": [ + "BRANCH_PROTECTED" + ], + "resource_type": "git.repository", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079600000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "workflow_id": "workflow-build-feature" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000010", + "event_type": "approval.requested", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079500000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_id": "reviewer-7d91", + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000002", + "event_type": "approval.approved", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079580000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "agent_id": "spiffe://example.test/agent/architect", + "classification": { + "producer": "example-dlp", + "taxonomy": "example.enterprise.v1", + "value": "confidential" + }, + "content_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "destination": { + "id": "architecture-agent", + "kind": "agent" + }, + "direction": "read", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000004", + "event_type": "data_flow.observed", + "policy_decision": "allow", + "producer": { + "name": "example-dataflow-adapter", + "version": "0.1.0" + }, + "purpose": "generate_architecture", + "run_id": "run-governed-sdlc-001", + "size_bytes": 18342, + "source": { + "id": "requirements-graph", + "kind": "knowledge_graph" + }, + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079400000000000", + "transformation": "metadata_only" + }, + { + "spec_version": "0.1.0-alpha.6", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", + "event_type": "action.executed", + "time_unix_nano": "1787079650000000000", + "run_id": "run-governed-sdlc-001", + "workflow_id": "workflow-build-feature", + "agent_id": "spiffe://example.test/agent/coder", + "producer": { + "name": "example-action-adapter", + "version": "0.1.0" + }, + "action_id": "tool-call-001", + "action_kind": "mcp", + "action_name": "github.create_pull_request", + "operation": "create", + "outcome": "success", + "duration_ns": 42000000, + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "result_digest": { + "algorithm": "sha256", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "target": { + "kind": "git.repository", + "id": "example/repository" + }, + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "approval_id": "approval-002" + } + ] +} diff --git a/conformance/appraisal/approved-action-policy-mismatch.json b/conformance/appraisal/approved-action-policy-mismatch.json new file mode 100644 index 0000000..1d0fa0d --- /dev/null +++ b/conformance/appraisal/approved-action-policy-mismatch.json @@ -0,0 +1,156 @@ +{ + "description": "The executed action names the approval but a different policy_event_id from the one the approval resolved.", + "expected_appraisal": "contraindicated", + "events": [ + { + "action_type": "repository.write", + "agent_id": "spiffe://example.test/agent/coder", + "decision": "challenge", + "enforcement_mode": "enforce", + "evaluation_duration_ns": 182000, + "event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "event_type": "policy.decision", + "policy": { + "bundle_digest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "engine": "cedar", + "engine_version": "4.3.0", + "policy_id": "repo-write" + }, + "producer": { + "name": "example-policy-adapter", + "version": "0.1.0" + }, + "reason_codes": [ + "BRANCH_PROTECTED" + ], + "resource_type": "git.repository", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079600000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "workflow_id": "workflow-build-feature" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000010", + "event_type": "approval.requested", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079500000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "actor_id": "reviewer-7d91", + "actor_type": "human", + "agent_id": "spiffe://example.test/agent/coder", + "approval_id": "approval-001", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000002", + "event_type": "approval.approved", + "producer": { + "name": "example-approval-adapter", + "version": "0.1.0" + }, + "requested_at_unix_nano": "1787079500000000000", + "run_id": "run-governed-sdlc-001", + "span_id": "00f067aa0ba902b7", + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079580000000000", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000001", + "chain_id": "operators", + "chain_version": "3", + "expires_at_unix_nano": "1787080100000000000" + }, + { + "agent_id": "spiffe://example.test/agent/architect", + "classification": { + "producer": "example-dlp", + "taxonomy": "example.enterprise.v1", + "value": "confidential" + }, + "content_digest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "destination": { + "id": "architecture-agent", + "kind": "agent" + }, + "direction": "read", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000004", + "event_type": "data_flow.observed", + "policy_decision": "allow", + "producer": { + "name": "example-dataflow-adapter", + "version": "0.1.0" + }, + "purpose": "generate_architecture", + "run_id": "run-governed-sdlc-001", + "size_bytes": 18342, + "source": { + "id": "requirements-graph", + "kind": "knowledge_graph" + }, + "spec_version": "0.1.0-alpha.6", + "time_unix_nano": "1787079400000000000", + "transformation": "metadata_only" + }, + { + "spec_version": "0.1.0-alpha.6", + "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", + "event_type": "action.executed", + "time_unix_nano": "1787079650000000000", + "run_id": "run-governed-sdlc-001", + "workflow_id": "workflow-build-feature", + "agent_id": "spiffe://example.test/agent/coder", + "producer": { + "name": "example-action-adapter", + "version": "0.1.0" + }, + "action_id": "tool-call-001", + "action_kind": "mcp", + "action_name": "github.create_pull_request", + "operation": "create", + "outcome": "success", + "duration_ns": 42000000, + "action_digest": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "result_digest": { + "algorithm": "sha256", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "target": { + "kind": "git.repository", + "id": "example/repository" + }, + "policy_event_id": "018f0f7d-7a13-7cc2-8000-000000000099", + "approval_id": "approval-001" + } + ] +} diff --git a/conformance/fixtures/invalid/action-without-digest.json b/conformance/fixtures/invalid/action-without-digest.json index ea04c66..e213968 100644 --- a/conformance/fixtures/invalid/action-without-digest.json +++ b/conformance/fixtures/invalid/action-without-digest.json @@ -1,5 +1,5 @@ { - "spec_version": "0.1.0-alpha.5", + "spec_version": "0.1.0-alpha.6", "event_id": "018f0f7d-7a13-7cc2-8000-000000000096", "event_type": "action.executed", "time_unix_nano": "1787079650000000000", diff --git a/conformance/fixtures/invalid/aggregate-cost-without-rollup.json b/conformance/fixtures/invalid/aggregate-cost-without-rollup.json index 86f1025..e30cf7a 100644 --- a/conformance/fixtures/invalid/aggregate-cost-without-rollup.json +++ b/conformance/fixtures/invalid/aggregate-cost-without-rollup.json @@ -1 +1 @@ -{"agent_id":"agent-1","cost":{"amount":1,"currency":"USD","source":"aggregate"},"event_id":"018f0f7d-7a13-7cc2-8000-000000000009","event_type":"usage.recorded","operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1"} +{"agent_id":"agent-1","cost":{"amount":1,"currency":"USD","source":"aggregate"},"event_id":"018f0f7d-7a13-7cc2-8000-000000000009","event_type":"usage.recorded","operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1"} diff --git a/conformance/fixtures/invalid/model-call-with-rollup.json b/conformance/fixtures/invalid/model-call-with-rollup.json index a383bf5..b00a499 100644 --- a/conformance/fixtures/invalid/model-call-with-rollup.json +++ b/conformance/fixtures/invalid/model-call-with-rollup.json @@ -1 +1 @@ -{"agent_id":"agent-1","aggregation":{"cost_coverage":0,"cost_sources":[],"event_count":1,"method":"sum","token_coverage":{"input_tokens":1}},"event_id":"018f0f7d-7a13-7cc2-8000-000000000010","event_type":"usage.recorded","input_tokens":1,"operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1"} +{"agent_id":"agent-1","aggregation":{"cost_coverage":0,"cost_sources":[],"event_count":1,"method":"sum","token_coverage":{"input_tokens":1}},"event_id":"018f0f7d-7a13-7cc2-8000-000000000010","event_type":"usage.recorded","input_tokens":1,"operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1"} diff --git a/conformance/fixtures/invalid/numeric-timestamp.json b/conformance/fixtures/invalid/numeric-timestamp.json index 5231cf3..cbce58e 100644 --- a/conformance/fixtures/invalid/numeric-timestamp.json +++ b/conformance/fixtures/invalid/numeric-timestamp.json @@ -1 +1 @@ -{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000011","event_type":"usage.recorded","input_tokens":1,"operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":1787079700000000000} +{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000011","event_type":"usage.recorded","input_tokens":1,"operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":1787079700000000000} diff --git a/conformance/fixtures/invalid/raw-content.json b/conformance/fixtures/invalid/raw-content.json index 21e7687..6ff4aca 100644 --- a/conformance/fixtures/invalid/raw-content.json +++ b/conformance/fixtures/invalid/raw-content.json @@ -1 +1 @@ -{"agent_id":"spiffe://example.test/agent/coder","event_id":"018f0f7d-7a13-7cc2-8000-000000000006","event_type":"usage.recorded","input_tokens":10,"operation":"chat","producer":{"name":"bad-adapter","version":"0.1.0"},"prompt":"copy the production credential","run_id":"run-unsafe","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079700000000000"} +{"agent_id":"spiffe://example.test/agent/coder","event_id":"018f0f7d-7a13-7cc2-8000-000000000006","event_type":"usage.recorded","input_tokens":10,"operation":"chat","producer":{"name":"bad-adapter","version":"0.1.0"},"prompt":"copy the production credential","run_id":"run-unsafe","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079700000000000"} diff --git a/conformance/fixtures/invalid/usage-without-measurement.json b/conformance/fixtures/invalid/usage-without-measurement.json index 69ff150..393c2b5 100644 --- a/conformance/fixtures/invalid/usage-without-measurement.json +++ b/conformance/fixtures/invalid/usage-without-measurement.json @@ -1 +1 @@ -{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000008","event_type":"usage.recorded","operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1"} +{"agent_id":"agent-1","event_id":"018f0f7d-7a13-7cc2-8000-000000000008","event_type":"usage.recorded","operation":"chat","producer":{"name":"test","version":"1"},"run_id":"run-1","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1"} diff --git a/conformance/fixtures/invalid/zero-trace-id.json b/conformance/fixtures/invalid/zero-trace-id.json index b517206..43d6490 100644 --- a/conformance/fixtures/invalid/zero-trace-id.json +++ b/conformance/fixtures/invalid/zero-trace-id.json @@ -1 +1 @@ -{"action_type":"repository.read","agent_id":"agent-1","decision":"allow","enforcement_mode":"enforce","evaluation_duration_ns":1,"event_id":"018f0f7d-7a13-7cc2-8000-000000000007","event_type":"policy.decision","policy":{"engine":"opa","engine_version":"1.0"},"producer":{"name":"test","version":"1"},"resource_type":"repository","run_id":"run-1","spec_version":"0.1.0-alpha.5","time_unix_nano":"1","trace_id":"00000000000000000000000000000000"} +{"action_type":"repository.read","agent_id":"agent-1","decision":"allow","enforcement_mode":"enforce","evaluation_duration_ns":1,"event_id":"018f0f7d-7a13-7cc2-8000-000000000007","event_type":"policy.decision","policy":{"engine":"opa","engine_version":"1.0"},"producer":{"name":"test","version":"1"},"resource_type":"repository","run_id":"run-1","spec_version":"0.1.0-alpha.6","time_unix_nano":"1","trace_id":"00000000000000000000000000000000"} diff --git a/conformance/fixtures/valid/action.json b/conformance/fixtures/valid/action.json index bb9ca80..f7bc2da 100644 --- a/conformance/fixtures/valid/action.json +++ b/conformance/fixtures/valid/action.json @@ -1,5 +1,5 @@ { - "spec_version": "0.1.0-alpha.5", + "spec_version": "0.1.0-alpha.6", "event_id": "018f0f7d-7a13-7cc2-8000-000000000006", "event_type": "action.executed", "time_unix_nano": "1787079650000000000", diff --git a/conformance/fixtures/valid/approval.json b/conformance/fixtures/valid/approval.json index 1d49bb7..55daab7 100644 --- a/conformance/fixtures/valid/approval.json +++ b/conformance/fixtures/valid/approval.json @@ -1 +1 @@ -{"action_digest":{"algorithm":"sha256","value":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"actor_id":"reviewer-7d91","actor_type":"human","agent_id":"spiffe://example.test/agent/coder","approval_id":"approval-001","event_id":"018f0f7d-7a13-7cc2-8000-000000000002","event_type":"approval.approved","producer":{"name":"example-approval-adapter","version":"0.1.0"},"requested_at_unix_nano":"1787079500000000000","run_id":"run-governed-sdlc-001","span_id":"00f067aa0ba902b7","spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079580000000000","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"} +{"action_digest":{"algorithm":"sha256","value":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"actor_id":"reviewer-7d91","actor_type":"human","agent_id":"spiffe://example.test/agent/coder","approval_id":"approval-001","event_id":"018f0f7d-7a13-7cc2-8000-000000000002","event_type":"approval.approved","producer":{"name":"example-approval-adapter","version":"0.1.0"},"requested_at_unix_nano":"1787079500000000000","run_id":"run-governed-sdlc-001","span_id":"00f067aa0ba902b7","spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079580000000000","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"} diff --git a/conformance/fixtures/valid/data-flow.json b/conformance/fixtures/valid/data-flow.json index 2927b46..9ac5e63 100644 --- a/conformance/fixtures/valid/data-flow.json +++ b/conformance/fixtures/valid/data-flow.json @@ -1 +1 @@ -{"agent_id":"spiffe://example.test/agent/architect","classification":{"producer":"example-dlp","taxonomy":"example.enterprise.v1","value":"confidential"},"content_digest":{"algorithm":"sha256","value":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"destination":{"id":"architecture-agent","kind":"agent"},"direction":"read","event_id":"018f0f7d-7a13-7cc2-8000-000000000004","event_type":"data_flow.observed","policy_decision":"allow","producer":{"name":"example-dataflow-adapter","version":"0.1.0"},"purpose":"generate_architecture","run_id":"run-governed-sdlc-001","size_bytes":18342,"source":{"id":"requirements-graph","kind":"knowledge_graph"},"spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079400000000000","transformation":"metadata_only"} +{"agent_id":"spiffe://example.test/agent/architect","classification":{"producer":"example-dlp","taxonomy":"example.enterprise.v1","value":"confidential"},"content_digest":{"algorithm":"sha256","value":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"destination":{"id":"architecture-agent","kind":"agent"},"direction":"read","event_id":"018f0f7d-7a13-7cc2-8000-000000000004","event_type":"data_flow.observed","policy_decision":"allow","producer":{"name":"example-dataflow-adapter","version":"0.1.0"},"purpose":"generate_architecture","run_id":"run-governed-sdlc-001","size_bytes":18342,"source":{"id":"requirements-graph","kind":"knowledge_graph"},"spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079400000000000","transformation":"metadata_only"} diff --git a/conformance/fixtures/valid/evidence.json b/conformance/fixtures/valid/evidence.json index 4c059f5..e3345f6 100644 --- a/conformance/fixtures/valid/evidence.json +++ b/conformance/fixtures/valid/evidence.json @@ -1 +1 @@ -{"capture_profile":"metadata_only","chain_digest":{"algorithm":"sha256","value":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"completeness":"complete","event_id":"018f0f7d-7a13-7cc2-8000-000000000005","event_type":"evidence.run_closed","producer":{"name":"agentrust-telemetry","version":"0.1.0-alpha.5"},"run_id":"run-governed-sdlc-001","sequence":14,"spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079700000000000"} +{"capture_profile":"metadata_only","chain_digest":{"algorithm":"sha256","value":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"completeness":"complete","event_id":"018f0f7d-7a13-7cc2-8000-000000000005","event_type":"evidence.run_closed","producer":{"name":"agentrust-telemetry","version":"0.1.0-alpha.6"},"run_id":"run-governed-sdlc-001","sequence":14,"spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079700000000000"} diff --git a/conformance/fixtures/valid/policy-decision.json b/conformance/fixtures/valid/policy-decision.json index b0afe51..9be6cb8 100644 --- a/conformance/fixtures/valid/policy-decision.json +++ b/conformance/fixtures/valid/policy-decision.json @@ -1 +1 @@ -{"action_type":"repository.write","agent_id":"spiffe://example.test/agent/coder","decision":"deny","enforcement_mode":"enforce","evaluation_duration_ns":182000,"event_id":"018f0f7d-7a13-7cc2-8000-000000000001","event_type":"policy.decision","policy":{"bundle_digest":{"algorithm":"sha256","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"engine":"cedar","engine_version":"4.3.0","policy_id":"repo-write"},"producer":{"name":"example-policy-adapter","version":"0.1.0"},"reason_codes":["BRANCH_PROTECTED"],"resource_type":"git.repository","run_id":"run-governed-sdlc-001","span_id":"00f067aa0ba902b7","spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079600000000000","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","workflow_id":"workflow-build-feature"} +{"action_type":"repository.write","agent_id":"spiffe://example.test/agent/coder","decision":"deny","enforcement_mode":"enforce","evaluation_duration_ns":182000,"event_id":"018f0f7d-7a13-7cc2-8000-000000000001","event_type":"policy.decision","policy":{"bundle_digest":{"algorithm":"sha256","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"engine":"cedar","engine_version":"4.3.0","policy_id":"repo-write"},"producer":{"name":"example-policy-adapter","version":"0.1.0"},"reason_codes":["BRANCH_PROTECTED"],"resource_type":"git.repository","run_id":"run-governed-sdlc-001","span_id":"00f067aa0ba902b7","spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079600000000000","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","workflow_id":"workflow-build-feature"} diff --git a/conformance/fixtures/valid/usage.json b/conformance/fixtures/valid/usage.json index 41fc5a8..88e82bb 100644 --- a/conformance/fixtures/valid/usage.json +++ b/conformance/fixtures/valid/usage.json @@ -1 +1 @@ -{"agent_id":"spiffe://example.test/agent/coder","cost":{"amount":0.0124,"currency":"USD","pricing_version":"provider-2026-08-01","source":"provider"},"event_id":"018f0f7d-7a13-7cc2-8000-000000000003","event_type":"usage.recorded","input_tokens":4200,"operation":"chat","output_tokens":812,"producer":{"name":"example-usage-adapter","version":"0.1.0"},"provider":"example-ai","request_model":"example-model-2","run_id":"run-governed-sdlc-001","scope":"model_call","spec_version":"0.1.0-alpha.5","time_unix_nano":"1787079590000000000","workflow_id":"workflow-build-feature"} +{"agent_id":"spiffe://example.test/agent/coder","cost":{"amount":0.0124,"currency":"USD","pricing_version":"provider-2026-08-01","source":"provider"},"event_id":"018f0f7d-7a13-7cc2-8000-000000000003","event_type":"usage.recorded","input_tokens":4200,"operation":"chat","output_tokens":812,"producer":{"name":"example-usage-adapter","version":"0.1.0"},"provider":"example-ai","request_model":"example-model-2","run_id":"run-governed-sdlc-001","scope":"model_call","spec_version":"0.1.0-alpha.6","time_unix_nano":"1787079590000000000","workflow_id":"workflow-build-feature"} diff --git a/conformance/runner/validate.py b/conformance/runner/validate.py index 11191ec..5379046 100644 --- a/conformance/runner/validate.py +++ b/conformance/runner/validate.py @@ -15,6 +15,8 @@ ROOT = Path(__file__).resolve().parents[2] SCHEMA_DIR = ROOT / "spec" / "schema" FIXTURE_DIR = ROOT / "conformance" / "fixtures" +APPRAISAL_DIR = ROOT / "conformance" / "appraisal" +APPRAISAL_STATUSES = {"affirming", "warning", "contraindicated", "none"} EVENT_SCHEMAS = { "action.executed": "action.schema.json", @@ -118,6 +120,42 @@ def run_suite(fixtures: Path = FIXTURE_DIR) -> int: failures += 1 for error in errors or ["invalid fixture unexpectedly passed"]: print(f" {error}") + failures += run_appraisal_cases() + return failures + + +def validate_appraisal_case(path: Path) -> list[str]: + """Each run-level case must be well formed and every event schema-valid. + + The expected appraisal itself is asserted by each SDK's test suite, which + finalizes the case; this runner checks only what it can without an SDK. + """ + try: + case = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"invalid JSON: {exc}"] + if not isinstance(case, dict) or not isinstance(case.get("events"), list): + return ["case must be an object with an events array"] + errors: list[str] = [] + if case.get("expected_appraisal") not in APPRAISAL_STATUSES: + errors.append(f"expected_appraisal must be one of {sorted(APPRAISAL_STATUSES)}") + for index, event in enumerate(case["events"]): + if not isinstance(event, dict): + errors.append(f"events[{index}]: must be an object") + continue + errors.extend(f"events[{index}]{error[1:]}" for error in validate_record(event)) + return errors + + +def run_appraisal_cases(directory: Path = APPRAISAL_DIR) -> int: + failures = 0 + for path in sorted(directory.glob("*.json")): + errors = validate_appraisal_case(path) + print(f"{'PASS' if not errors else 'FAIL'} {path.relative_to(ROOT)}") + if errors: + failures += 1 + for error in errors: + print(f" {error}") return failures diff --git a/docs/action-events.md b/docs/action-events.md index c422366..cacc153 100644 --- a/docs/action-events.md +++ b/docs/action-events.md @@ -9,6 +9,30 @@ the action may additionally name its `approval_id` and governing `policy_event_id`. The telemetry contract validates these fields but does not prove that a producer computed or linked them honestly. +## Approval binding + +An `action.executed` event is approval-gated when it names an `approval_id`, +or names a `policy_event_id` whose `policy.decision` is `challenge`. A gated +action with an outcome other than `denied` must run under an `approval.approved` +event that: + +- carries the same `approval_id` (or, when the action names none, the same + `policy_event_id`); +- carries an `action_digest` with the same `algorithm` and `value` as the + action's `action_digest`; +- names the same `policy_event_id` as the action, when the action names one; +- is bound to its `approval.requested` event and resolved inside that request's + window, as described in [TRACE finalization](trace-finalization.md); and +- has a `time_unix_nano` no later than the action's `time_unix_nano`. + +A `denied` attempt did not run, so it is exempt. These rules are checked across +the run's evidence; a single event cannot show a violation, so the per-event +schemas do not change. The run-level cases in `conformance/appraisal/` cover a +matching action and each way a gated action can fail to match. + +Binding relies on the producer naming `approval_id` or `policy_event_id` on the +action. An action that names neither is not gated by this check. + `outcome` distinguishes success, error, denial, cancellation, and timeout. Denied attempts are still resolved attempts and therefore count in a TRACE tool transcript. The event intentionally carries no arguments, results, source code, diff --git a/docs/evidence-chain.md b/docs/evidence-chain.md index 70757d1..38b68c5 100644 --- a/docs/evidence-chain.md +++ b/docs/evidence-chain.md @@ -1,6 +1,6 @@ # Evidence chain profile -Status: experimental `rfc8785-jcs-v1` for contract `0.1.0-alpha.5`. +Status: experimental `rfc8785-jcs-v1` for contract `0.1.0-alpha.6`. Each accepted event is validated and privacy-checked before entering the chain. Sequence numbers start at zero and represent acceptance order, not event time. diff --git a/docs/otel-genai-compatibility.md b/docs/otel-genai-compatibility.md index b427fc3..a71b6aa 100644 --- a/docs/otel-genai-compatibility.md +++ b/docs/otel-genai-compatibility.md @@ -1,6 +1,6 @@ # OpenTelemetry GenAI compatibility -Status: pinned compatibility assessment for AgentTrust contract `0.1.0-alpha.5`. +Status: pinned compatibility assessment for AgentTrust contract `0.1.0-alpha.6`. The machine-readable matrix is [`compatibility/otel-genai.json`](../compatibility/otel-genai.json). It is pinned to OpenTelemetry's dedicated GenAI semantic-conventions repository diff --git a/docs/otel-projection.md b/docs/otel-projection.md index 3256d04..7fee2a2 100644 --- a/docs/otel-projection.md +++ b/docs/otel-projection.md @@ -1,6 +1,6 @@ # OpenTelemetry projection profile -Status: experimental for contract `0.1.0-alpha.5`. +Status: experimental for contract `0.1.0-alpha.6`. The normalized JSON event is the source contract. OpenTelemetry is a projection, not an alternative event model. diff --git a/docs/trace-finalization.md b/docs/trace-finalization.md index 52761e3..d8d3c9b 100644 --- a/docs/trace-finalization.md +++ b/docs/trace-finalization.md @@ -34,3 +34,28 @@ structural validation, public-key derivation, and signature verification. The finalizer invokes all four steps and fails closed. It does not substitute a local shape check for official TRACE validation. Tool transcript bytes use RFC 8785 JCS in both SDKs. + +## Appraisal + +The adapter derives `appraisal.status` from the sealed evidence, worst first: + +- `contraindicated` when any policy decision is `deny` or `error`; when the run + carries `approval.rejected`, `approval.expired`, or + `approval.execution_failed`; or when an approval-gated action contradicts its + approval: the approval for it carries a different `action_digest` or + `policy_event_id`, or was resolved after the action completed. The evidence + then shows that something other than the approved action ran, so the + appraisal failed. +- `warning` when a `challenge` decision has no bound approval, when the run + carries `approval.cancelled`, or when an approval-gated action has no bound + approval at all. +- `affirming` when policy or approval evidence is present and none of the + above applies. +- `none` when there is no policy or approval evidence. + +An approval resolves a challenge only when an `approval.approved` event matches +an `approval.requested` event on `approval_id`, `policy_event_id`, +`action_digest`, `chain_id`, `chain_version`, `requested_at_unix_nano` and +`expires_at_unix_nano`, and its `time_unix_nano` falls inside the request's +window. The approval-gated action rules are in +[Action execution events](action-events.md#approval-binding). diff --git a/examples/manual_governance.py b/examples/manual_governance.py index 0c0f0db..fd7f62f 100644 --- a/examples/manual_governance.py +++ b/examples/manual_governance.py @@ -14,7 +14,7 @@ def emit(self, record): event = { - "spec_version": "0.1.0-alpha.5", + "spec_version": "0.1.0-alpha.6", "event_id": str(uuid.uuid4()), "event_type": "policy.decision", "time_unix_nano": str(time.time_ns()), diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 04973a3..8e0189c 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -1,6 +1,6 @@ # `@agentrust-io/telemetry` -TypeScript reference SDK for the AgentTrust Telemetry `0.1.0-alpha.5` contract. +TypeScript reference SDK for the AgentTrust Telemetry `0.1.0-alpha.6` contract. This alpha Node package validates the same closed schemas and metadata-only privacy profile as the Python SDK. It supplies event construction, caller-owned diff --git a/packages/typescript/package-lock.json b/packages/typescript/package-lock.json index b5ac3d0..154ddb6 100644 --- a/packages/typescript/package-lock.json +++ b/packages/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentrust-io/telemetry", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentrust-io/telemetry", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/typescript/package.json b/packages/typescript/package.json index da6642a..7cb6517 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@agentrust-io/telemetry", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "description": "TypeScript reference SDK for AgentTrust governance telemetry", "keywords": ["ai-agents", "governance", "opentelemetry", "observability", "telemetry"], "type": "module", diff --git a/packages/typescript/schemas/envelope.schema.json b/packages/typescript/schemas/envelope.schema.json index 0a15fa4..e6cb7af 100644 --- a/packages/typescript/schemas/envelope.schema.json +++ b/packages/typescript/schemas/envelope.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["spec_version", "event_id", "event_type", "time_unix_nano", "run_id", "producer"], "properties": { - "spec_version": {"const": "0.1.0-alpha.5"}, + "spec_version": {"const": "0.1.0-alpha.6"}, "event_id": {"$ref": "common.schema.json#/$defs/event_id"}, "event_type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$", "maxLength": 128}, "time_unix_nano": {"$ref": "common.schema.json#/$defs/time_unix_nano"}, diff --git a/packages/typescript/src/factory.ts b/packages/typescript/src/factory.ts index a33da5f..f744325 100644 --- a/packages/typescript/src/factory.ts +++ b/packages/typescript/src/factory.ts @@ -26,7 +26,7 @@ export class EventFactory { if (collisions.length) throw new Error(`payload cannot override envelope fields: ${collisions.join(", ")}`); const normalizedPayload = Object.fromEntries(Object.entries(payload).map(([key, value]) => [key, key.endsWith("_at_unix_nano") ? unixNano(value) : value])); const event = { - spec_version: "0.1.0-alpha.5" as const, + spec_version: "0.1.0-alpha.6" as const, event_id: fields.eventId ?? this.eventIdFactory(), event_type: eventType, time_unix_nano: unixNano(fields.timeUnixNano ?? this.clockNs()), run_id: fields.runId, producer: structuredClone(this.#producer), ...normalizedPayload, diff --git a/packages/typescript/src/trace-finalizer.ts b/packages/typescript/src/trace-finalizer.ts index e55ca14..1780107 100644 --- a/packages/typescript/src/trace-finalizer.ts +++ b/packages/typescript/src/trace-finalizer.ts @@ -71,15 +71,52 @@ function appraisal(events: readonly NormalizedEvent[]): string { const decisions = new Map(events.filter((event) => event.event_type === "policy.decision").map((event) => [event.event_id, String(event.decision)])); const approvals = events.filter((event) => event.event_type.startsWith("approval.")); const types = new Set(approvals.map((event) => event.event_type)); - if ([...decisions.values()].some((value) => value === "deny" || value === "error") || ["approval.rejected", "approval.expired", "approval.execution_failed"].some((value) => types.has(value))) return "contraindicated"; - const approved = boundApprovedPolicyEvents(approvals); - if ([...decisions].some(([id, value]) => value === "challenge" && !approved.has(id)) || types.has("approval.cancelled")) return "warning"; + const bound = boundApprovals(approvals); + const actionBinding = actionApprovalBinding(events, decisions, approvals, bound); + if ([...decisions.values()].some((value) => value === "deny" || value === "error") || ["approval.rejected", "approval.expired", "approval.execution_failed"].some((value) => types.has(value)) || actionBinding === "contradicted") return "contraindicated"; + const approved = new Set([...bound].map((event) => String(event.policy_event_id))); + if ([...decisions].some(([id, value]) => value === "challenge" && !approved.has(id)) || types.has("approval.cancelled") || actionBinding === "unresolved") return "warning"; return decisions.size || approvals.length ? "affirming" : "none"; } -function boundApprovedPolicyEvents(approvals: readonly NormalizedEvent[]): Set { +// An action is approval-gated when it names approval_id, or names a +// policy_event_id whose decision was challenge; a denied outcome did not run. +// A gated action is bound only by an approval.approved for the same approval +// (or policy event) with the same action_digest and policy_event_id, resolved +// inside its request window and not after the action completed. An approval +// that disagrees, or postdates the action, contradicts it; no usable approval +// leaves it unresolved. +function actionApprovalBinding(events: readonly NormalizedEvent[], decisions: ReadonlyMap, approvals: readonly NormalizedEvent[], bound: ReadonlySet): "bound" | "unresolved" | "contradicted" { + const approved = approvals.filter((event) => event.event_type === "approval.approved"); + let result: "bound" | "unresolved" = "bound"; + for (const action of events) { + if (action.event_type !== "action.executed" || action.outcome === "denied") continue; + const approvalId = action.approval_id; const policyEventId = action.policy_event_id; + if (approvalId === undefined && decisions.get(String(policyEventId)) !== "challenge") continue; + const candidates = approved.filter((event) => approvalId !== undefined ? event.approval_id === approvalId : event.policy_event_id === policyEventId); + const completedAt = BigInt(action.time_unix_nano); + let contradicted = false, satisfied = false; + for (const approval of candidates) { + const approvalPolicy = approval.policy_event_id; + if (!sameDigest(approval.action_digest, action.action_digest) || (policyEventId !== undefined && approvalPolicy !== undefined && approvalPolicy !== policyEventId) || BigInt(approval.time_unix_nano) > completedAt) contradicted = true; + // Binding to a request requires policy_event_id, so a bound approval + // that reached here names the action's policy event. + else if (bound.has(approval)) satisfied = true; + } + if (satisfied) continue; + if (contradicted) return "contradicted"; + result = "unresolved"; + } + return result; +} +function sameDigest(left: unknown, right: unknown): boolean { + if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) return false; + const a = left as Record, b = right as Record; + return a.algorithm === b.algorithm && a.value === b.value; +} +function boundApprovals(approvals: readonly NormalizedEvent[]): Set { const requests = approvals.filter((event) => event.event_type === "approval.requested"); const bindingFields = ["approval_id", "policy_event_id", "action_digest", "chain_id", "chain_version", "requested_at_unix_nano", "expires_at_unix_nano"] as const; - const resolved = new Set(); + const resolved = new Set(); for (const approval of approvals.filter((event) => event.event_type === "approval.approved")) { for (const request of requests) { if (bindingFields.some((field) => approval[field] === undefined || request[field] === undefined || JSON.stringify(approval[field]) !== JSON.stringify(request[field]))) continue; @@ -88,7 +125,7 @@ function boundApprovedPolicyEvents(approvals: readonly NormalizedEvent[]): Set & { - spec_version: "0.1.0-alpha.5"; + spec_version: "0.1.0-alpha.6"; event_id: string; event_type: string; time_unix_nano: string; diff --git a/packages/typescript/test/trace-finalizer.test.ts b/packages/typescript/test/trace-finalizer.test.ts index b7bc238..8349f6b 100644 --- a/packages/typescript/test/trace-finalizer.test.ts +++ b/packages/typescript/test/trace-finalizer.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; +import {readdirSync, readFileSync} from "node:fs"; import test from "node:test"; import {fileURLToPath} from "node:url"; import {EvidenceAccumulator, finalizeTrace, SchemaValidator, TraceFinalizationError} from "../src/index.js"; @@ -21,7 +21,7 @@ test("finalizer derives claims then signs, validates, and self-verifies", () => }); test("tool transcript uses the shared RFC 8785 sequence/event digest", () => { const record = finalizeTrace(snapshot([fixture("action.json")]), config, {signingKey: "private", codec: new RecordingCodec()}); - assert.deepEqual(record.tool_transcript, {hash: "sha256:69ff36176ca16383fc3c7dc99b488342691a44572c7361673a5fa9fed6b46789", call_count: 1}); + assert.deepEqual(record.tool_transcript, {hash: "sha256:c6a971b931d1b54e98a88184c50cba44084463279186b4cab7401d91354c128e", call_count: 1}); const changed = fixture("action.json"); changed.outcome = "error"; changed.error_type = "remote_error"; assert.notEqual((finalizeTrace(snapshot([changed]), config, {signingKey: "private", codec: new RecordingCodec()}).tool_transcript as Record).hash, (record.tool_transcript as Record).hash); }); @@ -39,6 +39,26 @@ test("linked approval resolves a challenge while an unrelated approval does not" approval.action_digest = request.action_digest; approval.time_unix_nano = "1787080100000000001"; assert.equal((make(request, approval).appraisal as Record).status, "warning"); }); +const finalizeEvents = (events: NormalizedEvent[]) => { const accumulator = new EvidenceAccumulator("run-governed-sdlc-001", validator); events.forEach((event) => accumulator.append(event)); return (finalizeTrace(accumulator.seal("complete"), config, {signingKey: "private", codec: new RecordingCodec()}).appraisal as Record).status; }; +type AppraisalCase = {description: string; expected_appraisal: string; events: NormalizedEvent[]}; +const appraisalCase = (name: string): AppraisalCase => JSON.parse(readFileSync(`${root}/conformance/appraisal/${name}`, "utf8")) as AppraisalCase; +test("an executed action must match the digest its approval approved", () => { + const events = appraisalCase("approved-action-matches.json").events; + assert.equal(finalizeEvents(events), "affirming"); + // The approval binds digest b*64; the action that ran carries c*64. Before + // this check the run still appraised affirming. + const swapped = events.map((event) => ({...event})); swapped.at(-1)!.action_digest = {algorithm: "sha256", value: "c".repeat(64)}; + assert.equal(finalizeEvents(swapped), "contraindicated"); + const unnamed = swapped.map((event) => ({...event})); delete unnamed.at(-1)!.approval_id; + assert.equal(finalizeEvents(unnamed), "contraindicated"); + const denied = swapped.map((event) => ({...event})); denied.at(-1)!.outcome = "denied"; + assert.equal(finalizeEvents(denied), "affirming"); +}); +test("shared appraisal conformance cases", () => { + const names = readdirSync(`${root}/conformance/appraisal`).filter((name) => name.endsWith(".json")).sort(); + assert.ok(names.length >= 2); + for (const name of names) { const item = appraisalCase(name); assert.equal(finalizeEvents(item.events), item.expected_appraisal, name); } +}); test("finalization refuses incomplete evidence, conflicts, unranked data, and missing trust inputs", () => { const open = new EvidenceAccumulator("run-governed-sdlc-001", validator); open.append(fixture("policy-decision.json")); assert.throws(() => finalizeTrace(open.snapshot(), config, {signingKey: "private", codec: new RecordingCodec()}), /sealed/); diff --git a/pyproject.toml b/pyproject.toml index 29b172d..7fc3940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentrust-telemetry" -version = "0.1.0a5" +version = "0.1.0a6" description = "Backend-neutral governance telemetry for AI-agent runtimes" readme = "README.md" requires-python = ">=3.10" diff --git a/spec/VERSION b/spec/VERSION index 8dc312c..88b971b 100644 --- a/spec/VERSION +++ b/spec/VERSION @@ -1 +1 @@ -0.1.0-alpha.5 +0.1.0-alpha.6 diff --git a/spec/schema/envelope.schema.json b/spec/schema/envelope.schema.json index 0a15fa4..e6cb7af 100644 --- a/spec/schema/envelope.schema.json +++ b/spec/schema/envelope.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["spec_version", "event_id", "event_type", "time_unix_nano", "run_id", "producer"], "properties": { - "spec_version": {"const": "0.1.0-alpha.5"}, + "spec_version": {"const": "0.1.0-alpha.6"}, "event_id": {"$ref": "common.schema.json#/$defs/event_id"}, "event_type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$", "maxLength": 128}, "time_unix_nano": {"$ref": "common.schema.json#/$defs/time_unix_nano"}, diff --git a/src/agentrust_telemetry/__init__.py b/src/agentrust_telemetry/__init__.py index e5f59c1..3f1b7e5 100644 --- a/src/agentrust_telemetry/__init__.py +++ b/src/agentrust_telemetry/__init__.py @@ -77,4 +77,4 @@ "finalize_trace", ] -__version__ = "0.1.0a5" +__version__ = "0.1.0a6" diff --git a/src/agentrust_telemetry/adapters/base.py b/src/agentrust_telemetry/adapters/base.py index 269fd9e..4d406dd 100644 --- a/src/agentrust_telemetry/adapters/base.py +++ b/src/agentrust_telemetry/adapters/base.py @@ -55,7 +55,7 @@ def build( raise ValueError(f"payload cannot override envelope fields: {collision}") timestamp = self._clock_ns() if time_unix_nano is None else time_unix_nano event: dict[str, Any] = { - "spec_version": "0.1.0-alpha.5", + "spec_version": "0.1.0-alpha.6", "event_id": event_id or str(self._event_id_factory()), "event_type": event_type, "time_unix_nano": _unix_nano(timestamp), diff --git a/src/agentrust_telemetry/schemas/envelope.schema.json b/src/agentrust_telemetry/schemas/envelope.schema.json index 0a15fa4..e6cb7af 100644 --- a/src/agentrust_telemetry/schemas/envelope.schema.json +++ b/src/agentrust_telemetry/schemas/envelope.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["spec_version", "event_id", "event_type", "time_unix_nano", "run_id", "producer"], "properties": { - "spec_version": {"const": "0.1.0-alpha.5"}, + "spec_version": {"const": "0.1.0-alpha.6"}, "event_id": {"$ref": "common.schema.json#/$defs/event_id"}, "event_type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$", "maxLength": 128}, "time_unix_nano": {"$ref": "common.schema.json#/$defs/time_unix_nano"}, diff --git a/src/agentrust_telemetry/trace_adapter.py b/src/agentrust_telemetry/trace_adapter.py index 41598ab..56932a9 100644 --- a/src/agentrust_telemetry/trace_adapter.py +++ b/src/agentrust_telemetry/trace_adapter.py @@ -230,29 +230,117 @@ def _appraisal(events: list[dict[str, Any]]) -> str: } approvals = [event for event in events if event["event_type"].startswith("approval.")] approval_types = {event["event_type"] for event in approvals} - if set(decisions.values()).intersection({"deny", "error"}) or approval_types.intersection( - {"approval.rejected", "approval.expired", "approval.execution_failed"} + bound_approvals = _bound_approvals(approvals) + action_binding = _action_approval_binding(events, decisions, approvals, bound_approvals) + if ( + set(decisions.values()).intersection({"deny", "error"}) + or approval_types.intersection( + {"approval.rejected", "approval.expired", "approval.execution_failed"} + ) + or action_binding == "contradicted" ): return "contraindicated" - approved_policy_events = _bound_approved_policy_events(approvals) + approved_policy_events = {str(event["policy_event_id"]) for event in bound_approvals} unresolved_challenges = { event_id for event_id, decision in decisions.items() if decision == "challenge" and event_id not in approved_policy_events } - if unresolved_challenges or "approval.cancelled" in approval_types: + if ( + unresolved_challenges + or "approval.cancelled" in approval_types + or action_binding == "unresolved" + ): return "warning" if decisions or approval_types: return "affirming" return "none" -def _bound_approved_policy_events(approvals: list[dict[str, Any]]) -> set[str]: - """Return challenges resolved by a complete, unexpired request/resolution pair.""" +ActionBinding = Literal["bound", "unresolved", "contradicted"] + + +def _action_approval_binding( + events: list[dict[str, Any]], + decisions: dict[str, Any], + approvals: list[dict[str, Any]], + bound_approvals: list[dict[str, Any]], +) -> ActionBinding: + """Check every executed, approval-gated action against the approval it ran under. + + An action is approval-gated when it names an ``approval_id``, or names a + ``policy_event_id`` whose decision was ``challenge``. An action whose + outcome is ``denied`` did not run and is exempt. A gated action is bound + when an ``approval.approved`` event for the same approval (or, without an + ``approval_id``, the same policy event) carries the same ``action_digest``, + names the same ``policy_event_id``, was resolved inside its request window, + and was not resolved after the action completed. + + A recorded approval that disagrees on digest or policy event, or that was + resolved after the action completed, contradicts the action. A gated action + with no approval at all, or only one that is not bound to its request, is + unresolved. + """ + approved = [event for event in approvals if event["event_type"] == "approval.approved"] + bound_ids = {id(event) for event in bound_approvals} + result: ActionBinding = "bound" + for action in events: + if action["event_type"] != "action.executed" or action["outcome"] == "denied": + continue + approval_id = action.get("approval_id") + policy_event_id = action.get("policy_event_id") + if approval_id is None and decisions.get(policy_event_id) != "challenge": + continue + candidates = [ + event + for event in approved + if ( + event["approval_id"] == approval_id + if approval_id is not None + else event.get("policy_event_id") == policy_event_id + ) + ] + completed_at = int(action["time_unix_nano"]) + contradicted = satisfied = False + for approval in candidates: + approval_policy = approval.get("policy_event_id") + if ( + not _same_digest(approval["action_digest"], action["action_digest"]) + or ( + policy_event_id is not None + and approval_policy is not None + and approval_policy != policy_event_id + ) + or int(approval["time_unix_nano"]) > completed_at + ): + contradicted = True + elif id(approval) in bound_ids: + # Binding to a request requires policy_event_id, so a bound + # approval that reached here names the action's policy event. + satisfied = True + if satisfied: + continue + if contradicted: + return "contradicted" + result = "unresolved" + return result + + +def _same_digest(left: Any, right: Any) -> bool: + return ( + isinstance(left, dict) + and isinstance(right, dict) + and left.get("algorithm") == right.get("algorithm") + and left.get("value") == right.get("value") + ) + + +def _bound_approvals(approvals: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return approvals resolved by a complete, unexpired request/resolution pair.""" requests = [ event for event in approvals if event["event_type"] == "approval.requested" ] - resolved: set[str] = set() + resolved: list[dict[str, Any]] = [] binding_fields = ( "approval_id", "policy_event_id", @@ -280,7 +368,7 @@ def _bound_approved_policy_events(approvals: list[dict[str, Any]]) -> set[str]: except (TypeError, ValueError): continue if requested_at <= approved_at <= expires_at: - resolved.add(str(approval["policy_event_id"])) + resolved.append(approval) break return resolved diff --git a/tests/test_repository_gates.py b/tests/test_repository_gates.py index 73e3439..40b1c44 100644 --- a/tests/test_repository_gates.py +++ b/tests/test_repository_gates.py @@ -64,7 +64,7 @@ def test_contract_versions_map_to_ecosystem_spellings(self): "0.1.0-dev": ("0.1.0.dev0", "0.1.0-dev.0"), "0.1.0-alpha.1": ("0.1.0a1", "0.1.0-alpha.1"), "0.1.0-alpha.2": ("0.1.0a2", "0.1.0-alpha.2"), - "0.1.0-alpha.5": ("0.1.0a5", "0.1.0-alpha.5"), + "0.1.0-alpha.6": ("0.1.0a6", "0.1.0-alpha.6"), "0.1.0-beta.2": ("0.1.0b2", "0.1.0-beta.2"), "0.1.0-rc.3": ("0.1.0rc3", "0.1.0-rc.3"), "0.1.0": ("0.1.0", "0.1.0"), @@ -78,10 +78,10 @@ def test_contract_version_rejects_unsupported_spellings(self): check_versions.ecosystem_versions("0.1") def test_release_tag_must_match_contract(self): - self.assertEqual(check_release_tag.validate_tag("v0.1.0-alpha.5"), []) + self.assertEqual(check_release_tag.validate_tag("v0.1.0-alpha.6"), []) self.assertEqual( check_release_tag.validate_tag("v0.1.0"), - ["release tag 'v0.1.0' must equal 'v0.1.0-alpha.5'"], + ["release tag 'v0.1.0' must equal 'v0.1.0-alpha.6'"], ) def test_otel_matrix_matches_shipped_projection(self): diff --git a/tests/test_trace_adapter.py b/tests/test_trace_adapter.py index 1be7fd1..6aa91ad 100644 --- a/tests/test_trace_adapter.py +++ b/tests/test_trace_adapter.py @@ -17,6 +17,9 @@ ) +APPRAISAL_DIR = ROOT / "conformance" / "appraisal" + + def fixture(name): return json.loads((ROOT / "conformance" / "fixtures" / "valid" / name).read_text()) @@ -74,7 +77,7 @@ def test_action_events_create_truthful_tool_transcript(self): self.assertRegex(record["tool_transcript"]["hash"], r"^sha256:[0-9a-f]{64}$") self.assertEqual( record["tool_transcript"]["hash"], - "sha256:69ff36176ca16383fc3c7dc99b488342691a44572c7361673a5fa9fed6b46789", + "sha256:c6a971b931d1b54e98a88184c50cba44084463279186b4cab7401d91354c128e", ) changed = fixture("action.json") @@ -184,6 +187,50 @@ def test_unbound_or_expired_approval_cannot_resolve_policy_challenge(self): ) self.assertEqual(record["appraisal"]["status"], "warning") + def finalize_events(self, events): + accumulator = EvidenceAccumulator("run-governed-sdlc-001", self.validator) + for event in events: + accumulator.append(event) + return finalize_trace( + accumulator.seal(completeness="complete"), self.config, signing_key=self.key + ) + + def test_executed_action_must_match_the_digest_its_approval_approved(self): + case = json.loads( + (APPRAISAL_DIR / "approved-action-matches.json").read_text(encoding="utf-8") + ) + events = case["events"] + self.assertEqual(self.finalize_events(events)["appraisal"]["status"], "affirming") + + # The approval binds digest b*64; the action that ran carries c*64. + # Before this check the run still appraised affirming. + swapped = [dict(event) for event in events] + swapped[-1]["action_digest"] = {"algorithm": "sha256", "value": "c" * 64} + self.assertEqual( + self.finalize_events(swapped)["appraisal"]["status"], "contraindicated" + ) + + # Without approval_id the challenged policy_event_id still gates it. + unnamed = [dict(event) for event in swapped] + del unnamed[-1]["approval_id"] + self.assertEqual( + self.finalize_events(unnamed)["appraisal"]["status"], "contraindicated" + ) + + # A denied attempt did not run, so its digest is not held to the approval. + denied = [dict(event) for event in swapped] + denied[-1]["outcome"] = "denied" + self.assertEqual(self.finalize_events(denied)["appraisal"]["status"], "affirming") + + def test_shared_appraisal_conformance_cases(self): + cases = sorted(APPRAISAL_DIR.glob("*.json")) + self.assertGreaterEqual(len(cases), 2) + for path in cases: + case = json.loads(path.read_text(encoding="utf-8")) + with self.subTest(case=path.name): + record = self.finalize_events(case["events"]) + self.assertEqual(record["appraisal"]["status"], case["expected_appraisal"]) + def test_refuses_unsealed_or_incomplete_evidence(self): accumulator = EvidenceAccumulator("run-governed-sdlc-001", self.validator) accumulator.append(fixture("policy-decision.json"))