From 8345625209de491e4810cc15426533e4b31320b9 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Sat, 3 Oct 2026 11:18:07 +0000 Subject: [PATCH] Release v3 approval lifecycle hardening --- CHANGELOG.md | 38 + README.md | 12 +- VERSION | 2 +- docs/architecture.md | 7 + docs/contracts.md | 80 +- docs/n8n-reference.md | 27 +- examples/approval-envelope.json | 123 +-- examples/approval-policy.json | 4 +- examples/approval-request.json | 8 +- examples/audit-events.json | 68 +- examples/audit-snapshot.json | 6 +- examples/authority-snapshot.json | 4 +- examples/dispatch-record.json | 4 +- examples/edited-envelope.json | 805 +++++++++++++++ examples/email-reply-approval-record.json | 7 +- examples/email-reply-approval.json | 8 +- examples/expired-envelope.json | 446 +++++++++ examples/n8n-approval-workflow.json | 46 +- examples/recovered-envelope.json | 935 ++++++++++++++++++ examples/rejected-envelope.json | 617 ++++++++++++ ...ected-with-settlement-approval-record.json | 5 +- examples/resolution-record.json | 6 +- examples/review-snapshot.json | 6 +- examples/validation-record.json | 4 +- examples/webauthn-approval-record.json | 11 +- schemas/actions/email.send.schema.json | 2 +- schemas/approval-envelope.schema.json | 10 +- schemas/approval-policy.schema.json | 2 +- schemas/approval-record.schema.json | 5 +- schemas/approval-request.schema.json | 2 +- schemas/audit-event.schema.json | 2 +- schemas/audit-snapshot.schema.json | 2 +- schemas/authority-snapshot.schema.json | 5 +- schemas/common.schema.json | 4 +- schemas/dispatch-record.schema.json | 49 +- schemas/proposed-action.schema.json | 2 +- schemas/resolution-record.schema.json | 2 +- schemas/review-snapshot.schema.json | 2 +- schemas/validation-record.schema.json | 2 +- scripts/generate_examples.py | 123 ++- scripts/validate_contracts.py | 414 +++++++- tests/hardening.json | 194 ++++ tests/test_lifecycle_hardening.py | 397 ++++++++ 43 files changed, 4261 insertions(+), 237 deletions(-) create mode 100644 examples/edited-envelope.json create mode 100644 examples/expired-envelope.json create mode 100644 examples/recovered-envelope.json create mode 100644 examples/rejected-envelope.json create mode 100644 tests/hardening.json create mode 100644 tests/test_lifecycle_hardening.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 428b4c1..67ccca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 3.0.0 + +This release enforces the approval and execution boundaries across complete success, +edited-action, refusal, expiry, and recovery lifecycles. + +### Changed + +- Revalidate original and edited payloads against trusted local action schemas, + including action type, released version, schema ID, and exact file digest. +- Require consumed request state before provider invocation, with causal decision, + terminal, consumption, and dispatch times and exclusive expiry boundaries. +- Represent rejection, expiry, cancellation, and failure without inventing approvals + or authority checks. Refused dispatches cannot invoke a provider or allow retries. +- Link dispatch history with `previous_dispatch_id`, stable action and idempotency + identities, fixed attempt ceilings, and contiguous retry attempt numbers. +- Require `policy_evaluation_id` on every decision and bind it to the request policy. + Authority evidence requires an explicit `valid_until` boundary. +- Verify typed audit witnesses against actual records, actors, outcomes, times, and + identities; reject foreign references and captures that precede included evidence. +- Count the complete dispatch quorum only from approvals for one effective action. +- Enforce JSON Patch target existence, exact array bounds, pointer escapes, and JSON + value equality without conflating booleans and numbers. +- Reject duplicate JSON keys at every depth and non-finite numbers at ingress. + +### Added + +- Complete edited-action, rejected, expired, and reconciled-retry examples plus + portable refusal vectors and focused lifecycle regressions. +- n8n reference endpoint contracts for `/v3/` integrations. + +### Migration + +Continue validating stored v2 records with their original schemas. For new v3 records, +add the request policy evaluation ID to every approval and a finite authority validity +window. Link retry and reconciliation records to the preceding dispatch record; retain +the initial consumed dispatch identity and stable idempotency key. Recompute hashes +and signatures after adopting the v3 schema IDs. Do not relabel historical records. + ## 2.1.0 This release adds an optional WebAuthn proof profile for independently verifiable diff --git a/README.md b/README.md index b0f259a..c16e342 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Nine boundaries make the gate real: If the agent still has the side-effect credential, the approval path remains optional from the agent's point of view. -## Contracts in v2.1.0 +## Contracts in v3.0.0 | Contract | Purpose | |---|---| @@ -71,7 +71,7 @@ from the agent's point of view. | [`approval-envelope.schema.json`](schemas/approval-envelope.schema.json) | Portable bundle plus cross-contract profile for a completed lifecycle | | [`actions/email.send.schema.json`](schemas/actions/email.send.schema.json) | Strict reference for composing action-specific payload contracts | -Schema IDs are pinned to the `v2.1.0` release. Pin a released ID in production; do not +Schema IDs are pinned to the `v3.0.0` release. Pin a released ID in production; do not resolve schemas from the mutable default branch. ## Quick start @@ -100,6 +100,14 @@ The complete synthetic lifecycle is in checks IDs, hashes, quorum, expiry, sequence linkage, and attempt ceilings that JSON Schema alone cannot compare across documents. +Version 3 also supplies complete [edited](examples/edited-envelope.json), +[rejected](examples/rejected-envelope.json), [expired](examples/expired-envelope.json), +and [recovered](examples/recovered-envelope.json) lifecycles. Refusal is a normal +terminal result; it requires neither an approving decision nor a provider invocation. +Recovery retains the unknown attempt, a linked reconciliation record, and a bounded +retry with fresh authority. The portable vectors in +[`tests/hardening.json`](tests/hardening.json) cover these boundaries. + [`tests/canonicalization.json`](tests/canonicalization.json) is a language-neutral RFC 8785 corpus with exact canonical UTF-8 bytes and SHA-256 results. Use it to prove that implementations in different languages bind the same document to the same hash. diff --git a/VERSION b/VERSION index 7ec1d6d..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +3.0.0 diff --git a/docs/architecture.md b/docs/architecture.md index fbf9bc0..0ad5cfc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,6 +222,13 @@ release examples and negative corpus. Production systems should perform the same checks in their implementation language at each boundary, not invoke the repository's development script as a network service. +Version 3 requires strict JSON parsing, trusted local payload schema bytes, an exact +policy evaluation on every decision, and a consumed request before provider invocation. +Quorum is evaluated for the cited effective action, not across unrelated edits. +Unknown outcomes and retry history remain append-only; refusal is a complete terminal +path without a fabricated approval or invocation. See the version 3 boundary rules in +[`contracts.md`](contracts.md). + ## Channel choice | Channel | Trust | Latency | Notes | diff --git a/docs/contracts.md b/docs/contracts.md index ae07ae2..831ac05 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -2,8 +2,8 @@ ## Versioning -The release version and every schema's `schema_version` are `2.1.0`. Schema `$id` -values resolve through the immutable `v2.1.0` tag. A consumer may cache schemas by +The release version and every schema's `schema_version` are `3.0.0`. Schema `$id` +values resolve through the immutable `v3.0.0` tag. A consumer may cache schemas by `$id`; it must not replace a cached release schema with content from `main`. - Patch releases may clarify descriptions and add compatible examples or tests. @@ -71,6 +71,73 @@ The envelope validator enforces the rules JSON Schema cannot express: Treat a semantic validation error as a hard refusal with a redacted audit reason. +## Version 3 boundaries + +Parse JSON before schema validation with duplicate-property detection at every depth. +Reject `NaN`, infinities, and overflowing numeric literals. Do not silently choose the +last duplicate property or normalize an ambiguous document before hashing it. + +Use a deployment-owned local catalog of released payload schema bytes. The reference +validator ships `email.send`; `validate_envelope` and `payload_errors` also accept a +trusted schema-ID-to-file mapping for other action types. Custom schemas identify +their action with `x-action-type` and use an immutable `/v/` ID. A payload +schema can have its own version independent of the envelope version. Do not fetch an +agent-selected schema URL. Verify the ID, version, action type, and raw-file digest, +then validate both the original payload and every effective edited payload. + +Every approval requires the request's exact `policy_evaluation_id`, including human +decisions. A resolution ID has one terminal approval identity; delivery retries update +that receipt's delivery state rather than minting another terminal receipt. + +Provider invocation requires a consumed request. Its terminal decision must be +recorded before consumption, and consumption must precede invocation. The consumed +revision must include both decision and consumption transitions after the revision +observed by the approver. Decision, consumption, and invocation must occur strictly +before request expiry; authority is usable only before its required `valid_until`. +The initial dispatch must include the consumed decision and a complete quorum whose +every cited approval binds the same effective action. Votes for different edits cannot +be combined into a quorum. + +### Terminal refusals + +An envelope covers an admitted request. Rejected, expired, cancelled, and failed +requests can have empty approval, resolution, authority, and dispatch arrays where +those steps never occurred. A rejected request still cites its rejecting decision; +every decision retains its durable resolution. Expiry cannot precede the deadline. + +A `not_dispatched` record may have no approval IDs or authority snapshot and must +have `retry_allowed: false`. It cannot carry provider evidence. If no dispatch step +occurred, an expiry or cancellation event, or the recorded rejection, describes the +terminal path without a synthetic invocation. Pending requests are not complete +envelopes. A pre-admission rejection remains a validation record and audit stream, +without an admitted-request envelope. + +### Dispatch history and recovery + +Order dispatch records by recorded history. Each has a unique `dispatch_id`; the +request retains the first ID as its consumed execution identity. Later records name +the immediately preceding record with `previous_dispatch_id`. They preserve the +effective action hash, approval set, stable provider idempotency key, and attempt +ceiling. A new attempt increments `attempt` by exactly one and cannot begin before +the preceding result was recorded. + +Only an authoritative `failed` result permitting retry, or a linked +`reconciled_not_applied` result permitting retry, allows another invocation. Unknown +outcomes require read-only reconciliation. Its record keeps the unknown attempt +number and lookup identity, and cites evidence checked after the unknown result. +Success, unresolved uncertainty, and an exhausted attempt ceiling never grant retry. +Every new invocation requires fresh, active authority; reconciliation does not grant +execution authority. + +### Audit witnesses + +A hash-valid stream must also describe the actual lifecycle. Every lifecycle record +needs a witness on its matching event type. All audit references resolve inside the +envelope, and supplied decision, action, attempt, policy, and result details agree +with the record. Decision actor and time agree with the approval; witness times agree +with the facts they record. Capturing the envelope cannot precede its contained +evidence. Rehashing a false event never makes it a valid witness. + ## WebAuthn approval proof `webauthn-es256` is an optional signature profile for authenticated web approvals. @@ -144,11 +211,18 @@ approvals that satisfy quorum. ## Modification profile -Version 2 uses RFC 6902 operations restricted to `/payload`. Allowed operations are `add`, +Version 3 uses RFC 6902 operations restricted to `/payload`. Allowed operations are `add`, `remove`, `replace`, and `test`. `move` and `copy` are deliberately absent: review interfaces should show explicit before/after values instead of making an approver mentally follow pointer movement. +Array indices are non-negative decimal tokens without leading zeroes. `add` may use +the array length or `-` to append; other operations require an existing index. +`replace`, `remove`, and `test` require an existing member. Reject malformed pointer +escapes and missing parent containers. `test` compares JSON values recursively: +numbers compare numerically, while booleans remain distinct from numbers. Apply the +whole patch to a copy; any failure refuses the approval without altering the proposal. + After applying modifications: 1. validate the complete proposal and action-specific payload again; diff --git a/docs/n8n-reference.md b/docs/n8n-reference.md index 5728179..8637771 100644 --- a/docs/n8n-reference.md +++ b/docs/n8n-reference.md @@ -8,7 +8,7 @@ deployment-owned endpoints below. ### Validator -`POST $APPROVAL_VALIDATOR_URL/v2/validate` +`POST $APPROVAL_VALIDATOR_URL/v3/validate` Input: `{ "schema": "proposed-action.schema.json", "instance": { ... } }` @@ -20,30 +20,37 @@ the pinned release IDs. The returned validation object is a complete ### Gate -- `POST /v2/validations` durably records the validator's result for the exact action. -- `POST /v2/requests` persists policy and request state before notification. -- `POST /v2/review-snapshots` renders and persists the exact decision surface. -- `POST /v2/requests/{id}/resolve` verifies callback signature, nonce, tenant, +- `POST /v3/validations` durably records the validator's result for the exact action. +- `POST /v3/requests` persists policy and request state before notification. +- `POST /v3/review-snapshots` renders and persists the exact decision surface. +- `POST /v3/requests/{id}/resolve` verifies callback signature, nonce, tenant, action hash, review content hash, expiry, status, and expected revision in one transaction. -- `POST /v2/resolutions` stores and delivers the terminal decision receipt. Delivery +- `POST /v3/resolutions` stores and delivers the terminal decision receipt. Delivery retries reuse the same receipt and target hash. -- `POST /v2/requests/{id}/consume` changes one approved request to consumed and returns +- `POST /v3/requests/{id}/consume` changes one approved request to consumed and returns one short-lived dispatch grant. Duplicate consumption returns the original terminal identity or a conflict, never a new grant. -- `POST /v2/authority-snapshots` rechecks the approver, policy, workspace, and +- `POST /v3/authority-snapshots` rechecks the approver, policy, workspace, and dispatcher immediately before provider invocation. The raw callback nonce appears only in the signed review link. The durable request stores its hash. +Version 3 endpoints emit `policy_evaluation_id` on every decision and `valid_until` +on authority snapshots. The dispatcher checks the consumed decision set and counts +quorum only over the exact effective action. Refusal endpoints may return no approving +records or authority snapshot; they never return a usable dispatch grant. Store each +retry or reconciliation with its `previous_dispatch_id`, retaining the initial consumed +dispatch identity. The workflow does not perform automatic provider retries. + ### Audit sink -`POST $APPROVAL_AUDIT_URL/v2/events` accepts a redacted event intent. The sink assigns +`POST $APPROVAL_AUDIT_URL/v3/events` accepts a redacted event intent. The sink assigns sequence, previous hash, event hash, ingestion time, classification, and retention under an append-only transaction. Do not let n8n clients choose sequence or hashes. -`POST $APPROVAL_AUDIT_URL/v2/snapshots` returns a complete export manifest after a +`POST $APPROVAL_AUDIT_URL/v3/snapshots` returns a complete export manifest after a terminal event. Verify its event count, sequence bounds, event-set digest, and terminal chain hash before archiving the run. diff --git a/examples/approval-envelope.json b/examples/approval-envelope.json index 85a1449..a5c16be 100644 --- a/examples/approval-envelope.json +++ b/examples/approval-envelope.json @@ -1,8 +1,8 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "captured_at": "2026-04-28T09:14:35Z", "proposal": { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", "agent": { @@ -27,9 +27,9 @@ ], "action_type": "email.send", "payload_schema": { - "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/actions/email.send.schema.json", - "version": "2.1.0", - "digest": "sha256:8129956be02f5c6a79919c2312fb96634974d9ca49e9dce9b717cd821b981b3a" + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" }, "payload": { "from": "support@acme-corp.example", @@ -51,10 +51,10 @@ }, "validations": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "validation_id": "validation_2026-04-28_841ac000", "proposal_id": "prop_2026-04-28_b3f1a2c4", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "validator": { "kind": "system", "identifier": "approval-validator", @@ -93,16 +93,16 @@ ], "review_snapshots": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "snapshot_id": "review_2026-04-28_47b2c000", "proposal_id": "prop_2026-04-28_b3f1a2c4", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", "rendered_at": "2026-04-28T09:14:26Z", "renderer": { "name": "approval-email-renderer", - "version": "2.1.0", + "version": "3.0.0", "format": "json" }, "locale": "en-US", @@ -138,12 +138,12 @@ } ], "request": { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", "tenant": "acme-corp", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "validation_id": "validation_2026-04-28_841ac000", "review_snapshot_id": "review_2026-04-28_47b2c000", "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", @@ -162,12 +162,12 @@ } }, "policy": { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "policy_id": "policy.high-risk-email.v1", "revision": "2026-04-15.3", "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", "evaluation_id": "eval_2026-04-28_1c4f8a00", - "material_input_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "evaluated_at": "2026-04-28T09:14:24Z", "requirements": { "minimum_approvals": 1, @@ -218,12 +218,12 @@ }, "approvals": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "approval_id": "approval_2026-04-28_f9821000", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_revision": 1, - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "review_snapshot_id": "review_2026-04-28_47b2c000", "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", @@ -253,19 +253,20 @@ "key_id": "tenant-acme-2026-q2", "value": "H4f8GxQqI5kTMs7b4Y8cn2RlpdVLz0WGv8VUW0Sm5OU" } - ] + ], + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" } ], "resolutions": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "resolution_id": "resolution_2026-04-28_78ac1000", "approval_id": "approval_2026-04-28_f9821000", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", - "approval_record_hash": "sha256:b8f5541033591193b0983be7184f5da1e4c65be699edea65ea2f4d05aeb2a0b2", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "approval_record_hash": "sha256:a3eaa0a9ea1e1b88b88de8d67fd7c58c1d66c05c7d274bf5ad130de46ec7c0e8", "decision": "approved", "terminal": true, "disposition": "execute_once", @@ -282,11 +283,11 @@ ], "authority_snapshots": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "authority_snapshot_id": "authority_2026-04-28_9bc31000", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_id": "req_2026-04-28_6a2d8800", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "checked_at": "2026-04-28T09:14:33Z", "valid_until": "2026-04-28T09:15:33Z", "status": "active", @@ -335,7 +336,7 @@ ], "dispatches": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "dispatch_id": "dispatch_2026-04-28_4ad31000", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_id": "req_2026-04-28_6a2d8800", @@ -343,7 +344,7 @@ "approval_2026-04-28_f9821000" ], "authority_snapshot_id": "authority_2026-04-28_9bc31000", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "idempotency_key": "idem_email_ZD-48211_reply_01", "attempt": 1, "max_attempts": 3, @@ -364,12 +365,12 @@ ], "audit_events": [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000001", "stream_id": "stream_prop_b3f1a2c4", "sequence": 1, "previous_event_hash": null, - "event_hash": "sha256:dfe3245d703176922cc69cc152889d3361e7a662a524617daa18b50db42adaf8", + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", "event_type": "proposal.received", "occurred_at": "2026-04-28T09:14:23Z", "recorded_at": "2026-04-28T09:14:24Z", @@ -393,16 +394,16 @@ }, "references": {}, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000002", "stream_id": "stream_prop_b3f1a2c4", "sequence": 2, - "previous_event_hash": "sha256:dfe3245d703176922cc69cc152889d3361e7a662a524617daa18b50db42adaf8", - "event_hash": "sha256:271755cafe782adebdd23277f392fc4d170e209b91cd10ea8ebb85328ebf9ebb", + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", "event_type": "validation.passed", "occurred_at": "2026-04-28T09:14:24Z", "recorded_at": "2026-04-28T09:14:24Z", @@ -428,16 +429,16 @@ "validation_id": "validation_2026-04-28_841ac000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000003", "stream_id": "stream_prop_b3f1a2c4", "sequence": 3, - "previous_event_hash": "sha256:271755cafe782adebdd23277f392fc4d170e209b91cd10ea8ebb85328ebf9ebb", - "event_hash": "sha256:4e47f1a2899efedcda707fc94f76d609a4b1abd9970f798765bed68e1bf2af57", + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", "event_type": "approval.requested", "occurred_at": "2026-04-28T09:14:25Z", "recorded_at": "2026-04-28T09:14:25Z", @@ -465,17 +466,17 @@ "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "policy_revision": "2026-04-15.3" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000004", "stream_id": "stream_prop_b3f1a2c4", "sequence": 4, - "previous_event_hash": "sha256:4e47f1a2899efedcda707fc94f76d609a4b1abd9970f798765bed68e1bf2af57", - "event_hash": "sha256:c93a9daecd69eb6b2f2e0478d2c63cbe3b8fa723a52f864ba3676da2dcb61104", + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", "event_type": "review.presented", "occurred_at": "2026-04-28T09:14:26Z", "recorded_at": "2026-04-28T09:14:26Z", @@ -502,18 +503,18 @@ "review_snapshot_id": "review_2026-04-28_47b2c000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000005", "stream_id": "stream_prop_b3f1a2c4", "sequence": 5, - "previous_event_hash": "sha256:c93a9daecd69eb6b2f2e0478d2c63cbe3b8fa723a52f864ba3676da2dcb61104", - "event_hash": "sha256:e8f430c610686e6bcdcbf10ae0330ed52c6dcb841d921489ca02b43a5d306f42", + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", "event_type": "approval.decided", "occurred_at": "2026-04-28T09:14:30Z", "recorded_at": "2026-04-28T09:14:31Z", @@ -546,19 +547,19 @@ "detail": { "decision": "approved", "channel": "email", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "policy_revision": "2026-04-15.3", "wait_ms": 7000 } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000006", "stream_id": "stream_prop_b3f1a2c4", "sequence": 6, - "previous_event_hash": "sha256:e8f430c610686e6bcdcbf10ae0330ed52c6dcb841d921489ca02b43a5d306f42", - "event_hash": "sha256:c9193e5776df3ef2e2b529e379fb381503b1ca235f4be2b0de5128f05990adf3", + "previous_event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", + "event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", "event_type": "resolution.emitted", "occurred_at": "2026-04-28T09:14:32Z", "recorded_at": "2026-04-28T09:14:32Z", @@ -587,16 +588,16 @@ }, "detail": { "decision": "approved", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000007", "stream_id": "stream_prop_b3f1a2c4", "sequence": 7, - "previous_event_hash": "sha256:c9193e5776df3ef2e2b529e379fb381503b1ca235f4be2b0de5128f05990adf3", - "event_hash": "sha256:64865c5cd326e07b08f4e71850dad1f8f41c6c02635467b27955812f83fbd3fe", + "previous_event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", + "event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", "event_type": "authority.checked", "occurred_at": "2026-04-28T09:14:33Z", "recorded_at": "2026-04-28T09:14:33Z", @@ -624,16 +625,16 @@ "authority_snapshot_id": "authority_2026-04-28_9bc31000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000008", "stream_id": "stream_prop_b3f1a2c4", "sequence": 8, - "previous_event_hash": "sha256:64865c5cd326e07b08f4e71850dad1f8f41c6c02635467b27955812f83fbd3fe", - "event_hash": "sha256:7f05b65a76687d234efa8469d4c7687c2ce94738764537e491c1b05cecf862d1", + "previous_event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", + "event_hash": "sha256:da01aa5134e17e13187f2046b5304750d85c8ea3c7ff6c49a018909c0aa65f4d", "event_type": "dispatch.succeeded", "occurred_at": "2026-04-28T09:14:34Z", "recorded_at": "2026-04-28T09:14:34Z", @@ -661,18 +662,18 @@ "dispatch_id": "dispatch_2026-04-28_4ad31000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "attempt": 1, "provider_result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000009", "stream_id": "stream_prop_b3f1a2c4", "sequence": 9, - "previous_event_hash": "sha256:7f05b65a76687d234efa8469d4c7687c2ce94738764537e491c1b05cecf862d1", - "event_hash": "sha256:5f91b7d2df0e1d5659a53a863ea4ac5fa2c16ad3deb6fd447bb184bb931bcb46", + "previous_event_hash": "sha256:da01aa5134e17e13187f2046b5304750d85c8ea3c7ff6c49a018909c0aa65f4d", + "event_hash": "sha256:22f923ad679f6d3b129733c86f20654350695be1af82883571672c9d990fed6b", "event_type": "audit.snapshotted", "occurred_at": "2026-04-28T09:14:35Z", "recorded_at": "2026-04-28T09:14:35Z", @@ -701,7 +702,7 @@ } ], "audit_snapshot": { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "snapshot_id": "audit_snapshot_2026-04-28_1df41000", "stream_id": "stream_prop_b3f1a2c4", "captured_at": "2026-04-28T09:14:35Z", @@ -709,7 +710,7 @@ "entry_count": 9, "first_sequence": 1, "last_sequence": 9, - "events_digest": "sha256:b4050f1b1cb9f6419956f5f94db2d234a7bd695f817ce3f45ec0fcf1c65d40d1", - "root_event_hash": "sha256:5f91b7d2df0e1d5659a53a863ea4ac5fa2c16ad3deb6fd447bb184bb931bcb46" + "events_digest": "sha256:832fc218e4a91f42b16a48d06cc0273269af9b79509b6afb6e7ff501269b7093", + "root_event_hash": "sha256:22f923ad679f6d3b129733c86f20654350695be1af82883571672c9d990fed6b" } } diff --git a/examples/approval-policy.json b/examples/approval-policy.json index 4418dc3..9eb7d09 100644 --- a/examples/approval-policy.json +++ b/examples/approval-policy.json @@ -1,10 +1,10 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "policy_id": "policy.high-risk-email.v1", "revision": "2026-04-15.3", "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", "evaluation_id": "eval_2026-04-28_1c4f8a00", - "material_input_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "evaluated_at": "2026-04-28T09:14:24Z", "requirements": { "minimum_approvals": 1, diff --git a/examples/approval-request.json b/examples/approval-request.json index 21360d1..ad656a4 100644 --- a/examples/approval-request.json +++ b/examples/approval-request.json @@ -1,10 +1,10 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", "tenant": "acme-corp", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "validation_id": "validation_2026-04-28_841ac000", "review_snapshot_id": "review_2026-04-28_47b2c000", "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", @@ -23,12 +23,12 @@ } }, "policy": { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "policy_id": "policy.high-risk-email.v1", "revision": "2026-04-15.3", "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", "evaluation_id": "eval_2026-04-28_1c4f8a00", - "material_input_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "evaluated_at": "2026-04-28T09:14:24Z", "requirements": { "minimum_approvals": 1, diff --git a/examples/audit-events.json b/examples/audit-events.json index 05fe5e0..3aee6f2 100644 --- a/examples/audit-events.json +++ b/examples/audit-events.json @@ -1,11 +1,11 @@ [ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000001", "stream_id": "stream_prop_b3f1a2c4", "sequence": 1, "previous_event_hash": null, - "event_hash": "sha256:dfe3245d703176922cc69cc152889d3361e7a662a524617daa18b50db42adaf8", + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", "event_type": "proposal.received", "occurred_at": "2026-04-28T09:14:23Z", "recorded_at": "2026-04-28T09:14:24Z", @@ -29,16 +29,16 @@ }, "references": {}, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000002", "stream_id": "stream_prop_b3f1a2c4", "sequence": 2, - "previous_event_hash": "sha256:dfe3245d703176922cc69cc152889d3361e7a662a524617daa18b50db42adaf8", - "event_hash": "sha256:271755cafe782adebdd23277f392fc4d170e209b91cd10ea8ebb85328ebf9ebb", + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", "event_type": "validation.passed", "occurred_at": "2026-04-28T09:14:24Z", "recorded_at": "2026-04-28T09:14:24Z", @@ -64,16 +64,16 @@ "validation_id": "validation_2026-04-28_841ac000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000003", "stream_id": "stream_prop_b3f1a2c4", "sequence": 3, - "previous_event_hash": "sha256:271755cafe782adebdd23277f392fc4d170e209b91cd10ea8ebb85328ebf9ebb", - "event_hash": "sha256:4e47f1a2899efedcda707fc94f76d609a4b1abd9970f798765bed68e1bf2af57", + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", "event_type": "approval.requested", "occurred_at": "2026-04-28T09:14:25Z", "recorded_at": "2026-04-28T09:14:25Z", @@ -101,17 +101,17 @@ "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "policy_revision": "2026-04-15.3" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000004", "stream_id": "stream_prop_b3f1a2c4", "sequence": 4, - "previous_event_hash": "sha256:4e47f1a2899efedcda707fc94f76d609a4b1abd9970f798765bed68e1bf2af57", - "event_hash": "sha256:c93a9daecd69eb6b2f2e0478d2c63cbe3b8fa723a52f864ba3676da2dcb61104", + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", "event_type": "review.presented", "occurred_at": "2026-04-28T09:14:26Z", "recorded_at": "2026-04-28T09:14:26Z", @@ -138,18 +138,18 @@ "review_snapshot_id": "review_2026-04-28_47b2c000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000005", "stream_id": "stream_prop_b3f1a2c4", "sequence": 5, - "previous_event_hash": "sha256:c93a9daecd69eb6b2f2e0478d2c63cbe3b8fa723a52f864ba3676da2dcb61104", - "event_hash": "sha256:e8f430c610686e6bcdcbf10ae0330ed52c6dcb841d921489ca02b43a5d306f42", + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", "event_type": "approval.decided", "occurred_at": "2026-04-28T09:14:30Z", "recorded_at": "2026-04-28T09:14:31Z", @@ -182,19 +182,19 @@ "detail": { "decision": "approved", "channel": "email", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "policy_revision": "2026-04-15.3", "wait_ms": 7000 } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000006", "stream_id": "stream_prop_b3f1a2c4", "sequence": 6, - "previous_event_hash": "sha256:e8f430c610686e6bcdcbf10ae0330ed52c6dcb841d921489ca02b43a5d306f42", - "event_hash": "sha256:c9193e5776df3ef2e2b529e379fb381503b1ca235f4be2b0de5128f05990adf3", + "previous_event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", + "event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", "event_type": "resolution.emitted", "occurred_at": "2026-04-28T09:14:32Z", "recorded_at": "2026-04-28T09:14:32Z", @@ -223,16 +223,16 @@ }, "detail": { "decision": "approved", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000007", "stream_id": "stream_prop_b3f1a2c4", "sequence": 7, - "previous_event_hash": "sha256:c9193e5776df3ef2e2b529e379fb381503b1ca235f4be2b0de5128f05990adf3", - "event_hash": "sha256:64865c5cd326e07b08f4e71850dad1f8f41c6c02635467b27955812f83fbd3fe", + "previous_event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", + "event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", "event_type": "authority.checked", "occurred_at": "2026-04-28T09:14:33Z", "recorded_at": "2026-04-28T09:14:33Z", @@ -260,16 +260,16 @@ "authority_snapshot_id": "authority_2026-04-28_9bc31000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3" + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000008", "stream_id": "stream_prop_b3f1a2c4", "sequence": 8, - "previous_event_hash": "sha256:64865c5cd326e07b08f4e71850dad1f8f41c6c02635467b27955812f83fbd3fe", - "event_hash": "sha256:7f05b65a76687d234efa8469d4c7687c2ce94738764537e491c1b05cecf862d1", + "previous_event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", + "event_hash": "sha256:da01aa5134e17e13187f2046b5304750d85c8ea3c7ff6c49a018909c0aa65f4d", "event_type": "dispatch.succeeded", "occurred_at": "2026-04-28T09:14:34Z", "recorded_at": "2026-04-28T09:14:34Z", @@ -297,18 +297,18 @@ "dispatch_id": "dispatch_2026-04-28_4ad31000" }, "detail": { - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "attempt": 1, "provider_result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b" } }, { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "event_id": "event_2026-04-28_00000009", "stream_id": "stream_prop_b3f1a2c4", "sequence": 9, - "previous_event_hash": "sha256:7f05b65a76687d234efa8469d4c7687c2ce94738764537e491c1b05cecf862d1", - "event_hash": "sha256:5f91b7d2df0e1d5659a53a863ea4ac5fa2c16ad3deb6fd447bb184bb931bcb46", + "previous_event_hash": "sha256:da01aa5134e17e13187f2046b5304750d85c8ea3c7ff6c49a018909c0aa65f4d", + "event_hash": "sha256:22f923ad679f6d3b129733c86f20654350695be1af82883571672c9d990fed6b", "event_type": "audit.snapshotted", "occurred_at": "2026-04-28T09:14:35Z", "recorded_at": "2026-04-28T09:14:35Z", diff --git a/examples/audit-snapshot.json b/examples/audit-snapshot.json index 3da5a64..0d43a27 100644 --- a/examples/audit-snapshot.json +++ b/examples/audit-snapshot.json @@ -1,5 +1,5 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "snapshot_id": "audit_snapshot_2026-04-28_1df41000", "stream_id": "stream_prop_b3f1a2c4", "captured_at": "2026-04-28T09:14:35Z", @@ -7,6 +7,6 @@ "entry_count": 9, "first_sequence": 1, "last_sequence": 9, - "events_digest": "sha256:b4050f1b1cb9f6419956f5f94db2d234a7bd695f817ce3f45ec0fcf1c65d40d1", - "root_event_hash": "sha256:5f91b7d2df0e1d5659a53a863ea4ac5fa2c16ad3deb6fd447bb184bb931bcb46" + "events_digest": "sha256:832fc218e4a91f42b16a48d06cc0273269af9b79509b6afb6e7ff501269b7093", + "root_event_hash": "sha256:22f923ad679f6d3b129733c86f20654350695be1af82883571672c9d990fed6b" } diff --git a/examples/authority-snapshot.json b/examples/authority-snapshot.json index f43b9d2..db7bdc1 100644 --- a/examples/authority-snapshot.json +++ b/examples/authority-snapshot.json @@ -1,9 +1,9 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "authority_snapshot_id": "authority_2026-04-28_9bc31000", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_id": "req_2026-04-28_6a2d8800", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "checked_at": "2026-04-28T09:14:33Z", "valid_until": "2026-04-28T09:15:33Z", "status": "active", diff --git a/examples/dispatch-record.json b/examples/dispatch-record.json index 64bef31..e0c757d 100644 --- a/examples/dispatch-record.json +++ b/examples/dispatch-record.json @@ -1,5 +1,5 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "dispatch_id": "dispatch_2026-04-28_4ad31000", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_id": "req_2026-04-28_6a2d8800", @@ -7,7 +7,7 @@ "approval_2026-04-28_f9821000" ], "authority_snapshot_id": "authority_2026-04-28_9bc31000", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "idempotency_key": "idem_email_ZD-48211_reply_01", "attempt": 1, "max_attempts": 3, diff --git a/examples/edited-envelope.json b/examples/edited-envelope.json new file mode 100644 index 0000000..a9215fe --- /dev/null +++ b/examples/edited-envelope.json @@ -0,0 +1,805 @@ +{ + "schema_version": "3.0.0", + "captured_at": "2026-04-28T09:14:35Z", + "proposal": { + "schema_version": "3.0.0", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "delegation_chain": [ + { + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "call_id": "call_2026-04-28_7e19c5ad", + "delegated_at": "2026-04-28T09:14:23Z" + } + ], + "action_type": "email.send", + "payload_schema": { + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" + }, + "payload": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "rationale": "The export issue maps to the documented rate-limit incident. Confirm the fix and offer a manual export as fallback.", + "created_at": "2026-04-28T09:14:23Z", + "expires_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "risk": "high", + "idempotency_key": "idem_email_ZD-48211_reply_01" + }, + "validations": [ + { + "schema_version": "3.0.0", + "validation_id": "validation_2026-04-28_841ac000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validator": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "validated_at": "2026-04-28T09:14:24Z", + "outcome": "passed", + "checks": [ + { + "name": "proposal_schema", + "status": "passed" + }, + { + "name": "payload_schema", + "status": "passed" + }, + { + "name": "semantic_preconditions", + "status": "passed" + }, + { + "name": "policy_inputs", + "status": "passed" + }, + { + "name": "authorization_inputs", + "status": "passed" + } + ] + } + ], + "review_snapshots": [ + { + "schema_version": "3.0.0", + "snapshot_id": "review_2026-04-28_47b2c000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "rendered_at": "2026-04-28T09:14:26Z", + "renderer": { + "name": "approval-email-renderer", + "version": "3.0.0", + "format": "json" + }, + "locale": "en-US", + "content": { + "action_type": "email.send", + "action_identity": { + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent_identity": "agent.support-triage.prod" + }, + "target": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [] + }, + "arguments": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "risk": "high", + "expires_at": "2026-04-28T17:14:23Z", + "policy_summary": "One distinct customer-support approver; email decisions require a signature." + } + }, + { + "schema_version": "3.0.0", + "snapshot_id": "review_edited_01", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "payload_hash": "sha256:46bfc2140a183593e6a03ba2ccac7fef1d029dbd8d0cd7c17878b6022f667a02", + "content_hash": "sha256:b699962e199ac183f4d64a36a21ce73a37ea52d16d7e061e4318c6c2f499bff9", + "rendered_at": "2026-04-28T09:14:26Z", + "renderer": { + "name": "approval-email-renderer", + "version": "3.0.0", + "format": "json" + }, + "locale": "en-US", + "content": { + "action_type": "email.send", + "action_identity": { + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent_identity": "agent.support-triage.prod" + }, + "target": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [] + }, + "arguments": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: invoice export restored", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "risk": "high", + "expires_at": "2026-04-28T17:14:23Z", + "policy_summary": "One distinct customer-support approver; email decisions require a signature." + } + } + ], + "request": { + "schema_version": "3.0.0", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "tenant": "acme-corp", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validation_id": "validation_2026-04-28_841ac000", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", + "requested_at": "2026-04-28T09:14:25Z", + "expires_at": "2026-04-28T17:14:23Z", + "status": "consumed", + "revision": 3, + "requested_by": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "policy": { + "schema_version": "3.0.0", + "policy_id": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", + "evaluation_id": "eval_2026-04-28_1c4f8a00", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "evaluated_at": "2026-04-28T09:14:24Z", + "requirements": { + "minimum_approvals": 1, + "distinct_approvers": true, + "human_only": true, + "allowed_channels": [ + "email", + "web" + ], + "signature_required_channels": [ + "email" + ], + "allow_auto_approval": false, + "required_roles": [ + "customer-support" + ], + "separation_of_duties": { + "requester_must_not_approve": true, + "agent_must_not_approve": true, + "distinct_signing_keys": true, + "eligible_approver_ids": [ + "rene@acme-corp.example" + ], + "excluded_actor_ids": [ + "approval-router", + "agent.support-triage.prod" + ], + "excluded_key_ids": [ + "agent-fleet-shared-key" + ] + } + } + }, + "channels": [ + { + "channel": "email", + "target_hash": "sha256:e8e3f7b243a672993b9c238585042b95995665863cc28ff00adeafca8e5ecb1b", + "delivery_status": "delivered", + "notification_id": "mail_2026-04-28_3391", + "sent_at": "2026-04-28T09:14:26Z" + } + ], + "terminal_at": "2026-04-28T09:14:31Z", + "decision_record_id": "approval_2026-04-28_f9821000", + "consumed_at": "2026-04-28T09:14:33Z", + "dispatch_id": "dispatch_2026-04-28_4ad31000", + "terminal_reason": "consumed_for_dispatch" + }, + "approvals": [ + { + "schema_version": "3.0.0", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_revision": 1, + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "payload_hash": "sha256:46bfc2140a183593e6a03ba2ccac7fef1d029dbd8d0cd7c17878b6022f667a02", + "review_snapshot_id": "review_edited_01", + "review_content_hash": "sha256:b699962e199ac183f4d64a36a21ce73a37ea52d16d7e061e4318c6c2f499bff9", + "decision": "approved", + "decided_by": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "display_name": "Rene Z.", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z", + "evidence_ref": "mail-callback:mail_2026-04-28_3391" + } + }, + "decided_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "channel": "email", + "comment": "Rate-limit explanation is accurate. Sending as drafted.", + "signatures": [ + { + "algorithm": "hmac-sha256", + "key_id": "tenant-acme-2026-q2", + "value": "H4f8GxQqI5kTMs7b4Y8cn2RlpdVLz0WGv8VUW0Sm5OU" + } + ], + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00", + "modifications": [ + { + "op": "replace", + "path": "/payload/subject", + "value": "Re: invoice export restored" + } + ] + } + ], + "resolutions": [ + { + "schema_version": "3.0.0", + "resolution_id": "resolution_2026-04-28_78ac1000", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "approval_record_hash": "sha256:2a1c24dc655e6e64f0197c0c466975d5d25e3f43671e8d1adc8175f8f2091b04", + "decision": "approved", + "terminal": true, + "disposition": "execute_once", + "retry_policy": "delivery_only", + "emitted_at": "2026-04-28T09:14:32Z", + "delivery": { + "target_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "attempt": 1, + "status": "acknowledged", + "last_attempt_at": "2026-04-28T09:14:32Z", + "acknowledged_at": "2026-04-28T09:14:32Z" + } + } + ], + "authority_snapshots": [ + { + "schema_version": "3.0.0", + "authority_snapshot_id": "authority_2026-04-28_9bc31000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "checked_at": "2026-04-28T09:14:33Z", + "valid_until": "2026-04-28T09:15:33Z", + "status": "active", + "authority_digest": "sha256:59f5c1d85329618a5fc407792e4eb132933cff78bb882906a858e3585a20509a", + "subjects": [ + { + "kind": "approver", + "identifier": "rene@acme-corp.example", + "revision": "directory-1842", + "status": "active", + "scopes": [ + "email.send", + "tenant:acme-corp" + ] + }, + { + "kind": "policy", + "identifier": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "status": "active", + "scopes": [ + "email.send" + ] + }, + { + "kind": "workspace", + "identifier": "workspace.acme-corp.support", + "revision": "acl-7781", + "status": "active", + "scopes": [ + "tenant:acme-corp" + ] + }, + { + "kind": "dispatcher", + "identifier": "email-dispatcher", + "revision": "credential-2026-q2", + "status": "active", + "scopes": [ + "email.send", + "from:support@acme-corp.example" + ] + } + ] + } + ], + "dispatches": [ + { + "schema_version": "3.0.0", + "dispatch_id": "dispatch_2026-04-28_4ad31000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "approval_ids": [ + "approval_2026-04-28_f9821000" + ], + "authority_snapshot_id": "authority_2026-04-28_9bc31000", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "idempotency_key": "idem_email_ZD-48211_reply_01", + "attempt": 1, + "max_attempts": 3, + "status": "succeeded", + "retry_allowed": false, + "started_at": "2026-04-28T09:14:33Z", + "completed_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "provider": { + "provider_name": "synthetic-email-provider", + "request_id": "provider_req_20260428_8821", + "result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b", + "external_refs": [ + "message:" + ] + } + } + ], + "audit_events": [ + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000001", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 1, + "previous_event_hash": null, + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_type": "proposal.received", + "occurred_at": "2026-04-28T09:14:23Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "proposal-ingress", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": {}, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000002", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 2, + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_type": "validation.passed", + "occurred_at": "2026-04-28T09:14:24Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": { + "validation_id": "validation_2026-04-28_841ac000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000003", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 3, + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_type": "approval.requested", + "occurred_at": "2026-04-28T09:14:25Z", + "recorded_at": "2026-04-28T09:14:25Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:25Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "validation_id": "validation_2026-04-28_841ac000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "policy_revision": "2026-04-15.3" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000004", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 4, + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_type": "review.presented", + "occurred_at": "2026-04-28T09:14:26Z", + "recorded_at": "2026-04-28T09:14:26Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-email-renderer", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:26Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:26Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "review_snapshot_id": "review_2026-04-28_47b2c000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_edited_review_01", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 5, + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:5db92bd1e204f8c9aee7c331e50ab86e920a39c84703f485280c920f35b5268e", + "event_type": "review.presented", + "occurred_at": "2026-04-28T09:14:26Z", + "recorded_at": "2026-04-28T09:14:26Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-email-renderer", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:26Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:26Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "review_snapshot_id": "review_edited_01" + }, + "detail": { + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "payload_hash": "sha256:46bfc2140a183593e6a03ba2ccac7fef1d029dbd8d0cd7c17878b6022f667a02", + "review_content_hash": "sha256:b699962e199ac183f4d64a36a21ce73a37ea52d16d7e061e4318c6c2f499bff9" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000005", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 6, + "previous_event_hash": "sha256:5db92bd1e204f8c9aee7c331e50ab86e920a39c84703f485280c920f35b5268e", + "event_hash": "sha256:7fbe3365e2f0dd8dca4b23d72b3140f5476b161dd86856f6d29c10dd16e84576", + "event_type": "approval.decided", + "occurred_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:31Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "decision": "approved", + "channel": "email", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "payload_hash": "sha256:46bfc2140a183593e6a03ba2ccac7fef1d029dbd8d0cd7c17878b6022f667a02", + "policy_revision": "2026-04-15.3", + "wait_ms": 7000 + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000006", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 7, + "previous_event_hash": "sha256:7fbe3365e2f0dd8dca4b23d72b3140f5476b161dd86856f6d29c10dd16e84576", + "event_hash": "sha256:938a68c99efadf3aad5e01dd4e2ed7f916e31f1a92bf73fbecb67f806b833add", + "event_type": "resolution.emitted", + "occurred_at": "2026-04-28T09:14:32Z", + "recorded_at": "2026-04-28T09:14:32Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:32Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:32Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "resolution_id": "resolution_2026-04-28_78ac1000" + }, + "detail": { + "decision": "approved", + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000007", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 8, + "previous_event_hash": "sha256:938a68c99efadf3aad5e01dd4e2ed7f916e31f1a92bf73fbecb67f806b833add", + "event_hash": "sha256:7af7151dbf276849202e1d16eaa793b63622b660feed5dc1d1a67fbe6f3da782", + "event_type": "authority.checked", + "occurred_at": "2026-04-28T09:14:33Z", + "recorded_at": "2026-04-28T09:14:33Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-gate", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:33Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "authority_snapshot_id": "authority_2026-04-28_9bc31000" + }, + "detail": { + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000008", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 9, + "previous_event_hash": "sha256:7af7151dbf276849202e1d16eaa793b63622b660feed5dc1d1a67fbe6f3da782", + "event_hash": "sha256:d9420a2f5768bad9adc75561e6c9101e0a2b5cda94f2f1692f3d3f60fc5b5501", + "event_type": "dispatch.succeeded", + "occurred_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "email-dispatcher", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:34Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "dispatch_id": "dispatch_2026-04-28_4ad31000" + }, + "detail": { + "action_hash": "sha256:84af89ea5e2e9c54828466f46df51c235692ec6939c3be2620b942ca8af53e53", + "attempt": 1, + "provider_result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000009", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 10, + "previous_event_hash": "sha256:d9420a2f5768bad9adc75561e6c9101e0a2b5cda94f2f1692f3d3f60fc5b5501", + "event_hash": "sha256:b0c5e6e3e16163d212d56f5aee40a657073d561f076aa14454545797c68a50a9", + "event_type": "audit.snapshotted", + "occurred_at": "2026-04-28T09:14:35Z", + "recorded_at": "2026-04-28T09:14:35Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-audit", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:35Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:35Z", + "legal_hold": false + }, + "references": { + "audit_snapshot_id": "audit_snapshot_2026-04-28_1df41000" + }, + "detail": {} + } + ], + "audit_snapshot": { + "schema_version": "3.0.0", + "snapshot_id": "audit_snapshot_2026-04-28_1df41000", + "stream_id": "stream_prop_b3f1a2c4", + "captured_at": "2026-04-28T09:14:35Z", + "complete": true, + "entry_count": 10, + "first_sequence": 1, + "last_sequence": 10, + "events_digest": "sha256:7896ac8db03212d18ce9de3bbd6fd0350f9c707b630af2627666e26681933a01", + "root_event_hash": "sha256:b0c5e6e3e16163d212d56f5aee40a657073d561f076aa14454545797c68a50a9" + } +} diff --git a/examples/email-reply-approval-record.json b/examples/email-reply-approval-record.json index 90314ee..9b6ef9c 100644 --- a/examples/email-reply-approval-record.json +++ b/examples/email-reply-approval-record.json @@ -1,10 +1,10 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "approval_id": "approval_2026-04-28_f9821000", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_revision": 1, - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "review_snapshot_id": "review_2026-04-28_47b2c000", "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", @@ -34,5 +34,6 @@ "key_id": "tenant-acme-2026-q2", "value": "H4f8GxQqI5kTMs7b4Y8cn2RlpdVLz0WGv8VUW0Sm5OU" } - ] + ], + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" } diff --git a/examples/email-reply-approval.json b/examples/email-reply-approval.json index c95bc06..b5e1252 100644 --- a/examples/email-reply-approval.json +++ b/examples/email-reply-approval.json @@ -1,5 +1,5 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", "agent": { @@ -24,9 +24,9 @@ ], "action_type": "email.send", "payload_schema": { - "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/actions/email.send.schema.json", - "version": "2.1.0", - "digest": "sha256:8129956be02f5c6a79919c2312fb96634974d9ca49e9dce9b717cd821b981b3a" + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" }, "payload": { "from": "support@acme-corp.example", diff --git a/examples/expired-envelope.json b/examples/expired-envelope.json new file mode 100644 index 0000000..1b9a0c4 --- /dev/null +++ b/examples/expired-envelope.json @@ -0,0 +1,446 @@ +{ + "schema_version": "3.0.0", + "captured_at": "2026-04-28T17:14:23Z", + "proposal": { + "schema_version": "3.0.0", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "delegation_chain": [ + { + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "call_id": "call_2026-04-28_7e19c5ad", + "delegated_at": "2026-04-28T09:14:23Z" + } + ], + "action_type": "email.send", + "payload_schema": { + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" + }, + "payload": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "rationale": "The export issue maps to the documented rate-limit incident. Confirm the fix and offer a manual export as fallback.", + "created_at": "2026-04-28T09:14:23Z", + "expires_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "risk": "high", + "idempotency_key": "idem_email_ZD-48211_reply_01" + }, + "validations": [ + { + "schema_version": "3.0.0", + "validation_id": "validation_2026-04-28_841ac000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validator": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "validated_at": "2026-04-28T09:14:24Z", + "outcome": "passed", + "checks": [ + { + "name": "proposal_schema", + "status": "passed" + }, + { + "name": "payload_schema", + "status": "passed" + }, + { + "name": "semantic_preconditions", + "status": "passed" + }, + { + "name": "policy_inputs", + "status": "passed" + }, + { + "name": "authorization_inputs", + "status": "passed" + } + ] + } + ], + "review_snapshots": [ + { + "schema_version": "3.0.0", + "snapshot_id": "review_2026-04-28_47b2c000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "rendered_at": "2026-04-28T09:14:26Z", + "renderer": { + "name": "approval-email-renderer", + "version": "3.0.0", + "format": "json" + }, + "locale": "en-US", + "content": { + "action_type": "email.send", + "action_identity": { + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent_identity": "agent.support-triage.prod" + }, + "target": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [] + }, + "arguments": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "risk": "high", + "expires_at": "2026-04-28T17:14:23Z", + "policy_summary": "One distinct customer-support approver; email decisions require a signature." + } + } + ], + "request": { + "schema_version": "3.0.0", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "tenant": "acme-corp", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validation_id": "validation_2026-04-28_841ac000", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", + "requested_at": "2026-04-28T09:14:25Z", + "expires_at": "2026-04-28T17:14:23Z", + "status": "expired", + "revision": 2, + "requested_by": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "policy": { + "schema_version": "3.0.0", + "policy_id": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", + "evaluation_id": "eval_2026-04-28_1c4f8a00", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "evaluated_at": "2026-04-28T09:14:24Z", + "requirements": { + "minimum_approvals": 1, + "distinct_approvers": true, + "human_only": true, + "allowed_channels": [ + "email", + "web" + ], + "signature_required_channels": [ + "email" + ], + "allow_auto_approval": false, + "required_roles": [ + "customer-support" + ], + "separation_of_duties": { + "requester_must_not_approve": true, + "agent_must_not_approve": true, + "distinct_signing_keys": true, + "eligible_approver_ids": [ + "rene@acme-corp.example" + ], + "excluded_actor_ids": [ + "approval-router", + "agent.support-triage.prod" + ], + "excluded_key_ids": [ + "agent-fleet-shared-key" + ] + } + } + }, + "channels": [ + { + "channel": "email", + "target_hash": "sha256:e8e3f7b243a672993b9c238585042b95995665863cc28ff00adeafca8e5ecb1b", + "delivery_status": "delivered", + "notification_id": "mail_2026-04-28_3391", + "sent_at": "2026-04-28T09:14:26Z" + } + ], + "terminal_at": "2026-04-28T17:14:23Z", + "terminal_reason": "deadline_elapsed" + }, + "approvals": [], + "resolutions": [], + "authority_snapshots": [], + "dispatches": [], + "audit_events": [ + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000001", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 1, + "previous_event_hash": null, + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_type": "proposal.received", + "occurred_at": "2026-04-28T09:14:23Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "proposal-ingress", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": {}, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000002", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 2, + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_type": "validation.passed", + "occurred_at": "2026-04-28T09:14:24Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": { + "validation_id": "validation_2026-04-28_841ac000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000003", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 3, + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_type": "approval.requested", + "occurred_at": "2026-04-28T09:14:25Z", + "recorded_at": "2026-04-28T09:14:25Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:25Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "validation_id": "validation_2026-04-28_841ac000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "policy_revision": "2026-04-15.3" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000004", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 4, + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_type": "review.presented", + "occurred_at": "2026-04-28T09:14:26Z", + "recorded_at": "2026-04-28T09:14:26Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-email-renderer", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:26Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:26Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "review_snapshot_id": "review_2026-04-28_47b2c000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000005", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 5, + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:51497e37dbf9f7284f52d9acda02f76a96fba7dba8bd53ff43f22d8e5bc9e2fd", + "event_type": "approval.expired", + "occurred_at": "2026-04-28T17:14:23Z", + "recorded_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:31Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800" + }, + "detail": { + "reason_code": "deadline_elapsed" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000009", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 6, + "previous_event_hash": "sha256:51497e37dbf9f7284f52d9acda02f76a96fba7dba8bd53ff43f22d8e5bc9e2fd", + "event_hash": "sha256:136a6cb24aca8c0410899e63c8c8bb4b35bfa20b2f8da4b22ca3af5723fc2089", + "event_type": "audit.snapshotted", + "occurred_at": "2026-04-28T17:14:23Z", + "recorded_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-audit", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:35Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:35Z", + "legal_hold": false + }, + "references": { + "audit_snapshot_id": "audit_snapshot_2026-04-28_1df41000" + }, + "detail": {} + } + ], + "audit_snapshot": { + "schema_version": "3.0.0", + "snapshot_id": "audit_snapshot_2026-04-28_1df41000", + "stream_id": "stream_prop_b3f1a2c4", + "captured_at": "2026-04-28T17:14:23Z", + "complete": true, + "entry_count": 6, + "first_sequence": 1, + "last_sequence": 6, + "events_digest": "sha256:66939b37bed32a3410fbceb0711f0a626026494f8307dd21429e07c51e61928f", + "root_event_hash": "sha256:136a6cb24aca8c0410899e63c8c8bb4b35bfa20b2f8da4b22ca3af5723fc2089" + } +} diff --git a/examples/n8n-approval-workflow.json b/examples/n8n-approval-workflow.json index 6848f23..a3dbc23 100644 --- a/examples/n8n-approval-workflow.json +++ b/examples/n8n-approval-workflow.json @@ -1,10 +1,10 @@ { - "name": "agent-approval-gate v2 reference", + "name": "agent-approval-gate v3 reference", "nodes": [ { "parameters": { "httpMethod": "POST", - "path": "agent-proposal-v2", + "path": "agent-proposal-v3", "responseMode": "onReceived", "options": {} }, @@ -13,12 +13,12 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300], - "webhookId": "agent-proposal-v2" + "webhookId": "agent-proposal-v3" }, { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_VALIDATOR_URL + '/v2/validate'}}", + "url": "={{$env.APPROVAL_VALIDATOR_URL + '/v3/validate'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -68,7 +68,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/validations'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/validations'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -87,7 +87,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/validations'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/validations'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -106,7 +106,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -124,7 +124,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/requests'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/requests'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -143,7 +143,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -161,7 +161,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/review-snapshots'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/review-snapshots'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -180,7 +180,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -229,12 +229,12 @@ "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [2400, 180], - "webhookId": "agent-approval-resume-v2" + "webhookId": "agent-approval-resume-v3" }, { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/requests/' + $('Create durable approval request').item.json.request_id + '/resolve'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/requests/' + $('Create durable approval request').item.json.request_id + '/resolve'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -253,7 +253,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/resolutions'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/resolutions'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -272,7 +272,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -320,7 +320,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/requests/' + $('Create durable approval request').item.json.request_id + '/consume'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/requests/' + $('Create durable approval request').item.json.request_id + '/consume'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -338,7 +338,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_GATE_URL + '/v2/authority-snapshots'}}", + "url": "={{$env.APPROVAL_GATE_URL + '/v3/authority-snapshots'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -357,7 +357,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -440,7 +440,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -458,7 +458,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -476,7 +476,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/snapshots'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/snapshots'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -495,7 +495,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/events'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/events'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", @@ -513,7 +513,7 @@ { "parameters": { "method": "POST", - "url": "={{$env.APPROVAL_AUDIT_URL + '/v2/snapshots'}}", + "url": "={{$env.APPROVAL_AUDIT_URL + '/v3/snapshots'}}", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", diff --git a/examples/recovered-envelope.json b/examples/recovered-envelope.json new file mode 100644 index 0000000..7d6531b --- /dev/null +++ b/examples/recovered-envelope.json @@ -0,0 +1,935 @@ +{ + "schema_version": "3.0.0", + "captured_at": "2026-04-28T09:14:38Z", + "proposal": { + "schema_version": "3.0.0", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "delegation_chain": [ + { + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "call_id": "call_2026-04-28_7e19c5ad", + "delegated_at": "2026-04-28T09:14:23Z" + } + ], + "action_type": "email.send", + "payload_schema": { + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" + }, + "payload": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "rationale": "The export issue maps to the documented rate-limit incident. Confirm the fix and offer a manual export as fallback.", + "created_at": "2026-04-28T09:14:23Z", + "expires_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "risk": "high", + "idempotency_key": "idem_email_ZD-48211_reply_01" + }, + "validations": [ + { + "schema_version": "3.0.0", + "validation_id": "validation_2026-04-28_841ac000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validator": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "validated_at": "2026-04-28T09:14:24Z", + "outcome": "passed", + "checks": [ + { + "name": "proposal_schema", + "status": "passed" + }, + { + "name": "payload_schema", + "status": "passed" + }, + { + "name": "semantic_preconditions", + "status": "passed" + }, + { + "name": "policy_inputs", + "status": "passed" + }, + { + "name": "authorization_inputs", + "status": "passed" + } + ] + } + ], + "review_snapshots": [ + { + "schema_version": "3.0.0", + "snapshot_id": "review_2026-04-28_47b2c000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "rendered_at": "2026-04-28T09:14:26Z", + "renderer": { + "name": "approval-email-renderer", + "version": "3.0.0", + "format": "json" + }, + "locale": "en-US", + "content": { + "action_type": "email.send", + "action_identity": { + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent_identity": "agent.support-triage.prod" + }, + "target": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [] + }, + "arguments": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "risk": "high", + "expires_at": "2026-04-28T17:14:23Z", + "policy_summary": "One distinct customer-support approver; email decisions require a signature." + } + } + ], + "request": { + "schema_version": "3.0.0", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "tenant": "acme-corp", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validation_id": "validation_2026-04-28_841ac000", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", + "requested_at": "2026-04-28T09:14:25Z", + "expires_at": "2026-04-28T17:14:23Z", + "status": "consumed", + "revision": 3, + "requested_by": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "policy": { + "schema_version": "3.0.0", + "policy_id": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", + "evaluation_id": "eval_2026-04-28_1c4f8a00", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "evaluated_at": "2026-04-28T09:14:24Z", + "requirements": { + "minimum_approvals": 1, + "distinct_approvers": true, + "human_only": true, + "allowed_channels": [ + "email", + "web" + ], + "signature_required_channels": [ + "email" + ], + "allow_auto_approval": false, + "required_roles": [ + "customer-support" + ], + "separation_of_duties": { + "requester_must_not_approve": true, + "agent_must_not_approve": true, + "distinct_signing_keys": true, + "eligible_approver_ids": [ + "rene@acme-corp.example" + ], + "excluded_actor_ids": [ + "approval-router", + "agent.support-triage.prod" + ], + "excluded_key_ids": [ + "agent-fleet-shared-key" + ] + } + } + }, + "channels": [ + { + "channel": "email", + "target_hash": "sha256:e8e3f7b243a672993b9c238585042b95995665863cc28ff00adeafca8e5ecb1b", + "delivery_status": "delivered", + "notification_id": "mail_2026-04-28_3391", + "sent_at": "2026-04-28T09:14:26Z" + } + ], + "terminal_at": "2026-04-28T09:14:31Z", + "decision_record_id": "approval_2026-04-28_f9821000", + "consumed_at": "2026-04-28T09:14:33Z", + "dispatch_id": "dispatch_2026-04-28_4ad31000", + "terminal_reason": "consumed_for_dispatch" + }, + "approvals": [ + { + "schema_version": "3.0.0", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_revision": 1, + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "decision": "approved", + "decided_by": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "display_name": "Rene Z.", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z", + "evidence_ref": "mail-callback:mail_2026-04-28_3391" + } + }, + "decided_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "channel": "email", + "comment": "Rate-limit explanation is accurate. Sending as drafted.", + "signatures": [ + { + "algorithm": "hmac-sha256", + "key_id": "tenant-acme-2026-q2", + "value": "H4f8GxQqI5kTMs7b4Y8cn2RlpdVLz0WGv8VUW0Sm5OU" + } + ], + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + } + ], + "resolutions": [ + { + "schema_version": "3.0.0", + "resolution_id": "resolution_2026-04-28_78ac1000", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "approval_record_hash": "sha256:a3eaa0a9ea1e1b88b88de8d67fd7c58c1d66c05c7d274bf5ad130de46ec7c0e8", + "decision": "approved", + "terminal": true, + "disposition": "execute_once", + "retry_policy": "delivery_only", + "emitted_at": "2026-04-28T09:14:32Z", + "delivery": { + "target_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "attempt": 1, + "status": "acknowledged", + "last_attempt_at": "2026-04-28T09:14:32Z", + "acknowledged_at": "2026-04-28T09:14:32Z" + } + } + ], + "authority_snapshots": [ + { + "schema_version": "3.0.0", + "authority_snapshot_id": "authority_2026-04-28_9bc31000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "checked_at": "2026-04-28T09:14:33Z", + "valid_until": "2026-04-28T09:15:33Z", + "status": "active", + "authority_digest": "sha256:59f5c1d85329618a5fc407792e4eb132933cff78bb882906a858e3585a20509a", + "subjects": [ + { + "kind": "approver", + "identifier": "rene@acme-corp.example", + "revision": "directory-1842", + "status": "active", + "scopes": [ + "email.send", + "tenant:acme-corp" + ] + }, + { + "kind": "policy", + "identifier": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "status": "active", + "scopes": [ + "email.send" + ] + }, + { + "kind": "workspace", + "identifier": "workspace.acme-corp.support", + "revision": "acl-7781", + "status": "active", + "scopes": [ + "tenant:acme-corp" + ] + }, + { + "kind": "dispatcher", + "identifier": "email-dispatcher", + "revision": "credential-2026-q2", + "status": "active", + "scopes": [ + "email.send", + "from:support@acme-corp.example" + ] + } + ] + }, + { + "schema_version": "3.0.0", + "authority_snapshot_id": "authority_retry_02", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "checked_at": "2026-04-28T09:14:36Z", + "valid_until": "2026-04-28T09:15:33Z", + "status": "active", + "authority_digest": "sha256:59f5c1d85329618a5fc407792e4eb132933cff78bb882906a858e3585a20509a", + "subjects": [ + { + "kind": "approver", + "identifier": "rene@acme-corp.example", + "revision": "directory-1842", + "status": "active", + "scopes": [ + "email.send", + "tenant:acme-corp" + ] + }, + { + "kind": "policy", + "identifier": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "status": "active", + "scopes": [ + "email.send" + ] + }, + { + "kind": "workspace", + "identifier": "workspace.acme-corp.support", + "revision": "acl-7781", + "status": "active", + "scopes": [ + "tenant:acme-corp" + ] + }, + { + "kind": "dispatcher", + "identifier": "email-dispatcher", + "revision": "credential-2026-q2", + "status": "active", + "scopes": [ + "email.send", + "from:support@acme-corp.example" + ] + } + ] + } + ], + "dispatches": [ + { + "schema_version": "3.0.0", + "dispatch_id": "dispatch_2026-04-28_4ad31000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "approval_ids": [ + "approval_2026-04-28_f9821000" + ], + "authority_snapshot_id": "authority_2026-04-28_9bc31000", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "idempotency_key": "idem_email_ZD-48211_reply_01", + "attempt": 1, + "max_attempts": 3, + "status": "outcome_unknown", + "retry_allowed": false, + "started_at": "2026-04-28T09:14:33Z", + "completed_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "reason": "provider_response_lost", + "reconciliation": { + "lookup_key_hash": "sha256:6b0b36a77f0fd26b349eef12816b534a60f24893d054e691aa3842ea53b6cecc", + "status": "pending" + } + }, + { + "schema_version": "3.0.0", + "dispatch_id": "dispatch_reconciled_01", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "approval_ids": [ + "approval_2026-04-28_f9821000" + ], + "authority_snapshot_id": "authority_2026-04-28_9bc31000", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "idempotency_key": "idem_email_ZD-48211_reply_01", + "attempt": 1, + "max_attempts": 3, + "status": "reconciled_not_applied", + "retry_allowed": true, + "started_at": "2026-04-28T09:14:35Z", + "completed_at": "2026-04-28T09:14:35Z", + "recorded_at": "2026-04-28T09:14:35Z", + "reconciliation": { + "lookup_key_hash": "sha256:6b0b36a77f0fd26b349eef12816b534a60f24893d054e691aa3842ea53b6cecc", + "status": "not_applied", + "checked_at": "2026-04-28T09:14:35Z", + "evidence": { + "kind": "reconciliation", + "uri": "urn:example:lookup:01", + "digest": "sha256:254adf4d281880c0c02a760ac072be002c579eb4d2c82aafbd6314df7217da75" + } + }, + "previous_dispatch_id": "dispatch_2026-04-28_4ad31000" + }, + { + "schema_version": "3.0.0", + "dispatch_id": "dispatch_retry_02", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "approval_ids": [ + "approval_2026-04-28_f9821000" + ], + "authority_snapshot_id": "authority_retry_02", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "idempotency_key": "idem_email_ZD-48211_reply_01", + "attempt": 2, + "max_attempts": 3, + "status": "succeeded", + "retry_allowed": false, + "started_at": "2026-04-28T09:14:36Z", + "completed_at": "2026-04-28T09:14:37Z", + "recorded_at": "2026-04-28T09:14:37Z", + "provider": { + "provider_name": "synthetic-email-provider", + "request_id": "provider_req_20260428_8821", + "result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b", + "external_refs": [ + "message:" + ] + }, + "previous_dispatch_id": "dispatch_reconciled_01" + } + ], + "audit_events": [ + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000001", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 1, + "previous_event_hash": null, + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_type": "proposal.received", + "occurred_at": "2026-04-28T09:14:23Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "proposal-ingress", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": {}, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000002", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 2, + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_type": "validation.passed", + "occurred_at": "2026-04-28T09:14:24Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": { + "validation_id": "validation_2026-04-28_841ac000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000003", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 3, + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_type": "approval.requested", + "occurred_at": "2026-04-28T09:14:25Z", + "recorded_at": "2026-04-28T09:14:25Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:25Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "validation_id": "validation_2026-04-28_841ac000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "policy_revision": "2026-04-15.3" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000004", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 4, + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_type": "review.presented", + "occurred_at": "2026-04-28T09:14:26Z", + "recorded_at": "2026-04-28T09:14:26Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-email-renderer", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:26Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:26Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "review_snapshot_id": "review_2026-04-28_47b2c000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000005", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 5, + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", + "event_type": "approval.decided", + "occurred_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:31Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "decision": "approved", + "channel": "email", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "policy_revision": "2026-04-15.3", + "wait_ms": 7000 + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000006", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 6, + "previous_event_hash": "sha256:c4006b95ac46a252dc5139ab83b9de948a47834cf64b412b0a0ab0da0055b262", + "event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", + "event_type": "resolution.emitted", + "occurred_at": "2026-04-28T09:14:32Z", + "recorded_at": "2026-04-28T09:14:32Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:32Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:32Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "resolution_id": "resolution_2026-04-28_78ac1000" + }, + "detail": { + "decision": "approved", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000007", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 7, + "previous_event_hash": "sha256:7077b2bb96fbe0d64f7d61ca4101ac41d5b8bee56156d674a8e63a8a859c8873", + "event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", + "event_type": "authority.checked", + "occurred_at": "2026-04-28T09:14:33Z", + "recorded_at": "2026-04-28T09:14:33Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-gate", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:33Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "authority_snapshot_id": "authority_2026-04-28_9bc31000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000008", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 8, + "previous_event_hash": "sha256:f56f556310f333146e94f4808659d227039ed5c3bfa1e6a8c0791b7c400aac14", + "event_hash": "sha256:786168c6f2ea3a65ee303a3886d474d84efeccbab841b2fb1976b86de6f6ba4a", + "event_type": "dispatch.outcome_unknown", + "occurred_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "email-dispatcher", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:34Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "dispatch_id": "dispatch_2026-04-28_4ad31000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "attempt": 1 + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_reconciled_01", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 9, + "previous_event_hash": "sha256:786168c6f2ea3a65ee303a3886d474d84efeccbab841b2fb1976b86de6f6ba4a", + "event_hash": "sha256:73b7b0a293861864123760352646ac19ed924606c5b099480d458610a8253400", + "event_type": "dispatch.reconciled", + "occurred_at": "2026-04-28T09:14:35Z", + "recorded_at": "2026-04-28T09:14:35Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "email-dispatcher", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:34Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "dispatch_id": "dispatch_reconciled_01" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "attempt": 1 + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_retry_authority_02", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 10, + "previous_event_hash": "sha256:73b7b0a293861864123760352646ac19ed924606c5b099480d458610a8253400", + "event_hash": "sha256:fcc6f19bb2d1c38bbae7cc9ee52395d16676a9c526c1d62e64398d4ab8aeb17f", + "event_type": "authority.checked", + "occurred_at": "2026-04-28T09:14:36Z", + "recorded_at": "2026-04-28T09:14:36Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-gate", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:33Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "authority_snapshot_id": "authority_retry_02" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_retry_02", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 11, + "previous_event_hash": "sha256:fcc6f19bb2d1c38bbae7cc9ee52395d16676a9c526c1d62e64398d4ab8aeb17f", + "event_hash": "sha256:f46bee5f1870fa93a3b8cf2c89fb02847f7f62279c78ad071fc0663f45228065", + "event_type": "dispatch.succeeded", + "occurred_at": "2026-04-28T09:14:37Z", + "recorded_at": "2026-04-28T09:14:37Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "email-dispatcher", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:34Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "dispatch_id": "dispatch_retry_02" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "attempt": 2, + "provider_result_hash": "sha256:78b0436944885eb78c09ea1a657e2d2c57b0d9672a19f709e6724c22a633a29b" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000009", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 12, + "previous_event_hash": "sha256:f46bee5f1870fa93a3b8cf2c89fb02847f7f62279c78ad071fc0663f45228065", + "event_hash": "sha256:bd6659674a0bc15aad3368db8fe31ac4a2eb97ce29df1b313076eaa61a658222", + "event_type": "audit.snapshotted", + "occurred_at": "2026-04-28T09:14:38Z", + "recorded_at": "2026-04-28T09:14:38Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-audit", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:35Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:35Z", + "legal_hold": false + }, + "references": { + "audit_snapshot_id": "audit_snapshot_2026-04-28_1df41000" + }, + "detail": {} + } + ], + "audit_snapshot": { + "schema_version": "3.0.0", + "snapshot_id": "audit_snapshot_2026-04-28_1df41000", + "stream_id": "stream_prop_b3f1a2c4", + "captured_at": "2026-04-28T09:14:38Z", + "complete": true, + "entry_count": 12, + "first_sequence": 1, + "last_sequence": 12, + "events_digest": "sha256:fd3af6e070b308abcda42c06332a1dce2f7ca7735e245435fe085a031e2812b5", + "root_event_hash": "sha256:bd6659674a0bc15aad3368db8fe31ac4a2eb97ce29df1b313076eaa61a658222" + } +} diff --git a/examples/rejected-envelope.json b/examples/rejected-envelope.json new file mode 100644 index 0000000..7918534 --- /dev/null +++ b/examples/rejected-envelope.json @@ -0,0 +1,617 @@ +{ + "schema_version": "3.0.0", + "captured_at": "2026-04-28T09:14:35Z", + "proposal": { + "schema_version": "3.0.0", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "delegation_chain": [ + { + "agent": { + "identity": "agent.support-triage.prod", + "name": "support-triage-agent", + "version": "1.4.2", + "model": "claude-opus-4-7", + "run_id": "run_2026-04-28_5f9e2c00" + }, + "call_id": "call_2026-04-28_7e19c5ad", + "delegated_at": "2026-04-28T09:14:23Z" + } + ], + "action_type": "email.send", + "payload_schema": { + "id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", + "version": "3.0.0", + "digest": "sha256:14730714de221b0506c8ae13b2f783f0eeb52202ea333385bf0d26e840f9d1d4" + }, + "payload": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "rationale": "The export issue maps to the documented rate-limit incident. Confirm the fix and offer a manual export as fallback.", + "created_at": "2026-04-28T09:14:23Z", + "expires_at": "2026-04-28T17:14:23Z", + "tenant": "acme-corp", + "risk": "high", + "idempotency_key": "idem_email_ZD-48211_reply_01" + }, + "validations": [ + { + "schema_version": "3.0.0", + "validation_id": "validation_2026-04-28_841ac000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validator": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "validated_at": "2026-04-28T09:14:24Z", + "outcome": "passed", + "checks": [ + { + "name": "proposal_schema", + "status": "passed" + }, + { + "name": "payload_schema", + "status": "passed" + }, + { + "name": "semantic_preconditions", + "status": "passed" + }, + { + "name": "policy_inputs", + "status": "passed" + }, + { + "name": "authorization_inputs", + "status": "passed" + } + ] + } + ], + "review_snapshots": [ + { + "schema_version": "3.0.0", + "snapshot_id": "review_2026-04-28_47b2c000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "rendered_at": "2026-04-28T09:14:26Z", + "renderer": { + "name": "approval-email-renderer", + "version": "3.0.0", + "format": "json" + }, + "locale": "en-US", + "content": { + "action_type": "email.send", + "action_identity": { + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "agent_identity": "agent.support-triage.prod" + }, + "target": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [] + }, + "arguments": { + "from": "support@acme-corp.example", + "to": [ + "customer@example.com" + ], + "cc": [], + "subject": "Re: cannot export invoices for March", + "body_text": "Hi,\n\nThanks for the report. The export endpoint was rate-limited above the documented threshold last week — we've raised it back to 50 req/min. Please retry the March export and let us know if it still fails.\n\nIf you need the data sooner, I can run the export on our side and email you the CSV directly.\n\nBest,\nSupport", + "in_reply_to_message_id": "", + "ticket_id": "ZD-48211" + }, + "risk": "high", + "expires_at": "2026-04-28T17:14:23Z", + "policy_summary": "One distinct customer-support approver; email decisions require a signature." + } + } + ], + "request": { + "schema_version": "3.0.0", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "tenant": "acme-corp", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "validation_id": "validation_2026-04-28_841ac000", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "callback_nonce_hash": "sha256:91eaf28be1fa8121d74d9ce65298da05363c70dfb691c4c4e1a17691bb09966a", + "requested_at": "2026-04-28T09:14:25Z", + "expires_at": "2026-04-28T17:14:23Z", + "status": "rejected", + "revision": 2, + "requested_by": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "policy": { + "schema_version": "3.0.0", + "policy_id": "policy.high-risk-email.v1", + "revision": "2026-04-15.3", + "policy_digest": "sha256:a4f5ac837738a43e70baa0f2d602bbba2f2285a518beb568746f310f0678dc35", + "evaluation_id": "eval_2026-04-28_1c4f8a00", + "material_input_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "evaluated_at": "2026-04-28T09:14:24Z", + "requirements": { + "minimum_approvals": 1, + "distinct_approvers": true, + "human_only": true, + "allowed_channels": [ + "email", + "web" + ], + "signature_required_channels": [ + "email" + ], + "allow_auto_approval": false, + "required_roles": [ + "customer-support" + ], + "separation_of_duties": { + "requester_must_not_approve": true, + "agent_must_not_approve": true, + "distinct_signing_keys": true, + "eligible_approver_ids": [ + "rene@acme-corp.example" + ], + "excluded_actor_ids": [ + "approval-router", + "agent.support-triage.prod" + ], + "excluded_key_ids": [ + "agent-fleet-shared-key" + ] + } + } + }, + "channels": [ + { + "channel": "email", + "target_hash": "sha256:e8e3f7b243a672993b9c238585042b95995665863cc28ff00adeafca8e5ecb1b", + "delivery_status": "delivered", + "notification_id": "mail_2026-04-28_3391", + "sent_at": "2026-04-28T09:14:26Z" + } + ], + "terminal_at": "2026-04-28T09:14:31Z", + "decision_record_id": "approval_2026-04-28_f9821000", + "terminal_reason": "rejected" + }, + "approvals": [ + { + "schema_version": "3.0.0", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_revision": 1, + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_snapshot_id": "review_2026-04-28_47b2c000", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", + "decision": "rejected", + "decided_by": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "display_name": "Rene Z.", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z", + "evidence_ref": "mail-callback:mail_2026-04-28_3391" + } + }, + "decided_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "channel": "email", + "comment": "Rate-limit explanation is accurate. Sending as drafted.", + "signatures": [ + { + "algorithm": "hmac-sha256", + "key_id": "tenant-acme-2026-q2", + "value": "H4f8GxQqI5kTMs7b4Y8cn2RlpdVLz0WGv8VUW0Sm5OU" + } + ], + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00", + "rejection_settlement": { + "state": "unresolved" + } + } + ], + "resolutions": [ + { + "schema_version": "3.0.0", + "resolution_id": "resolution_2026-04-28_78ac1000", + "approval_id": "approval_2026-04-28_f9821000", + "request_id": "req_2026-04-28_6a2d8800", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "call_id": "call_2026-04-28_7e19c5ad", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "approval_record_hash": "sha256:505db82561b809cedbc4f9469c0967724295f6d15a9a62802aba407e9955fc5a", + "decision": "rejected", + "terminal": true, + "disposition": "do_not_execute", + "retry_policy": "never", + "emitted_at": "2026-04-28T09:14:32Z", + "delivery": { + "target_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "attempt": 1, + "status": "acknowledged", + "last_attempt_at": "2026-04-28T09:14:32Z", + "acknowledged_at": "2026-04-28T09:14:32Z" + } + } + ], + "authority_snapshots": [], + "dispatches": [ + { + "schema_version": "3.0.0", + "dispatch_id": "dispatch_2026-04-28_4ad31000", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "request_id": "req_2026-04-28_6a2d8800", + "approval_ids": [], + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "idempotency_key": "idem_email_ZD-48211_reply_01", + "attempt": 1, + "max_attempts": 3, + "status": "not_dispatched", + "retry_allowed": false, + "started_at": "2026-04-28T09:14:33Z", + "completed_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "reason": "approval_rejected" + } + ], + "audit_events": [ + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000001", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 1, + "previous_event_hash": null, + "event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_type": "proposal.received", + "occurred_at": "2026-04-28T09:14:23Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "proposal-ingress", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": {}, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000002", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 2, + "previous_event_hash": "sha256:66a246a3d0b327820f1917f2b253b519ee63eb7f6c208b2516699c04f826061e", + "event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_type": "validation.passed", + "occurred_at": "2026-04-28T09:14:24Z", + "recorded_at": "2026-04-28T09:14:24Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-validator", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:24Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:24Z", + "legal_hold": false + }, + "references": { + "validation_id": "validation_2026-04-28_841ac000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000003", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 3, + "previous_event_hash": "sha256:08ea2c92d08a80f8500e577f39b99fbd92c82a36fdd5d4f383f7fe80b8a80a4c", + "event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_type": "approval.requested", + "occurred_at": "2026-04-28T09:14:25Z", + "recorded_at": "2026-04-28T09:14:25Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:25Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:25Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "validation_id": "validation_2026-04-28_841ac000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "policy_revision": "2026-04-15.3" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000004", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 4, + "previous_event_hash": "sha256:090742d5bbe1ff6082ac67791bfddd3f05d98e9b99c9dca2d56adc1c0c08a32a", + "event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_type": "review.presented", + "occurred_at": "2026-04-28T09:14:26Z", + "recorded_at": "2026-04-28T09:14:26Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-email-renderer", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:26Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:26Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "review_snapshot_id": "review_2026-04-28_47b2c000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000005", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 5, + "previous_event_hash": "sha256:56d02856f4b6549f0eae2ebc33c895541bd91ca1395df398acea29548a5d9688", + "event_hash": "sha256:bb1b52981c3d3731098ca4754c7f56e58c624aa12333d5433358e040b85087cc", + "event_type": "approval.decided", + "occurred_at": "2026-04-28T09:14:30Z", + "recorded_at": "2026-04-28T09:14:31Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "human", + "identifier": "rene@acme-corp.example", + "roles": [ + "customer-support" + ], + "authentication": { + "method": "signed_callback", + "human_present": true, + "assurance": "substantial", + "authenticated_at": "2026-04-28T09:14:30Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:31Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" + }, + "detail": { + "decision": "rejected", + "channel": "email", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", + "policy_revision": "2026-04-15.3", + "wait_ms": 7000 + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000006", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 6, + "previous_event_hash": "sha256:bb1b52981c3d3731098ca4754c7f56e58c624aa12333d5433358e040b85087cc", + "event_hash": "sha256:bd699c2a161d7336c666c497397c7bae150aae3a82257b18d30cc0e28d2813b3", + "event_type": "resolution.emitted", + "occurred_at": "2026-04-28T09:14:32Z", + "recorded_at": "2026-04-28T09:14:32Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-router", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:32Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:32Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "resolution_id": "resolution_2026-04-28_78ac1000" + }, + "detail": { + "decision": "rejected", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000008", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 7, + "previous_event_hash": "sha256:bd699c2a161d7336c666c497397c7bae150aae3a82257b18d30cc0e28d2813b3", + "event_hash": "sha256:d57b9e1073743a0f7d98dbc496ef7cc77cd659656fd9631deec143cba3c33174", + "event_type": "dispatch.not_dispatched", + "occurred_at": "2026-04-28T09:14:34Z", + "recorded_at": "2026-04-28T09:14:34Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "email-dispatcher", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:33Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:34Z", + "legal_hold": false + }, + "references": { + "request_id": "req_2026-04-28_6a2d8800", + "approval_id": "approval_2026-04-28_f9821000", + "dispatch_id": "dispatch_2026-04-28_4ad31000" + }, + "detail": { + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "reason_code": "approval_rejected" + } + }, + { + "schema_version": "3.0.0", + "event_id": "event_2026-04-28_00000009", + "stream_id": "stream_prop_b3f1a2c4", + "sequence": 8, + "previous_event_hash": "sha256:d57b9e1073743a0f7d98dbc496ef7cc77cd659656fd9631deec143cba3c33174", + "event_hash": "sha256:092f9ece7c9245c5385f03f2b98cb05b5880b880553cc6fc386a94bb654e02a3", + "event_type": "audit.snapshotted", + "occurred_at": "2026-04-28T09:14:35Z", + "recorded_at": "2026-04-28T09:14:35Z", + "tenant": "acme-corp", + "proposal_id": "prop_2026-04-28_b3f1a2c4", + "actor": { + "kind": "system", + "identifier": "approval-audit", + "authentication": { + "method": "service_identity", + "human_present": false, + "assurance": "high", + "authenticated_at": "2026-04-28T09:14:35Z" + } + }, + "classification": "confidential", + "retention": { + "class": "standard", + "delete_after": "2027-04-28T09:14:35Z", + "legal_hold": false + }, + "references": { + "audit_snapshot_id": "audit_snapshot_2026-04-28_1df41000" + }, + "detail": {} + } + ], + "audit_snapshot": { + "schema_version": "3.0.0", + "snapshot_id": "audit_snapshot_2026-04-28_1df41000", + "stream_id": "stream_prop_b3f1a2c4", + "captured_at": "2026-04-28T09:14:35Z", + "complete": true, + "entry_count": 8, + "first_sequence": 1, + "last_sequence": 8, + "events_digest": "sha256:a4d3054ccf857372263566a881a02665df1fbb14c685e28f5c0e4cec561218dc", + "root_event_hash": "sha256:092f9ece7c9245c5385f03f2b98cb05b5880b880553cc6fc386a94bb654e02a3" + } +} diff --git a/examples/rejected-with-settlement-approval-record.json b/examples/rejected-with-settlement-approval-record.json index 27073e0..cfdcc8c 100644 --- a/examples/rejected-with-settlement-approval-record.json +++ b/examples/rejected-with-settlement-approval-record.json @@ -1,5 +1,5 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "approval_id": "approval_2026-04-28_0e1bb000", "request_id": "req_2026-04-28_91cc7000", "proposal_id": "prop_2026-04-28_9d7e0f13", @@ -44,5 +44,6 @@ "label": "CRM target record absent after rejection" }, "note": "Polled the CRM record after the decision landed. No write was present." - } + }, + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00" } diff --git a/examples/resolution-record.json b/examples/resolution-record.json index 5226c13..0cef3d1 100644 --- a/examples/resolution-record.json +++ b/examples/resolution-record.json @@ -1,12 +1,12 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "resolution_id": "resolution_2026-04-28_78ac1000", "approval_id": "approval_2026-04-28_f9821000", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "call_id": "call_2026-04-28_7e19c5ad", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", - "approval_record_hash": "sha256:b8f5541033591193b0983be7184f5da1e4c65be699edea65ea2f4d05aeb2a0b2", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", + "approval_record_hash": "sha256:a3eaa0a9ea1e1b88b88de8d67fd7c58c1d66c05c7d274bf5ad130de46ec7c0e8", "decision": "approved", "terminal": true, "disposition": "execute_once", diff --git a/examples/review-snapshot.json b/examples/review-snapshot.json index 6bf6429..d34965e 100644 --- a/examples/review-snapshot.json +++ b/examples/review-snapshot.json @@ -1,14 +1,14 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "snapshot_id": "review_2026-04-28_47b2c000", "proposal_id": "prop_2026-04-28_b3f1a2c4", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", "rendered_at": "2026-04-28T09:14:26Z", "renderer": { "name": "approval-email-renderer", - "version": "2.1.0", + "version": "3.0.0", "format": "json" }, "locale": "en-US", diff --git a/examples/validation-record.json b/examples/validation-record.json index f76981a..4fdd451 100644 --- a/examples/validation-record.json +++ b/examples/validation-record.json @@ -1,8 +1,8 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "validation_id": "validation_2026-04-28_841ac000", "proposal_id": "prop_2026-04-28_b3f1a2c4", - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "validator": { "kind": "system", "identifier": "approval-validator", diff --git a/examples/webauthn-approval-record.json b/examples/webauthn-approval-record.json index 033aa26..59d4069 100644 --- a/examples/webauthn-approval-record.json +++ b/examples/webauthn-approval-record.json @@ -1,10 +1,10 @@ { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "approval_id": "approval_2026-04-28_webauthn1", "request_id": "req_2026-04-28_6a2d8800", "proposal_id": "prop_2026-04-28_b3f1a2c4", "request_revision": 1, - "action_hash": "sha256:e2e1a44e6cb6f86c61e1131d6cd2d63ccc5c1654a18d8744121b4387179491d3", + "action_hash": "sha256:94710a0a0a7cebd31eda4a86a91ad493fd162495484726cf7b13fd7906ef5f8c", "payload_hash": "sha256:c919182dcf3aa49d95f5927636aeb7a6a227fa9f9b3daa7384ac4c531a9f891b", "review_snapshot_id": "review_2026-04-28_47b2c000", "review_content_hash": "sha256:1496a0bf99cb54f169a7deb5e57ec48be882f1859fbd39cc2014563d3a250636", @@ -28,18 +28,19 @@ "recorded_at": "2026-04-28T09:14:31Z", "channel": "web", "comment": "Reviewed the bound action details and approved with a passkey.", + "policy_evaluation_id": "eval_2026-04-28_1c4f8a00", "signatures": [ { "algorithm": "webauthn-es256", "key_id": "0cjNOhrHb5fQyIDYaRY4Xl9alS_ZqMfUMhHTUOdwD44", - "value": "MEUCIQDCSBdWIFAr1ZoojL_hQp1zxhFP7KzeFgejdWv3oRszkQIgOyJG2MsQzBJ2bDAIbgDbgimNlXhK7npRs1SN9NorI2E", - "signed_object_hash": "sha256:dfe8c9788a260ada0e32ad5fa7b0dd224214cf2feee63f0d71cca4fb79a4adba", + "value": "MEYCIQDIUUpEON4hAJD6vWc2nE7UkgnwJZnopj6ljYVYWLP6UAIhAPNeH_j_L7KWlKZXx_MLnGTqvHBna3rib8k2pI6ptSEo", + "signed_object_hash": "sha256:d8e3d79f6f1bd80c0a50861a4835f789b5e4b74e8610c33cfd81349aa6f442b2", "webauthn": { "credential_id": "0cjNOhrHb5fQyIDYaRY4Xl9alS_ZqMfUMhHTUOdwD44", "credential_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwTs5yuIWm75cZG8csjFhuwDbl58swFV1Dg9wMx7heTMY8zZXtOZpXSj0WgP5gICY76Nc8k3u39ELxbZlpWK8IA", "public_key_format": "spki-der", "authenticator_data": "Bh4i5kXWgfgyp3-i_7Tc6tPg1gtE80-ZMLP6GootWYQFAAAACQ", - "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiMy1qSmVJb21DdG9PTXExZnA3RGRJa0lVenlfdTVqOE5jY3lrLTNta3JibyIsIm9yaWdpbiI6Imh0dHBzOi8vYXBwcm92ZS5hY21lLWNvcnAuZXhhbXBsZSIsImNyb3NzT3JpZ2luIjpmYWxzZX0", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiMk9QWG4yOGIyQXdLVUlZYVNEWDNpYlhrdDA2R0VNTThfWUUwbXFiMFFySSIsIm9yaWdpbiI6Imh0dHBzOi8vYXBwcm92ZS5hY21lLWNvcnAuZXhhbXBsZSIsImNyb3NzT3JpZ2luIjpmYWxzZX0", "rp_id": "acme-corp.example", "origin": "https://approve.acme-corp.example", "user_verification": "required", diff --git a/schemas/actions/email.send.schema.json b/schemas/actions/email.send.schema.json index cf71015..f940880 100644 --- a/schemas/actions/email.send.schema.json +++ b/schemas/actions/email.send.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/actions/email.send.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/actions/email.send.schema.json", "title": "email.send payload", "description": "Reference payload contract for an email dispatcher. Copy and adapt this pattern next to each real dispatcher.", "type": "object", diff --git a/schemas/approval-envelope.schema.json b/schemas/approval-envelope.schema.json index 2d09c30..a567b73 100644 --- a/schemas/approval-envelope.schema.json +++ b/schemas/approval-envelope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/approval-envelope.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/approval-envelope.schema.json", "title": "ApprovalEnvelope", "description": "A complete portable lifecycle bundle. JSON Schema checks structure; scripts/validate_contracts.py also checks cross-record identity, hashes, quorum, time, sequence, and attempt invariants.", "type": "object", @@ -38,25 +38,25 @@ "request": {"$ref": "approval-request.schema.json"}, "approvals": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 16, "items": {"$ref": "approval-record.schema.json"} }, "resolutions": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 100, "items": {"$ref": "resolution-record.schema.json"} }, "authority_snapshots": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 100, "items": {"$ref": "authority-snapshot.schema.json"} }, "dispatches": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 100, "items": {"$ref": "dispatch-record.schema.json"} }, diff --git a/schemas/approval-policy.schema.json b/schemas/approval-policy.schema.json index 4bb3344..bce9d20 100644 --- a/schemas/approval-policy.schema.json +++ b/schemas/approval-policy.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/approval-policy.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/approval-policy.schema.json", "title": "ApprovalPolicySnapshot", "description": "Immutable evidence of the policy revision and material inputs that selected an approval path.", "type": "object", diff --git a/schemas/approval-record.schema.json b/schemas/approval-record.schema.json index 520b066..735a218 100644 --- a/schemas/approval-record.schema.json +++ b/schemas/approval-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/approval-record.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/approval-record.schema.json", "title": "ApprovalRecord", "description": "An append-only terminal decision bound to the exact effective action and payload reviewed by the approver.", "type": "object", @@ -18,7 +18,8 @@ "decided_by", "decided_at", "recorded_at", - "channel" + "channel", + "policy_evaluation_id" ], "additionalProperties": false, "properties": { diff --git a/schemas/approval-request.schema.json b/schemas/approval-request.schema.json index 9df05fa..a36eb43 100644 --- a/schemas/approval-request.schema.json +++ b/schemas/approval-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/approval-request.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/approval-request.schema.json", "title": "ApprovalRequest", "description": "Durable state for one pending approval. Resolve it by request_id with compare-and-set semantics; never infer authority from payload shape or conversation text.", "type": "object", diff --git a/schemas/audit-event.schema.json b/schemas/audit-event.schema.json index 684773c..4289611 100644 --- a/schemas/audit-event.schema.json +++ b/schemas/audit-event.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/audit-event.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/audit-event.schema.json", "title": "ApprovalAuditEvent", "description": "A redacted, append-only lifecycle event. event_hash is the RFC 8785 SHA-256 digest of this document with event_hash omitted; previous_event_hash links the stream.", "type": "object", diff --git a/schemas/audit-snapshot.schema.json b/schemas/audit-snapshot.schema.json index b078fb9..8305cb7 100644 --- a/schemas/audit-snapshot.schema.json +++ b/schemas/audit-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/audit-snapshot.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/audit-snapshot.schema.json", "title": "AuditSnapshot", "description": "A completeness manifest that binds one exported audit-event set to its stream, sequence bounds, entry count, canonical digest, and terminal chain hash.", "type": "object", diff --git a/schemas/authority-snapshot.schema.json b/schemas/authority-snapshot.schema.json index 5f58088..ab7dd49 100644 --- a/schemas/authority-snapshot.schema.json +++ b/schemas/authority-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/authority-snapshot.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/authority-snapshot.schema.json", "title": "AuthoritySnapshot", "description": "A dispatch-time snapshot proving that the approver, policy, workspace, and dispatcher authorities remain active after approval and before provider invocation.", "type": "object", @@ -13,7 +13,8 @@ "checked_at", "status", "authority_digest", - "subjects" + "subjects", + "valid_until" ], "additionalProperties": false, "properties": { diff --git a/schemas/common.schema.json b/schemas/common.schema.json index df43467..bd91dbc 100644 --- a/schemas/common.schema.json +++ b/schemas/common.schema.json @@ -1,11 +1,11 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/common.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/common.schema.json", "title": "Agent Approval Gate common definitions", "$defs": { "schema_version": { "type": "string", - "const": "2.1.0" + "const": "3.0.0" }, "identifier": { "type": "string", diff --git a/schemas/dispatch-record.schema.json b/schemas/dispatch-record.schema.json index b66ca1f..0667df4 100644 --- a/schemas/dispatch-record.schema.json +++ b/schemas/dispatch-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/dispatch-record.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/dispatch-record.schema.json", "title": "DispatchRecord", "description": "An append-only record for one deterministic dispatch attempt or reconciliation result. outcome_unknown is never an invitation to retry blindly.", "type": "object", @@ -10,7 +10,6 @@ "proposal_id", "request_id", "approval_ids", - "authority_snapshot_id", "action_hash", "idempotency_key", "attempt", @@ -24,11 +23,12 @@ "properties": { "schema_version": {"$ref": "common.schema.json#/$defs/schema_version"}, "dispatch_id": {"$ref": "common.schema.json#/$defs/identifier"}, + "previous_dispatch_id": {"$ref": "common.schema.json#/$defs/identifier"}, "proposal_id": {"$ref": "common.schema.json#/$defs/identifier"}, "request_id": {"$ref": "common.schema.json#/$defs/identifier"}, "approval_ids": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 16, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/identifier"} @@ -69,7 +69,10 @@ "provider_timeout", "provider_response_lost", "attempt_limit", - "dispatcher_error" + "dispatcher_error", + "approval_rejected", + "approval_cancelled", + "request_failed" ] }, "provider": { @@ -168,6 +171,44 @@ } } } + }, + { + "if": { + "properties": { + "status": { + "const": "not_dispatched" + } + }, + "required": [ + "status" + ] + }, + "then": { + "properties": { + "retry_allowed": { + "const": false + } + }, + "not": { + "required": [ + "provider" + ] + } + }, + "else": { + "properties": { + "approval_ids": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": {"status": {"enum": ["succeeded", "failed", "outcome_unknown"]}}, + "required": ["status"] + }, + "then": {"required": ["authority_snapshot_id"]} } ] } diff --git a/schemas/proposed-action.schema.json b/schemas/proposed-action.schema.json index 55a46ee..f489378 100644 --- a/schemas/proposed-action.schema.json +++ b/schemas/proposed-action.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/proposed-action.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/proposed-action.schema.json", "title": "ProposedAction", "description": "An immutable, serializable action draft. The RFC 8785 SHA-256 digest of this complete document is the action_hash carried through approval and dispatch.", "type": "object", diff --git a/schemas/resolution-record.schema.json b/schemas/resolution-record.schema.json index 050b06e..a58449b 100644 --- a/schemas/resolution-record.schema.json +++ b/schemas/resolution-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/resolution-record.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/resolution-record.schema.json", "title": "ResolutionRecord", "description": "A durable agent-facing receipt for an approval decision. Recording the decision and delivering it are separate facts, so a failed resume can be retried without reopening approval or execution.", "type": "object", diff --git a/schemas/review-snapshot.schema.json b/schemas/review-snapshot.schema.json index c897d7e..e1ecf87 100644 --- a/schemas/review-snapshot.schema.json +++ b/schemas/review-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/review-snapshot.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/review-snapshot.schema.json", "title": "ReviewSnapshot", "description": "The exact decision material presented to an approver, bound to the effective action and payload by canonical hashes.", "type": "object", diff --git a/schemas/validation-record.schema.json b/schemas/validation-record.schema.json index d0ada9f..8833c94 100644 --- a/schemas/validation-record.schema.json +++ b/schemas/validation-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v2.1.0/schemas/validation-record.schema.json", + "$id": "https://raw.githubusercontent.com/renezander030/agent-approval-gate/v3.0.0/schemas/validation-record.schema.json", "title": "ValidationRecord", "description": "Immutable pre-admission evidence that an exact proposed action passed structural, payload, semantic, policy-input, and authorization-input checks before a human was notified.", "type": "object", diff --git a/scripts/generate_examples.py b/scripts/generate_examples.py index 9f4cb8c..fb0310f 100644 --- a/scripts/generate_examples.py +++ b/scripts/generate_examples.py @@ -41,6 +41,126 @@ def base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") +def refresh_audit(envelope: dict[str, Any]) -> None: + previous = None + for index, event in enumerate(envelope["audit_events"], 1): + event["sequence"] = index + event["previous_event_hash"] = previous + event["event_hash"] = digest_value({k: v for k, v in event.items() if k != "event_hash"}) + previous = event["event_hash"] + events = envelope["audit_events"] + envelope["audit_snapshot"].update( + entry_count=len(events), first_sequence=1, last_sequence=len(events), + events_digest=digest_value(events), root_event_hash=previous, + ) + + +def lifecycle_examples(base: dict[str, Any]) -> None: + edited = deepcopy(base) + approval = edited["approvals"][0] + approval["modifications"] = [{"op": "replace", "path": "/payload/subject", "value": "Re: invoice export restored"}] + effective = deepcopy(edited["proposal"]) + effective["payload"]["subject"] = "Re: invoice export restored" + action_hash = digest_value(effective) + payload_hash = digest_value(effective["payload"]) + review = deepcopy(edited["review_snapshots"][0]) + review.update(snapshot_id="review_edited_01", action_hash=action_hash, payload_hash=payload_hash, rendered_at="2026-04-28T09:14:26Z") + review["content"]["arguments"] = deepcopy(effective["payload"]) + review["content_hash"] = digest_value(review["content"]) + edited["review_snapshots"].append(review) + approval.update(action_hash=action_hash, payload_hash=payload_hash, review_snapshot_id=review["snapshot_id"], review_content_hash=review["content_hash"]) + edited["resolutions"][0].update(action_hash=action_hash, approval_record_hash=digest_value(approval)) + edited["authority_snapshots"][0]["action_hash"] = action_hash + edited["dispatches"][0]["action_hash"] = action_hash + review_event = deepcopy(edited["audit_events"][3]) + review_event.update(event_id="event_edited_review_01", occurred_at=review["rendered_at"], recorded_at=review["rendered_at"]) + review_event["references"]["review_snapshot_id"] = review["snapshot_id"] + review_event["detail"].update(action_hash=action_hash, payload_hash=payload_hash, review_content_hash=review["content_hash"]) + edited["audit_events"].insert(4, review_event) + for event in edited["audit_events"][5:]: + if "action_hash" in event["detail"]: + event["detail"]["action_hash"] = action_hash + if "payload_hash" in event["detail"]: + event["detail"]["payload_hash"] = payload_hash + refresh_audit(edited) + write("edited-envelope.json", edited) + + rejected = deepcopy(base) + request = rejected["request"] + request.update(status="rejected", revision=2, terminal_reason="rejected") + request.pop("consumed_at") + request.pop("dispatch_id") + approval = rejected["approvals"][0] + approval.update(decision="rejected", rejection_settlement={"state": "unresolved"}) + resolution = rejected["resolutions"][0] + resolution.update(decision="rejected", disposition="do_not_execute", retry_policy="never", approval_record_hash=digest_value(approval)) + rejected["authority_snapshots"] = [] + dispatch = rejected["dispatches"][0] + dispatch.update(status="not_dispatched", approval_ids=[], reason="approval_rejected") + dispatch.pop("authority_snapshot_id") + dispatch.pop("provider") + rejected["audit_events"] = [e for e in rejected["audit_events"] if e["event_type"] != "authority.checked"] + for event in rejected["audit_events"]: + if event["event_type"] in {"approval.decided", "resolution.emitted"}: + event["detail"]["decision"] = "rejected" + if event["event_type"] == "dispatch.succeeded": + event["event_type"] = "dispatch.not_dispatched" + event["detail"] = {"action_hash": dispatch["action_hash"], "reason_code": "approval_rejected"} + refresh_audit(rejected) + write("rejected-envelope.json", rejected) + + expired = deepcopy(base) + expiry = expired["request"]["expires_at"] + expired["request"].update(status="expired", revision=2, terminal_at=expiry, terminal_reason="deadline_elapsed") + for key in ("consumed_at", "dispatch_id", "decision_record_id"): + expired["request"].pop(key) + for key in ("approvals", "resolutions", "authority_snapshots", "dispatches"): + expired[key] = [] + expiry_event = deepcopy(expired["audit_events"][4]) + expiry_event.update(event_type="approval.expired", occurred_at=expiry, recorded_at=expiry, actor=deepcopy(expired["request"]["requested_by"]), references={"request_id": expired["request"]["request_id"]}, detail={"reason_code": "deadline_elapsed"}) + snapshot_event = deepcopy(expired["audit_events"][-1]) + snapshot_event.update(occurred_at=expiry, recorded_at=expiry) + expired["audit_events"] = expired["audit_events"][:4] + [expiry_event, snapshot_event] + expired["captured_at"] = expired["audit_snapshot"]["captured_at"] = expiry + refresh_audit(expired) + write("expired-envelope.json", expired) + + recovered = deepcopy(base) + first = recovered["dispatches"][0] + first.update(status="outcome_unknown", reason="provider_response_lost", reconciliation={"lookup_key_hash": digest_value("provider-lookup-01"), "status": "pending"}) + first.pop("provider") + reconciled = deepcopy(first) + reconciled.update(dispatch_id="dispatch_reconciled_01", previous_dispatch_id=first["dispatch_id"], status="reconciled_not_applied", retry_allowed=True, started_at="2026-04-28T09:14:35Z", completed_at="2026-04-28T09:14:35Z", recorded_at="2026-04-28T09:14:35Z") + reconciled.pop("reason") + reconciled["reconciliation"].update(status="not_applied", checked_at="2026-04-28T09:14:35Z", evidence={"kind":"reconciliation", "uri":"urn:example:lookup:01", "digest":digest_value("not-applied")}) + retry = deepcopy(base["dispatches"][0]) + retry.update(dispatch_id="dispatch_retry_02", previous_dispatch_id=reconciled["dispatch_id"], attempt=2, started_at="2026-04-28T09:14:36Z", completed_at="2026-04-28T09:14:37Z", recorded_at="2026-04-28T09:14:37Z", authority_snapshot_id="authority_retry_02") + retry_authority = deepcopy(recovered["authority_snapshots"][0]) + retry_authority.update(authority_snapshot_id="authority_retry_02", checked_at="2026-04-28T09:14:36Z") + recovered["authority_snapshots"].append(retry_authority) + recovered["dispatches"] = [first, reconciled, retry] + events = recovered["audit_events"] + first_event = events[-2] + first_event["event_type"] = "dispatch.outcome_unknown" + first_event["detail"].pop("provider_result_hash") + reconciliation_event = deepcopy(first_event) + reconciliation_event.update(event_id="event_reconciled_01", event_type="dispatch.reconciled", occurred_at=reconciled["completed_at"], recorded_at=reconciled["recorded_at"]) + reconciliation_event["references"]["dispatch_id"] = reconciled["dispatch_id"] + authority_event = deepcopy(events[-3]) + authority_event.update(event_id="event_retry_authority_02", occurred_at=retry_authority["checked_at"], recorded_at=retry_authority["checked_at"]) + authority_event["references"]["authority_snapshot_id"] = retry_authority["authority_snapshot_id"] + retry_event = deepcopy(base["audit_events"][-2]) + retry_event.update(event_id="event_retry_02", occurred_at=retry["completed_at"], recorded_at=retry["recorded_at"]) + retry_event["references"]["dispatch_id"] = retry["dispatch_id"] + retry_event["detail"]["attempt"] = 2 + final_event = events[-1] + final_event.update(occurred_at="2026-04-28T09:14:38Z", recorded_at="2026-04-28T09:14:38Z") + recovered["audit_events"] = events[:-1] + [reconciliation_event, authority_event, retry_event, final_event] + recovered["captured_at"] = recovered["audit_snapshot"]["captured_at"] = "2026-04-28T09:14:38Z" + refresh_audit(recovered) + write("recovered-envelope.json", recovered) + + def main() -> None: proposal = load("email-reply-approval.json") proposal["payload_schema"]["digest"] = digest_file( @@ -191,7 +311,7 @@ def main() -> None: write("audit-snapshot.json", audit_snapshot) envelope = { - "schema_version": "2.1.0", + "schema_version": "3.0.0", "captured_at": "2026-04-28T09:14:35Z", "proposal": proposal, "validations": [validation], @@ -205,6 +325,7 @@ def main() -> None: "audit_snapshot": audit_snapshot, } write("approval-envelope.json", envelope) + lifecycle_examples(envelope) if __name__ == "__main__": diff --git a/scripts/validate_contracts.py b/scripts/validate_contracts.py index 83b8e57..118df8d 100644 --- a/scripts/validate_contracts.py +++ b/scripts/validate_contracts.py @@ -8,8 +8,11 @@ import binascii import hashlib import json +import math +import re from copy import deepcopy from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -29,7 +32,7 @@ def load(path: str | Path) -> Any: target = Path(path) if not target.is_absolute(): target = ROOT / target - return json.loads(target.read_text(encoding="utf-8")) + return strict_json_loads(target.read_text(encoding="utf-8")) def digest(value: Any) -> str: @@ -57,6 +60,22 @@ def _object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, An return value +def strict_json_loads(value: str) -> Any: + def reject_constant(_: str) -> Any: + raise ValueError("non-finite JSON number") + + def finite_float(value: str) -> float: + number = float(value) + if not math.isfinite(number): + raise ValueError("non-finite JSON number") + return number + + return json.loads( + value, object_pairs_hook=_object_without_duplicate_keys, + parse_constant=reject_constant, parse_float=finite_float, + ) + + def webauthn_signature_errors(approval: dict[str, Any]) -> list[str]: errors: list[str] = [] unsigned_approval = { @@ -112,10 +131,7 @@ def webauthn_signature_errors(approval: dict[str, Any]) -> list[str]: continue try: - client_data = json.loads( - client_data_json.decode("utf-8"), - object_pairs_hook=_object_without_duplicate_keys, - ) + client_data = strict_json_loads(client_data_json.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: errors.append(f"{prefix} contains invalid clientDataJSON: {error}") continue @@ -209,6 +225,7 @@ def webauthn_signature_errors(approval: dict[str, Any]) -> list[str]: return errors +@lru_cache(maxsize=1) def schema_catalog() -> tuple[dict[str, dict[str, Any]], Registry]: schemas: dict[str, dict[str, Any]] = {} resources: list[tuple[str, Resource[Any]]] = [] @@ -231,30 +248,78 @@ def validator_for( ) +def _array_index(token: str, length: int, *, add: bool = False) -> int: + if add and token == "-": + return length + if not re.fullmatch(r"0|[1-9][0-9]*", token): + raise ValueError("invalid JSON Pointer array index") + index = int(token) + if index > length or (index == length and not add): + raise ValueError("JSON Pointer array index is out of bounds") + return index + + def _pointer_parent(document: Any, path: str) -> tuple[Any, str]: if not path.startswith("/"): raise ValueError(f"JSON Pointer must start with '/': {path}") + if re.search(r"~(?![01])", path): + raise ValueError("invalid JSON Pointer escape") tokens = [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] current = document for token in tokens[:-1]: - current = current[int(token)] if isinstance(current, list) else current[token] + if isinstance(current, list): + current = current[_array_index(token, len(current))] + elif isinstance(current, dict) and token in current: + current = current[token] + else: + raise ValueError("JSON Pointer parent does not exist") return current, tokens[-1] +def _json_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return left == right + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(_json_equal(left[k], right[k]) for k in left) + if isinstance(left, list): + return len(left) == len(right) and all(_json_equal(a, b) for a, b in zip(left, right)) + return left == right + + def apply_operations(document: Any, operations: list[dict[str, Any]]) -> Any: result = deepcopy(document) for operation in operations: - parent, key = _pointer_parent(result, operation["path"]) op = operation["op"] + if op not in {"add", "remove", "replace", "test"}: + raise ValueError(f"unsupported operation: {op}") + if operation["path"] == "": + if op == "remove": + raise ValueError("removing the document root is not supported") + if op == "test": + if not _json_equal(result, operation["value"]): + raise ValueError("test operation failed at root") + else: + result = deepcopy(operation["value"]) + continue + parent, key = _pointer_parent(result, operation["path"]) + if not isinstance(parent, (dict, list)): + raise ValueError("JSON Pointer parent is not a container") + if isinstance(parent, list): + index = _array_index(key, len(parent), add=op == "add") + elif op != "add" and key not in parent: + raise ValueError("JSON Pointer target does not exist") if op == "remove": if isinstance(parent, list): - parent.pop(int(key)) + parent.pop(index) else: del parent[key] elif op in {"add", "replace"}: value = deepcopy(operation["value"]) if isinstance(parent, list): - index = len(parent) if key == "-" else int(key) if op == "add": parent.insert(index, value) else: @@ -262,11 +327,9 @@ def apply_operations(document: Any, operations: list[dict[str, Any]]) -> Any: else: parent[key] = value elif op == "test": - actual = parent[int(key)] if isinstance(parent, list) else parent[key] - if actual != operation["value"]: + actual = parent[index] if isinstance(parent, list) else parent[key] + if not _json_equal(actual, operation["value"]): raise ValueError(f"test operation failed at {operation['path']}") - else: - raise ValueError(f"unsupported operation: {op}") return result @@ -293,7 +356,185 @@ def canonicalization_errors() -> list[str]: return errors -def semantic_errors(envelope: dict[str, Any]) -> list[str]: +def payload_errors(action: dict[str, Any], schema_files: dict[str, Path] | None = None) -> list[str]: + """Use locally supplied released bytes; never resolve an agent-selected URL.""" + if "payload" not in action: + return ["effective action has no payload"] + files = { + load(path)["$id"]: path + for path in (ROOT / "schemas" / "actions").glob("*.json") + } + files.update(schema_files or {}) + binding = action["payload_schema"] + path = files.get(binding["id"]) + if path is None: + return ["payload schema is not available in the trusted local catalog"] + try: + schema = load(path) + if schema["$id"] != binding["id"]: + return ["payload schema ID mismatch"] + if f"/v{binding['version']}/" not in binding["id"]: + return ["payload schema version mismatch"] + expected_type = schema.get("x-action-type", path.name.removesuffix(".schema.json")) + if expected_type != action["action_type"]: + return ["payload schema action type mismatch"] + file_hash = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if binding["digest"] != file_hash: + return ["payload schema digest mismatch"] + Draft202012Validator.check_schema(schema) + _, registry = schema_catalog() + registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) + validator = Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()) + if list(validator.iter_errors(action["payload"])): + return ["effective payload fails its action schema"] + except Exception: + return ["payload schema could not be validated from the trusted catalog"] + return [] + + +def validate_envelope(envelope: Any, schema_files: dict[str, Path] | None = None) -> list[str]: + schemas, registry = schema_catalog() + validator = validator_for("schemas/approval-envelope.schema.json", schemas, registry) + if list(validator.iter_errors(envelope)): + return ["envelope fails structural validation"] + try: + return semantic_errors(envelope, schema_files) + except (ValueError, TypeError, KeyError, IndexError, rfc8785.CanonicalizationError): + return ["envelope contains invalid semantic input"] + + +def dispatch_history_errors(dispatches: list[dict[str, Any]], request: dict[str, Any]) -> list[str]: + errors: list[str] = [] + previous: dict[str, Any] | None = None + for record in dispatches: + if record["retry_allowed"] and record["attempt"] >= record["max_attempts"]: + errors.append("dispatch at attempt ceiling cannot allow retry") + reconciled = record["status"].startswith("reconciled_") + if previous is None: + if reconciled or record["attempt"] != 1 or "previous_dispatch_id" in record: + errors.append("dispatch history must start with an initial attempt") + if request["status"] == "consumed" and record["dispatch_id"] != request["dispatch_id"]: + errors.append("dispatch history does not start at the consumed dispatch") + else: + if record.get("previous_dispatch_id") != previous["dispatch_id"]: + errors.append("dispatch history previous_dispatch_id linkage is broken") + for field in ("action_hash", "idempotency_key", "max_attempts"): + if record[field] != previous[field]: + errors.append(f"dispatch history changes {field}") + if set(record["approval_ids"]) != set(previous["approval_ids"]): + errors.append("dispatch history changes the consumed approval set") + if parse_time(record["started_at"]) < parse_time(previous["recorded_at"]): + errors.append("dispatch history starts before prior outcome was recorded") + if reconciled: + if previous["status"] != "outcome_unknown" or record["attempt"] != previous["attempt"]: + errors.append("reconciliation must resolve the preceding unknown attempt") + if previous.get("reconciliation") and record["reconciliation"]["lookup_key_hash"] != previous["reconciliation"]["lookup_key_hash"]: + errors.append("reconciliation changed its lookup identity") + if parse_time(record["reconciliation"]["checked_at"]) < parse_time(previous["recorded_at"]): + errors.append("reconciliation evidence predates the unknown outcome") + if parse_time(record["reconciliation"]["checked_at"]) > parse_time(record["recorded_at"]): + errors.append("reconciliation was recorded before its evidence") + else: + if previous["status"] not in {"failed", "reconciled_not_applied"} or not previous["retry_allowed"]: + errors.append("retry requires authoritative failure or reconciled non-application") + if record["attempt"] != previous["attempt"] + 1: + errors.append("retry attempt numbers must be contiguous") + previous = record + return errors + + +def audit_binding_errors(envelope: dict[str, Any]) -> list[str]: + errors: list[str] = [] + request = envelope["request"] + records = { + "validation_id": {x["validation_id"]: x for x in envelope["validations"]}, + "review_snapshot_id": {x["snapshot_id"]: x for x in envelope["review_snapshots"]}, + "request_id": {request["request_id"]: request}, + "approval_id": {x["approval_id"]: x for x in envelope["approvals"]}, + "resolution_id": {x["resolution_id"]: x for x in envelope["resolutions"]}, + "authority_snapshot_id": {x["authority_snapshot_id"]: x for x in envelope["authority_snapshots"]}, + "dispatch_id": {x["dispatch_id"]: x for x in envelope["dispatches"]}, + "audit_snapshot_id": {envelope["audit_snapshot"]["snapshot_id"]: envelope["audit_snapshot"]}, + "policy_evaluation_id": {request["policy"]["evaluation_id"]: request["policy"]}, + } + witness_types = { + "validation_id": {"validation.passed", "validation.rejected"}, + "review_snapshot_id": {"review.presented"}, + "request_id": {"approval.requested"}, + "approval_id": {"approval.decided"}, + "resolution_id": {"resolution.emitted"}, + "authority_snapshot_id": {"authority.checked"}, + "dispatch_id": {"dispatch.not_dispatched", "dispatch.succeeded", "dispatch.failed", "dispatch.outcome_unknown", "dispatch.reconciled"}, + "audit_snapshot_id": {"audit.snapshotted"}, + } + witnessed: dict[str, set[str]] = {key: set() for key in witness_types} + event_ids: set[str] = set() + for event in envelope["audit_events"]: + if event["event_id"] in event_ids: + errors.append("audit events reuse event_id") + event_ids.add(event["event_id"]) + refs = event["references"] + event_type = event["event_type"] + for key, value in refs.items(): + if value not in records[key]: + errors.append(f"audit event references unknown {key}") + continue + if event_type in witness_types.get(key, set()): + witnessed[key].add(value) + primary_keys = [key for key, types in witness_types.items() if event_type in types] + for key in primary_keys: + if key not in refs: + errors.append(f"audit {event_type} lacks its {key} witness") + detail = event["detail"] + primary = next((records[key].get(refs.get(key)) for key in primary_keys if records[key].get(refs.get(key))), None) + if primary is None: + continue + for key in ("action_hash", "payload_hash", "decision", "attempt"): + if key in detail and key in primary and detail[key] != primary[key]: + errors.append(f"audit {event_type} {key} disagrees with its record") + for key in ("approval_id", "request_id", "proposal_id"): + if key in refs and key in primary and refs[key] != primary[key]: + errors.append(f"audit {event_type} {key} disagrees with its record") + if "policy_revision" in detail and detail["policy_revision"] != request["policy"]["revision"]: + errors.append("audit policy revision mismatch") + if event_type == "validation.passed" and primary["outcome"] != "passed": + errors.append("audit validation outcome disagrees with validation record") + if event_type == "validation.rejected" and primary["outcome"] == "passed": + errors.append("audit validation outcome disagrees with validation record") + if event_type == "review.presented" and "review_content_hash" in detail and detail["review_content_hash"] != primary["content_hash"]: + errors.append("audit review content hash disagrees with snapshot") + if event_type == "approval.decided": + if event["actor"]["kind"] != primary["decided_by"]["kind"] or event["actor"]["identifier"] != primary["decided_by"]["identifier"]: + errors.append("audit decision actor disagrees with approval") + if parse_time(event["occurred_at"]) != parse_time(primary["decided_at"]): + errors.append("audit decision time disagrees with approval") + if event_type.startswith("dispatch."): + expected = { + "succeeded": "dispatch.succeeded", "failed": "dispatch.failed", + "not_dispatched": "dispatch.not_dispatched", "outcome_unknown": "dispatch.outcome_unknown", + "reconciled_succeeded": "dispatch.reconciled", "reconciled_not_applied": "dispatch.reconciled", + }[primary["status"]] + if event_type != expected: + errors.append("audit dispatch outcome disagrees with dispatch record") + if "provider_result_hash" in detail and detail["provider_result_hash"] != primary.get("provider", {}).get("result_hash"): + errors.append("audit provider result hash disagrees with dispatch record") + witness_time = { + "validation.passed": "validated_at", "validation.rejected": "validated_at", + "review.presented": "rendered_at", "approval.requested": "requested_at", + "resolution.emitted": "emitted_at", "authority.checked": "checked_at", + "dispatch.succeeded": "completed_at", "dispatch.failed": "completed_at", + "dispatch.outcome_unknown": "completed_at", "dispatch.not_dispatched": "completed_at", + "dispatch.reconciled": "completed_at", "audit.snapshotted": "captured_at", + }.get(event_type) + if witness_time and parse_time(event["occurred_at"]) != parse_time(primary[witness_time]): + errors.append("audit witness time disagrees with its record") + for key, witnesses in witnessed.items(): + if set(records[key]) - witnesses: + errors.append(f"audit stream lacks typed witnesses for {key}") + return errors + + +def semantic_errors(envelope: dict[str, Any], schema_files: dict[str, Path] | None = None) -> list[str]: errors: list[str] = [] proposal = envelope["proposal"] validations = envelope["validations"] @@ -306,6 +547,19 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: events = envelope["audit_events"] audit_snapshot = envelope["audit_snapshot"] + errors.extend(payload_errors(proposal, schema_files)) + for records in (validations, review_snapshots, approvals, resolutions, authority_snapshots, dispatches): + if any(record["proposal_id"] != proposal["proposal_id"] for record in records): + errors.append("lifecycle record points at another proposal") + if request["status"] == "pending": + errors.append("complete envelope cannot contain a pending request") + invocation_statuses = {"succeeded", "failed", "outcome_unknown"} + invokes_provider = any(item["status"] in invocation_statuses for item in dispatches) + if invokes_provider and request["status"] != "consumed": + errors.append("provider invocation requires a consumed request") + if request["status"] in {"rejected", "expired", "cancelled", "failed"} and invokes_provider: + errors.append("refused request cannot invoke a provider") + initial_action_hash = digest(proposal) if request["action_hash"] != initial_action_hash: errors.append("request action_hash does not match the proposed action") @@ -414,7 +668,9 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: errors.append(f"review snapshot {snapshot['snapshot_id']} was rendered after expiry") if proposal["action_type"] == "email.send": expected_target = { - key: proposal["payload"][key] for key in ("from", "to", "cc") + key: snapshot["content"]["arguments"][key] + for key in ("from", "to", "cc") + if key in snapshot["content"]["arguments"] } if snapshot["content"]["target"] != expected_target: errors.append( @@ -444,7 +700,24 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: errors.append(f"approval {approval['approval_id']} points at another proposal") if approval["request_id"] != request["request_id"]: errors.append(f"approval {approval['approval_id']} points at another request") - effective_action = apply_operations(proposal, approval.get("modifications", [])) + operations = approval.get("modifications", []) + if any(op["path"] != "/payload" and not op["path"].startswith("/payload/") for op in operations): + errors.append("approval modifications must remain inside payload") + continue + try: + effective_action = apply_operations(proposal, operations) + except (ValueError, TypeError, KeyError, IndexError): + errors.append(f"approval {approval['approval_id']} has invalid JSON Patch") + continue + schemas, registry = schema_catalog() + effective_validator = validator_for("schemas/proposed-action.schema.json", schemas, registry) + if list(effective_validator.iter_errors(effective_action)): + errors.append(f"approval {approval['approval_id']} effective action fails proposal schema") + errors.extend(payload_errors(effective_action, schema_files)) + continue + errors.extend(payload_errors(effective_action, schema_files)) + if approval["policy_evaluation_id"] != request["policy"]["evaluation_id"]: + errors.append(f"approval {approval['approval_id']} policy evaluation mismatch") expected_action_hash = digest(effective_action) expected_payload_hash = digest(effective_action["payload"]) effective_hashes[approval["approval_id"]] = expected_action_hash @@ -491,11 +764,11 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: if approval["decision"] in {"approved", "auto_approved", "rejected"}: if decided_at < requested_at: errors.append(f"approval {approval['approval_id']} predates its request") - if parse_time(approval["decided_at"]) > request_expires_at: + if parse_time(approval["decided_at"]) >= request_expires_at: errors.append(f"approval {approval['approval_id']} was decided after expiry") requirements = request["policy"]["requirements"] - if len(approved_records) < requirements["minimum_approvals"]: + if request["status"] in {"approved", "consumed"} and len(approved_records) < requirements["minimum_approvals"]: errors.append("approval quorum is not satisfied") if requirements["distinct_approvers"]: actors = { @@ -566,6 +839,8 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: if approval is None: errors.append(f"resolution {resolution_id} names an unknown approval") continue + if approval["approval_id"] in resolved_approval_ids: + errors.append("approval has more than one terminal resolution receipt") resolved_approval_ids.add(approval["approval_id"]) if resolution["approval_record_hash"] != digest(approval): errors.append(f"resolution {resolution_id} approval_record_hash mismatch") @@ -666,6 +941,9 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: ) approval_ids = {approval["approval_id"] for approval in approved_records} + dispatch_by_id = {item["dispatch_id"]: item for item in dispatches} + if len(dispatch_by_id) != len(dispatches): + errors.append("dispatch records reuse dispatch_id") for dispatch in dispatches: if dispatch["proposal_id"] != proposal["proposal_id"]: errors.append(f"dispatch {dispatch['dispatch_id']} points at another proposal") @@ -678,18 +956,24 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: for item in dispatch["approval_ids"] if item in effective_hashes } - if dispatch["action_hash"] not in expected_hashes: - errors.append(f"dispatch {dispatch['dispatch_id']} action_hash is not approved") + if dispatch["status"] == "not_dispatched": + if dispatch["action_hash"] not in set(effective_hashes.values()) | {initial_action_hash}: + errors.append("refusal dispatch action_hash does not match lifecycle") + else: + if expected_hashes != {dispatch["action_hash"]}: + errors.append(f"dispatch {dispatch['dispatch_id']} mixes or lacks approved action hashes") + cited = [approval_by_id[item] for item in dispatch["approval_ids"] if item in approval_ids] + if len(cited) < requirements["minimum_approvals"]: + errors.append(f"dispatch {dispatch['dispatch_id']} approval quorum is not satisfied") if dispatch["idempotency_key"] != proposal["idempotency_key"]: errors.append(f"dispatch {dispatch['dispatch_id']} changed the idempotency key") if dispatch["attempt"] > dispatch["max_attempts"]: errors.append(f"dispatch {dispatch['dispatch_id']} exceeds max_attempts") started_at = parse_time(dispatch["started_at"]) - authority = authority_by_id.get(dispatch["authority_snapshot_id"]) + authority = authority_by_id.get(dispatch.get("authority_snapshot_id")) if authority is None: - errors.append( - f"dispatch {dispatch['dispatch_id']} names an unknown authority snapshot" - ) + if dispatch["status"] in invocation_statuses or "authority_snapshot_id" in dispatch: + errors.append(f"dispatch {dispatch['dispatch_id']} names an unknown authority snapshot") else: checked_at = parse_time(authority["checked_at"]) if authority["action_hash"] != dispatch["action_hash"]: @@ -704,11 +988,11 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: errors.append( f"dispatch {dispatch['dispatch_id']} authority check predates consumption" ) - if "valid_until" in authority and started_at > parse_time(authority["valid_until"]): + if dispatch["status"] in invocation_statuses and started_at >= parse_time(authority["valid_until"]): errors.append( f"dispatch {dispatch['dispatch_id']} used expired authority evidence" ) - if dispatch["status"] != "not_dispatched" and authority["status"] != "active": + if dispatch["status"] in invocation_statuses and authority["status"] != "active": errors.append( f"dispatch {dispatch['dispatch_id']} used inactive authority" ) @@ -727,13 +1011,26 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: else None ) recorded_at = parse_time(dispatch["recorded_at"]) - if started_at > proposal_expires_at: + if dispatch["status"] in invocation_statuses and started_at >= min(proposal_expires_at, request_expires_at): errors.append(f"dispatch {dispatch['dispatch_id']} started after proposal expiry") if completed_at and completed_at < started_at: errors.append(f"dispatch {dispatch['dispatch_id']} completed before it started") if recorded_at < (completed_at or started_at): errors.append(f"dispatch {dispatch['dispatch_id']} was recorded too early") + terminal_at = parse_time(request["terminal_at"]) if "terminal_at" in request else None + decision_record = approval_by_id.get(request.get("decision_record_id")) + if terminal_at is not None: + if terminal_at < requested_at: + errors.append("request terminal state predates its request") + if decision_record and terminal_at < parse_time(decision_record["recorded_at"]): + errors.append("request terminal state predates its decision record") + if request["status"] == "approved" and (not decision_record or decision_record["decision"] not in {"approved", "auto_approved"}): + errors.append("approved request lacks an approving decision") + if request["status"] == "rejected" and (not decision_record or decision_record["decision"] != "rejected"): + errors.append("rejected request lacks a rejecting decision") + if request["status"] == "expired" and terminal_at and terminal_at < request_expires_at: + errors.append("expired request terminated before its expiry") if request["status"] == "consumed": if request["decision_record_id"] not in approval_ids: errors.append("consumed request does not name an approving decision") @@ -742,11 +1039,29 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: consumed_at = parse_time(request["consumed_at"]) if consumed_at < requested_at: errors.append("request was consumed before it was created") + if consumed_at >= request_expires_at: + errors.append("request was consumed at or after expiry") + if terminal_at and consumed_at < terminal_at: + errors.append("request was consumed before its terminal decision") + if decision_record and consumed_at < parse_time(decision_record["recorded_at"]): + errors.append("request was consumed before its decision was recorded") + if decision_record and request["revision"] < decision_record["request_revision"] + 2: + errors.append("consumed request lacks decision and consumption revisions") matching_dispatches = [ item for item in dispatches if item["dispatch_id"] == request["dispatch_id"] ] if matching_dispatches and parse_time(matching_dispatches[0]["started_at"]) < consumed_at: errors.append("dispatch started before the request was consumed") + if matching_dispatches and request["decision_record_id"] not in matching_dispatches[0]["approval_ids"]: + errors.append("consumption decision is not in the initial dispatch approval set") + errors.extend(dispatch_history_errors(dispatches, request)) + captured_at = parse_time(envelope["captured_at"]) + for records, fields in ((validations, ("validated_at",)), (review_snapshots, ("rendered_at",)), + (approvals, ("recorded_at",)), (resolutions, ("emitted_at",)), + (authority_snapshots, ("checked_at",)), (dispatches, ("recorded_at",)), + (events, ("recorded_at",)), ([audit_snapshot], ("captured_at",))): + if any(parse_time(record[field]) > captured_at for record in records for field in fields): + errors.append("envelope capture predates a contained record") expected_previous: str | None = None previous_recorded_at: datetime | None = None @@ -797,18 +1112,25 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: "validation.passed", "approval.requested", "review.presented", - "approval.decided", - "resolution.emitted", - "authority.checked", "audit.snapshotted", } + if approvals: + required_event_types.add("approval.decided") + if resolutions: + required_event_types.add("resolution.emitted") + if authority_snapshots: + required_event_types.add("authority.checked") + if request["status"] == "expired": + required_event_types.add("approval.expired") + if request["status"] == "cancelled": + required_event_types.add("approval.cancelled") missing_event_types = sorted(required_event_types - event_types) if missing_event_types: errors.append( "audit stream lacks required lifecycle events: " + ", ".join(missing_event_types) ) - if not event_types.intersection( + if dispatches and not event_types.intersection( { "dispatch.succeeded", "dispatch.failed", @@ -838,6 +1160,7 @@ def semantic_errors(envelope: dict[str, Any]) -> list[str]: for key, expected in expected_audit_references.items(): if not expected.issubset(observed_audit_references[key]): errors.append(f"audit stream omits a {key} lifecycle reference") + errors.extend(audit_binding_errors(envelope)) return errors @@ -904,17 +1227,17 @@ def n8n_reference_errors() -> list[str]: if unreachable: errors.append(f"n8n required nodes are unreachable: {', '.join(unreachable)}") rendered = json.dumps(workflow, sort_keys=True) - if "'/v1/" in rendered or '"/v1/' in rendered: - errors.append("n8n workflow still references a v1 contract endpoint") + if re.search(r"/v[12]/", rendered): + errors.append("n8n workflow still references an older contract endpoint") for endpoint in ( - "/v2/validate", - "/v2/validations", - "/v2/requests", - "/v2/review-snapshots", - "/v2/resolutions", - "/v2/authority-snapshots", - "/v2/events", - "/v2/snapshots", + "/v3/validate", + "/v3/validations", + "/v3/requests", + "/v3/review-snapshots", + "/v3/resolutions", + "/v3/authority-snapshots", + "/v3/events", + "/v3/snapshots", ): if endpoint not in rendered: errors.append(f"n8n workflow does not reference {endpoint}") @@ -942,7 +1265,7 @@ def validate_repository() -> list[str]: if "/main/" in identifier: failures.append(f"{path}: $id points at mutable main") - for corpus_path in ("tests/conformance.json", "tests/adversarial.json"): + for corpus_path in ("tests/conformance.json", "tests/adversarial.json", "tests/hardening.json"): corpus = load(corpus_path) for vector in corpus["positive"]: validator = validator_for(vector["schema"], schemas, registry) @@ -998,6 +1321,8 @@ def validate_repository() -> list[str]: f"{corpus_path} negative {vector['name']}: " "unexpectedly passed semantic validation" ) + if vector.get("expect_error") and not any(vector["expect_error"] in error for error in vector_errors): + failures.append(f"{corpus_path} negative {vector['name']}: expected refusal reason was not reported") failures.extend(canonicalization_errors()) failures.extend(f"n8n reference: {error}" for error in n8n_reference_errors()) return failures @@ -1007,7 +1332,10 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--quiet", action="store_true") args = parser.parse_args() - failures = validate_repository() + try: + failures = validate_repository() + except (ValueError, TypeError, KeyError, IndexError, rfc8785.CanonicalizationError): + failures = ["contract input could not be validated"] if failures: for failure in failures: print(f"FAIL: {failure}") diff --git a/tests/hardening.json b/tests/hardening.json new file mode 100644 index 0000000..04293c9 --- /dev/null +++ b/tests/hardening.json @@ -0,0 +1,194 @@ +{ + "positive": [ + { + "name": "rejected lifecycle", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/rejected-envelope.json", + "semantic": true + }, + { + "name": "expired lifecycle", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/expired-envelope.json", + "semantic": true + }, + { + "name": "recovered lifecycle", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/recovered-envelope.json", + "semantic": true + }, + { + "name": "approved effective edit", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/edited-envelope.json", + "semantic": true + } + ], + "negative": [ + { + "name": "approved but unconsumed request cannot invoke provider", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "provider invocation requires a consumed request", + "operations": [ + { + "op": "replace", + "path": "/request/status", + "value": "approved" + }, + { + "op": "replace", + "path": "/request/terminal_reason", + "value": "approved" + }, + { + "op": "remove", + "path": "/request/consumed_at" + }, + { + "op": "remove", + "path": "/request/dispatch_id" + } + ] + }, + { + "name": "edited payload must retain required fields", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "effective payload fails its action schema", + "operations": [ + { + "op": "add", + "path": "/approvals/0/modifications", + "value": [ + { + "op": "remove", + "path": "/payload/subject" + } + ] + } + ] + }, + { + "name": "payload schema digest names exact file bytes", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "payload schema digest mismatch", + "operations": [ + { + "op": "replace", + "path": "/proposal/payload_schema/digest", + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ] + }, + { + "name": "decision must cite exact policy evaluation", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "policy evaluation mismatch", + "operations": [ + { + "op": "replace", + "path": "/approvals/0/policy_evaluation_id", + "value": "eval_other_01" + } + ] + }, + { + "name": "consumption needs two state transitions", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "consumption revisions", + "operations": [ + { + "op": "replace", + "path": "/request/revision", + "value": 2 + } + ] + }, + { + "name": "retry cannot change attempt ceiling", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/recovered-envelope.json", + "expect": "semantic", + "expect_error": "changes max_attempts", + "operations": [ + { + "op": "replace", + "path": "/dispatches/2/max_attempts", + "value": 4 + } + ] + }, + { + "name": "retry cannot skip attempt number", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/recovered-envelope.json", + "expect": "semantic", + "expect_error": "attempt numbers must be contiguous", + "operations": [ + { + "op": "replace", + "path": "/dispatches/2/attempt", + "value": 3 + } + ] + }, + { + "name": "audit cannot cite foreign lifecycle record", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "references unknown dispatch_id", + "operations": [ + { + "op": "add", + "path": "/audit_events/0/references/dispatch_id", + "value": "dispatch_foreign_01" + } + ] + }, + { + "name": "negative patch indices are refused", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/approval-envelope.json", + "expect": "semantic", + "expect_error": "invalid JSON Patch", + "operations": [ + { + "op": "add", + "path": "/approvals/0/modifications", + "value": [ + { + "op": "replace", + "path": "/payload/to/-1", + "value": "other@example.com" + } + ] + } + ] + }, + { + "name": "expired requests cannot expire early", + "schema": "schemas/approval-envelope.schema.json", + "instance": "examples/expired-envelope.json", + "expect": "semantic", + "expect_error": "terminated before its expiry", + "operations": [ + { + "op": "replace", + "path": "/request/terminal_at", + "value": "2026-04-28T09:14:25Z" + } + ] + } + ] +} diff --git a/tests/test_lifecycle_hardening.py b/tests/test_lifecycle_hardening.py new file mode 100644 index 0000000..0c62d0c --- /dev/null +++ b/tests/test_lifecycle_hardening.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import hashlib +import json +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from generate_examples import refresh_audit # noqa: E402 +from validate_contracts import ( # noqa: E402 + apply_operations, digest, load, payload_errors, strict_json_loads, validate_envelope, +) + + +class LifecycleHardeningTests(unittest.TestCase): + def setUp(self) -> None: + self.envelope = load("examples/approval-envelope.json") + + def assert_refused(self, envelope: dict, reason: str) -> None: + refresh_audit(envelope) + errors = validate_envelope(envelope) + self.assertTrue(any(reason in error for error in errors), errors) + + def test_positive_terminal_and_recovery_lifecycles(self) -> None: + for name in ("approval", "edited", "rejected", "expired", "recovered"): + with self.subTest(name=name): + self.assertEqual(validate_envelope(load(f"examples/{name}-envelope.json")), []) + + def two_approvals(self, *, modified: bool = False) -> dict: + envelope = self.envelope + request = envelope["request"] + request["policy"]["requirements"]["minimum_approvals"] = 2 + request["policy"]["requirements"]["separation_of_duties"]["eligible_approver_ids"].append("second@example.com") + approval = deepcopy(envelope["approvals"][0]) + approval["approval_id"] = "approval_second_01" + approval["decided_by"]["identifier"] = "second@example.com" + approval["signatures"][0]["key_id"] = "second-signing-key" + if modified: + approval["modifications"] = [{"op": "replace", "path": "/payload/subject", "value": "Changed subject"}] + effective = apply_operations(envelope["proposal"], approval["modifications"]) + approval["action_hash"] = digest(effective) + approval["payload_hash"] = digest(effective["payload"]) + review = deepcopy(envelope["review_snapshots"][0]) + review["snapshot_id"] = "review_second_01" + review["action_hash"] = approval["action_hash"] + review["payload_hash"] = approval["payload_hash"] + review["content"]["arguments"] = effective["payload"] + review["content_hash"] = digest(review["content"]) + approval["review_snapshot_id"] = review["snapshot_id"] + approval["review_content_hash"] = review["content_hash"] + envelope["review_snapshots"].append(review) + event = deepcopy(envelope["audit_events"][3]) + event["event_id"] = "event_second_review" + event["references"]["review_snapshot_id"] = review["snapshot_id"] + event["detail"].update(action_hash=review["action_hash"], payload_hash=review["payload_hash"], review_content_hash=review["content_hash"]) + envelope["audit_events"].insert(4, event) + envelope["approvals"].append(approval) + resolution = deepcopy(envelope["resolutions"][0]) + resolution.update(resolution_id="resolution_second_01", approval_id=approval["approval_id"], approval_record_hash=digest(approval), action_hash=approval["action_hash"]) + envelope["resolutions"].append(resolution) + authority = envelope["authority_snapshots"][0] + approver = deepcopy(authority["subjects"][0]) + approver["identifier"] = "second@example.com" + authority["subjects"].append(approver) + authority["authority_digest"] = digest(authority["subjects"]) + for event_type, record in (("approval.decided", approval), ("resolution.emitted", resolution)): + original = next(e for e in envelope["audit_events"] if e["event_type"] == event_type) + event = deepcopy(original) + event["event_id"] = "event_second_" + event_type.replace(".", "_") + event["references"]["approval_id"] = approval["approval_id"] + event["detail"]["action_hash"] = approval["action_hash"] + if event_type == "approval.decided": + event["actor"] = deepcopy(approval["decided_by"]) + event["detail"]["payload_hash"] = approval["payload_hash"] + else: + event["references"]["resolution_id"] = resolution["resolution_id"] + index = envelope["audit_events"].index(original) + envelope["audit_events"].insert(index + 1, event) + refresh_audit(envelope) + return envelope + + def test_dispatch_must_cite_full_quorum(self) -> None: + envelope = self.two_approvals() + self.assert_refused(envelope, "dispatch " + envelope["dispatches"][0]["dispatch_id"] + " approval quorum") + envelope["dispatches"][0]["approval_ids"].append("approval_second_01") + self.assertEqual(validate_envelope(envelope), []) + + def test_approvals_for_different_actions_do_not_form_quorum(self) -> None: + envelope = self.two_approvals(modified=True) + envelope["dispatches"][0]["approval_ids"].append("approval_second_01") + self.assert_refused(envelope, "mixes or lacks approved action hashes") + + def test_payload_revalidation_after_edits(self) -> None: + for operation in ({"op": "remove", "path": "/payload/subject"}, + {"op": "replace", "path": "/payload/to", "value": ["invalid-address"]}, + {"op": "add", "path": "/payload/unreviewed", "value": True}): + with self.subTest(operation=operation): + envelope = deepcopy(self.envelope) + envelope["approvals"][0]["modifications"] = [operation] + self.assert_refused(envelope, "effective payload fails its action schema") + + def test_patch_cannot_remove_payload_container(self) -> None: + self.envelope["approvals"][0]["modifications"] = [{"op":"remove", "path":"/payload"}] + self.assert_refused(self.envelope, "effective action fails proposal schema") + + def test_review_target_follows_the_effective_recipients(self) -> None: + envelope = load("examples/edited-envelope.json") + approval = envelope["approvals"][0] + approval["modifications"].append({"op":"replace", "path":"/payload/to/0", "value":"new@example.com"}) + effective = apply_operations(envelope["proposal"], approval["modifications"]) + approval["action_hash"] = digest(effective) + approval["payload_hash"] = digest(effective["payload"]) + review = envelope["review_snapshots"][-1] + review["content"]["arguments"] = deepcopy(effective["payload"]) + review["content"]["target"]["to"] = ["new@example.com"] + review.update(action_hash=approval["action_hash"], payload_hash=approval["payload_hash"], content_hash=digest(review["content"])) + approval["review_content_hash"] = review["content_hash"] + envelope["resolutions"][0].update(action_hash=approval["action_hash"], approval_record_hash=digest(approval)) + envelope["authority_snapshots"][0]["action_hash"] = approval["action_hash"] + envelope["dispatches"][0]["action_hash"] = approval["action_hash"] + for event in envelope["audit_events"][4:]: + if "action_hash" in event["detail"]: + event["detail"]["action_hash"] = approval["action_hash"] + if "payload_hash" in event["detail"]: + event["detail"]["payload_hash"] = approval["payload_hash"] + if "review_content_hash" in event["detail"]: + event["detail"]["review_content_hash"] = review["content_hash"] + refresh_audit(envelope) + self.assertEqual(validate_envelope(envelope), []) + + def test_payload_schema_binding(self) -> None: + for field, value, reason in (("digest", "sha256:" + "0" * 64, "payload schema digest mismatch"), + ("id", "https://untrusted.example/v3.0.0/action.json", "trusted local catalog"), + ("version", "0.0.0", "payload schema version mismatch")): + envelope = deepcopy(self.envelope) + envelope["proposal"]["payload_schema"][field] = value + with self.subTest(field=field): + self.assert_refused(envelope, reason) + + def test_trusted_custom_payload_catalog(self) -> None: + action = deepcopy(self.envelope["proposal"]) + schema = {"$schema":"https://json-schema.org/draft/2020-12/schema", "$id":"https://contracts.example/v1.0.0/action.json", "x-action-type":"ticket.create", "type":"object", "required":["title"], "additionalProperties":False, "properties":{"title":{"type":"string"}}} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "custom.json" + path.write_text(json.dumps(schema)) + action.update(action_type="ticket.create", payload={"title":"Example"}) + action["payload_schema"] = {"id":schema["$id"], "version":"1.0.0", "digest":"sha256:"+hashlib.sha256(path.read_bytes()).hexdigest()} + self.assertEqual(payload_errors(action, {schema["$id"]:path}), []) + action["payload"]["extra"] = True + self.assertIn("effective payload fails its action schema", payload_errors(action, {schema["$id"]:path})) + action["action_type"] = "email.send" + self.assertIn("payload schema action type mismatch", payload_errors(action, {schema["$id"]:path})) + + def test_unconsumed_request_cannot_dispatch(self) -> None: + request = self.envelope["request"] + request.update(status="approved", terminal_reason="approved", revision=2) + request.pop("consumed_at") + request.pop("dispatch_id") + self.assert_refused(self.envelope, "provider invocation requires a consumed request") + + def test_consumption_causality_and_revisions(self) -> None: + for field, value, reason in (("consumed_at", "2026-04-28T09:14:30Z", "consumed before its decision"), + ("revision", 2, "consumption revisions"), + ("terminal_at", "2026-04-28T09:14:30Z", "terminal state predates its decision")): + envelope = deepcopy(self.envelope) + envelope["request"][field] = value + with self.subTest(field=field): + self.assert_refused(envelope, reason) + + def test_expiry_is_exclusive_for_decision_consumption_and_execution(self) -> None: + for target, field in (("approval", "decided_at"), ("request", "consumed_at"), ("dispatch", "started_at")): + envelope = deepcopy(self.envelope) + record = {"approval":envelope["approvals"][0], "request":envelope["request"], "dispatch":envelope["dispatches"][0]}[target] + record[field] = envelope["request"]["expires_at"] + with self.subTest(target=target): + self.assert_refused(envelope, "expiry") + + def test_policy_evaluation_binding(self) -> None: + self.envelope["approvals"][0]["policy_evaluation_id"] = "eval_different_01" + self.assert_refused(self.envelope, "policy evaluation mismatch") + + def test_policy_binding_is_required(self) -> None: + self.envelope["approvals"][0].pop("policy_evaluation_id") + self.assertIn("envelope fails structural validation", validate_envelope(self.envelope)) + + def test_authority_must_have_a_finite_exclusive_window(self) -> None: + envelope = deepcopy(self.envelope) + envelope["authority_snapshots"][0].pop("valid_until") + self.assertIn("envelope fails structural validation", validate_envelope(envelope)) + envelope = deepcopy(self.envelope) + envelope["authority_snapshots"][0]["valid_until"] = envelope["dispatches"][0]["started_at"] + self.assert_refused(envelope, "used expired authority evidence") + + def test_authoritative_failure_can_retry_with_fresh_authority(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"].pop(1) + first, retry = envelope["dispatches"] + first.update(status="failed", retry_allowed=True, reason="provider_rejected") + first.pop("reconciliation") + retry["previous_dispatch_id"] = first["dispatch_id"] + envelope["audit_events"] = [event for event in envelope["audit_events"] if event["event_type"] != "dispatch.reconciled"] + next(event for event in envelope["audit_events"] if event["event_type"] == "dispatch.outcome_unknown")["event_type"] = "dispatch.failed" + refresh_audit(envelope) + self.assertEqual(validate_envelope(envelope), []) + + def test_read_only_reconciliation_can_finish_after_approval_expiry(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"].pop() + envelope["authority_snapshots"].pop() + record = envelope["dispatches"][1] + expiry = envelope["request"]["expires_at"] + record.update(status="reconciled_succeeded", retry_allowed=False, started_at=expiry, completed_at=expiry, recorded_at=expiry) + record.pop("authority_snapshot_id") + record["reconciliation"].update(status="succeeded", checked_at=expiry) + envelope["audit_events"] = [event for event in envelope["audit_events"] if event["event_id"] not in {"event_retry_authority_02", "event_retry_02"}] + event = next(e for e in envelope["audit_events"] if e["event_type"] == "dispatch.reconciled") + event.update(occurred_at=expiry, recorded_at=expiry) + envelope["audit_events"][-1].update(occurred_at=expiry, recorded_at=expiry) + envelope["audit_snapshot"]["captured_at"] = envelope["captured_at"] = expiry + refresh_audit(envelope) + self.assertEqual(validate_envelope(envelope), []) + + def test_cancelled_and_failed_requests_need_no_approval(self) -> None: + for status, terminal_reason in (("cancelled", "cancelled_by_requester"), ("failed", "delivery_failed")): + envelope = load("examples/expired-envelope.json") + envelope["request"].update(status=status, terminal_reason=terminal_reason) + event = next(e for e in envelope["audit_events"] if e["event_type"] == "approval.expired") + if status == "cancelled": + event["event_type"] = "approval.cancelled" + else: + event["event_type"] = "dispatch.not_dispatched" + event["references"]["dispatch_id"] = "dispatch_failure_01" + dispatch = deepcopy(self.envelope["dispatches"][0]) + dispatch.update(dispatch_id="dispatch_failure_01", status="not_dispatched", approval_ids=[], reason="request_failed", started_at=event["occurred_at"], completed_at=event["occurred_at"], recorded_at=event["recorded_at"]) + dispatch.pop("authority_snapshot_id") + dispatch.pop("provider") + envelope["dispatches"] = [dispatch] + refresh_audit(envelope) + self.assertEqual(validate_envelope(envelope), []) + + def test_resolution_identity_cannot_fork(self) -> None: + resolution = deepcopy(self.envelope["resolutions"][0]) + resolution["resolution_id"] = "resolution_fork_01" + self.envelope["resolutions"].append(resolution) + self.assert_refused(self.envelope, "more than one terminal resolution") + + def test_refusal_never_invokes_provider(self) -> None: + envelope = load("examples/rejected-envelope.json") + dispatch = envelope["dispatches"][0] + dispatch.update(status="succeeded", provider=deepcopy(self.envelope["dispatches"][0]["provider"]), authority_snapshot_id=self.envelope["authority_snapshots"][0]["authority_snapshot_id"], approval_ids=self.envelope["dispatches"][0]["approval_ids"]) + envelope["authority_snapshots"] = deepcopy(self.envelope["authority_snapshots"]) + self.assert_refused(envelope, "refused request cannot invoke a provider") + + def test_refusal_cannot_allow_retry_or_claim_provider(self) -> None: + for updates in ({"retry_allowed":True}, {"provider":{}}): + envelope = load("examples/rejected-envelope.json") + envelope["dispatches"][0].update(updates) + self.assertIn("envelope fails structural validation", validate_envelope(envelope)) + + def test_expired_request_cannot_terminate_early(self) -> None: + envelope = load("examples/expired-envelope.json") + envelope["request"]["terminal_at"] = envelope["request"]["requested_at"] + self.assert_refused(envelope, "terminated before its expiry") + + def test_retry_lineage_cannot_skip_or_change_identity(self) -> None: + for field, value, reason in (("previous_dispatch_id", "dispatch_missing_01", "linkage is broken"), + ("attempt", 3, "attempt numbers must be contiguous"), + ("max_attempts", 4, "changes max_attempts"), + ("idempotency_key", "idem_different_01", "changes idempotency_key")): + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"][-1][field] = value + with self.subTest(field=field): + self.assert_refused(envelope, reason) + + def test_unknown_outcome_cannot_retry_without_reconciliation(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"].pop(1) + envelope["dispatches"][-1]["previous_dispatch_id"] = envelope["dispatches"][0]["dispatch_id"] + self.assert_refused(envelope, "retry requires authoritative failure") + + def test_reconciliation_requires_unknown_history(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"][0] = deepcopy(self.envelope["dispatches"][0]) + self.assert_refused(envelope, "reconciliation must resolve the preceding unknown") + + def test_reconciliation_preserves_lookup_identity(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"][1]["reconciliation"]["lookup_key_hash"] = "sha256:" + "1" * 64 + self.assert_refused(envelope, "lookup identity") + + def test_success_cannot_be_retried_and_limit_cannot_extend(self) -> None: + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"][1]["status"] = "reconciled_succeeded" + envelope["dispatches"][1]["retry_allowed"] = False + envelope["dispatches"][1]["reconciliation"]["status"] = "succeeded" + self.assert_refused(envelope, "retry requires authoritative failure") + envelope = load("examples/recovered-envelope.json") + envelope["dispatches"][1]["max_attempts"] = 1 + self.assert_refused(envelope, "attempt ceiling cannot allow retry") + + def test_dispatch_ids_are_unique(self) -> None: + self.envelope["dispatches"].append(deepcopy(self.envelope["dispatches"][0])) + self.assert_refused(self.envelope, "reuse dispatch_id") + + def test_audit_unknown_reference_is_rejected_after_rehash(self) -> None: + self.envelope["audit_events"][0]["references"]["dispatch_id"] = "dispatch_unknown_01" + self.assert_refused(self.envelope, "references unknown dispatch_id") + + def test_every_exported_record_belongs_to_its_proposal(self) -> None: + for key in ("validations", "review_snapshots", "approvals", "resolutions", "authority_snapshots", "dispatches"): + envelope = deepcopy(self.envelope) + envelope[key][0]["proposal_id"] = "prop_foreign_01" + with self.subTest(key=key): + self.assert_refused(envelope, "lifecycle record points at another proposal") + + def test_audit_reference_on_wrong_type_is_not_a_witness(self) -> None: + event = next(e for e in self.envelope["audit_events"] if e["event_type"] == "approval.decided") + event["references"].pop("approval_id") + self.assert_refused(self.envelope, "lacks its approval_id witness") + + def test_audit_result_actor_and_decision_must_match(self) -> None: + for kind in ("outcome", "actor", "decision"): + envelope = deepcopy(self.envelope) + if kind == "outcome": + next(e for e in envelope["audit_events"] if e["event_type"] == "dispatch.succeeded")["event_type"] = "dispatch.failed" + reason = "outcome disagrees" + else: + event = next(e for e in envelope["audit_events"] if e["event_type"] == "approval.decided") + if kind == "actor": + event["actor"]["identifier"] = "other@example.com" + reason = "actor disagrees" + else: + event["detail"]["decision"] = "rejected" + reason = "decision disagrees" + with self.subTest(kind=kind): + self.assert_refused(envelope, reason) + + def test_envelope_cannot_capture_future_records(self) -> None: + self.envelope["captured_at"] = self.envelope["request"]["requested_at"] + self.assert_refused(self.envelope, "capture predates a contained record") + + def test_invalid_patch_is_a_refusal_and_does_not_mutate_original(self) -> None: + original = deepcopy(self.envelope["proposal"]) + self.envelope["approvals"][0]["modifications"] = [{"op":"replace", "path":"/payload/to/-1", "value":"other@example.com"}] + self.assert_refused(self.envelope, "invalid JSON Patch") + self.assertEqual(self.envelope["proposal"], original) + + def test_malformed_envelope_fails_closed(self) -> None: + for value in (None, [], {}, {"proposal":{}}, {"schema_version":"3.0.0"}): + self.assertEqual(validate_envelope(value), ["envelope fails structural validation"]) + + +class JsonBoundaryTests(unittest.TestCase): + def test_strict_json_rejects_ambiguity_at_every_depth(self) -> None: + for source in ('{"a":1,"a":2}', '{"nested":{"a":1,"a":2}}', '{"a":1,"\\u0061":2}', '[NaN]', '[Infinity]', '[-Infinity]', '[1e9999]'): + with self.subTest(source=source), self.assertRaises(ValueError): + strict_json_loads(source) + self.assertEqual(strict_json_loads('{"a":1,"b":[true,null,1.5]}'), {"a":1,"b":[True,None,1.5]}) + + def test_patch_rejects_invalid_indices_members_and_escapes(self) -> None: + document = {"payload":{"values":[1,2], "flag":True}} + for operation in ( + {"op":"replace", "path":"/payload/values/-1", "value":3}, + {"op":"add", "path":"/payload/values/4", "value":3}, + {"op":"replace", "path":"/payload/values/01", "value":3}, + {"op":"remove", "path":"/payload/values/2"}, + {"op":"replace", "path":"/payload/missing", "value":3}, + {"op":"replace", "path":"/payload/~2", "value":3}, + {"op":"test", "path":"/payload/flag", "value":1}, + {"op":"test", "path":"/payload", "value":{"values":[1,2],"flag":1}}, + {"op":"add", "path":"/payload/missing/child", "value":3}, + {"op":"remove", "path":"/payload/flag/child"}, + ): + with self.subTest(operation=operation), self.assertRaises(ValueError): + apply_operations(document, [operation]) + self.assertEqual(document, {"payload":{"values":[1,2], "flag":True}}) + + def test_valid_json_patch_profile(self) -> None: + original = {"payload":{"values":[1,2],"a/b":{"~key":1}}} + result = apply_operations(original, [ + {"op":"add", "path":"/payload/values/2", "value":3}, + {"op":"add", "path":"/payload/values/-", "value":4}, + {"op":"replace", "path":"/payload/a~1b/~0key", "value":2}, + {"op":"test", "path":"/payload/values/0", "value":1.0}, + ]) + self.assertEqual(result["payload"]["values"], [1,2,3,4]) + self.assertEqual(result["payload"]["a/b"]["~key"], 2) + self.assertEqual(original["payload"]["values"], [1,2]) + + +if __name__ == "__main__": + unittest.main()