diff --git a/.github/workflows/action-receipt-adapters.yml b/.github/workflows/action-receipt-adapters.yml index 35016689..9c192ecc 100644 --- a/.github/workflows/action-receipt-adapters.yml +++ b/.github/workflows/action-receipt-adapters.yml @@ -9,6 +9,8 @@ on: - "docs/action-receipt-adapters-report.json" - "scripts/build-action-revocation-report.mjs" - "docs/action-receipt-revocation-report.json" + - "scripts/build-action-epoch-report.mjs" + - "docs/action-receipt-policy-epoch-report.json" - "test/action-receipt-adapters.test.js" - ".github/workflows/action-receipt-adapters.yml" pull_request: @@ -18,6 +20,8 @@ on: - "docs/action-receipt-adapters-report.json" - "scripts/build-action-revocation-report.mjs" - "docs/action-receipt-revocation-report.json" + - "scripts/build-action-epoch-report.mjs" + - "docs/action-receipt-policy-epoch-report.json" - "test/action-receipt-adapters.test.js" - ".github/workflows/action-receipt-adapters.yml" @@ -34,4 +38,5 @@ jobs: node-version: "22" - run: node scripts/build-action-receipt-adapters-report.mjs --check - run: node scripts/build-action-revocation-report.mjs --check + - run: node scripts/build-action-epoch-report.mjs --check - run: node --test test/action-receipt-adapters.test.js diff --git a/.github/workflows/action-receipt-registry.yml b/.github/workflows/action-receipt-registry.yml index 22a41a95..ec81d3b8 100644 --- a/.github/workflows/action-receipt-registry.yml +++ b/.github/workflows/action-receipt-registry.yml @@ -17,6 +17,8 @@ on: - "docs/action-receipt-adapters-report.json" - "docs/action-receipt-revocation-report.json" - "scripts/build-action-revocation-report.mjs" + - "docs/action-receipt-policy-epoch-report.json" + - "scripts/build-action-epoch-report.mjs" - ".github/workflows/action-receipt-registry.yml" pull_request: paths: @@ -33,6 +35,8 @@ on: - "docs/action-receipt-adapters-report.json" - "docs/action-receipt-revocation-report.json" - "scripts/build-action-revocation-report.mjs" + - "docs/action-receipt-policy-epoch-report.json" + - "scripts/build-action-epoch-report.mjs" - ".github/workflows/action-receipt-registry.yml" permissions: diff --git a/docs/action-receipt-policy-epoch-report.json b/docs/action-receipt-policy-epoch-report.json new file mode 100644 index 00000000..0c3093dd --- /dev/null +++ b/docs/action-receipt-policy-epoch-report.json @@ -0,0 +1,999 @@ +{ + "protocol": "aml-receipt-policy-epoch-report/1", + "adapters": [ + { + "protocol": "aml-receipt-policy-epoch-evidence/1", + "adapter": "email", + "proposal": { + "protocol": "aml-proposed-action/1", + "tool": "send_email", + "effect": "send", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "purpose": "Policy epoch fixture for email", + "arguments": { + "to": "team@example.test", + "cc": [], + "bcc": [], + "subject": "Update", + "body": "Ready" + } + }, + "original_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "tool": "send_email", + "effect": "send", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "requires_approval": true + } + ] + }, + "intervening_policy": { + "protocol": "aml-action-policy/1", + "rules": [] + }, + "without_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "email-without-epoch", + "proposal": { + "arguments": { + "bcc": [], + "body": "Ready", + "cc": [], + "subject": "Update", + "to": "team@example.test" + }, + "effect": "send", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for email", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + }, + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "send", + "requires_approval": true, + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + } + ] + }, + "policy_sha256": "5de9aec024a11f0dc4a18f3a2e10ad8222754da658f2530614ce5adb185598e9", + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "31b602b10850f838fe66a34a22b2db8169df8bdde177bb7799da7bf11d5c4e19", + "host_mac_sha256": "ccbcfa36ea4b290f4ecaf7fd1911e84ba6efcf88510eed128033f031fa6037ef" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "with_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "email-epoch-bound", + "proposal": { + "arguments": { + "bcc": [], + "body": "Ready", + "cc": [], + "subject": "Update", + "to": "team@example.test" + }, + "effect": "send", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for email", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + }, + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "send", + "requires_approval": true, + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + } + ] + }, + "policy_sha256": "5de9aec024a11f0dc4a18f3a2e10ad8222754da658f2530614ce5adb185598e9", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "213a1d31bdc5c1e33f179878698a263c35effbfe52a967ff05fed4eacba9c4a3", + "host_mac_sha256": "6ce99b9267a6bbb1181476d577b0600182305c3fabf76c6bd3ae71d1b0741764" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "callback_count": 0 + }, + "replay": { + "decision": "SUPPRESS", + "reason": "receipt_reused_or_unknown", + "callback_count": 0 + }, + "callback_count": 0 + }, + "change_during_approval": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "email-approval-aba", + "proposal": { + "arguments": { + "bcc": [], + "body": "Ready", + "cc": [], + "subject": "Update", + "to": "team@example.test" + }, + "effect": "send", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for email", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + }, + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "send", + "requires_approval": true, + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + } + ] + }, + "policy_sha256": "5de9aec024a11f0dc4a18f3a2e10ad8222754da658f2530614ce5adb185598e9", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "receipt_sha256": "dac5623012083506647b2c7c4bc5d6338b43eeab9ebce511776cc640285a9e80", + "host_mac_sha256": "0766dcc6ec3b4da4752f974a5aaea4994b53d0c6f978f570060babfe9f167cb5" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "receipt_suppressed", + "callback_count": 0 + }, + "callback_count": 0 + }, + "unchanged_epoch_control": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "email-unchanged-control", + "proposal": { + "arguments": { + "bcc": [], + "body": "Ready", + "cc": [], + "subject": "Update", + "to": "team@example.test" + }, + "effect": "send", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for email", + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + }, + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "send", + "requires_approval": true, + "resource": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "tool": "send_email" + } + ] + }, + "policy_sha256": "5de9aec024a11f0dc4a18f3a2e10ad8222754da658f2530614ce5adb185598e9", + "policy_epoch": 9, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "f4d62e8f2ca887b78b2803bc6e7a24a7a84bd01ff5baa2478ddfbc8f8de4f88c", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "mail:60a00ad5fc33ae881d59474cb6f6c637b41406f0334ab846679b816f9f6f74cb", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "8763a496117bd2ce16b6e6650b0bd1e8fdfe909c685eed71ecaa89c68f081975", + "host_mac_sha256": "4198db5a83dbbdb66b9b58ded7e37c81f28f359b125e94320dd75e0c060dfab2" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "claim_boundary": "Project-authored local callbacks and public fixture key; the host epoch is simulated, not a durable provider.", + "receipt_sha256": "2c4204ad0ed3493d531eec624066f8507b6623b87dc1f2577a8282af6032a586" + }, + { + "protocol": "aml-receipt-policy-epoch-evidence/1", + "adapter": "http", + "proposal": { + "protocol": "aml-proposed-action/1", + "tool": "http_request", + "effect": "write", + "resource": "https://api.example.test/v1/status", + "purpose": "Policy epoch fixture for http", + "arguments": { + "url": "https://api.example.test/v1/status", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"ready\":true}" + } + }, + "original_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "tool": "http_request", + "effect": "write", + "resource": "https://api.example.test/v1/status", + "requires_approval": true + } + ] + }, + "intervening_policy": { + "protocol": "aml-action-policy/1", + "rules": [] + }, + "without_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "http-without-epoch", + "proposal": { + "arguments": { + "body": "{\"ready\":true}", + "headers": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "https://api.example.test/v1/status" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for http", + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + }, + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + } + ] + }, + "policy_sha256": "b33c4130ca3836294360269f6bbb066815fdbce5d2cb2b390a36119840ae2cf2", + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "https://api.example.test/v1/status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "56436eecdb100c700d5ccac664ff2905da3bf9b56552cb4707fcf53dd7d73f8e", + "host_mac_sha256": "fd0c630d24b0e29122dd9ef2dc34e0b16b2b1d7dbeb3172d8072beccd28dc9df" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "with_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "http-epoch-bound", + "proposal": { + "arguments": { + "body": "{\"ready\":true}", + "headers": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "https://api.example.test/v1/status" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for http", + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + }, + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + } + ] + }, + "policy_sha256": "b33c4130ca3836294360269f6bbb066815fdbce5d2cb2b390a36119840ae2cf2", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "https://api.example.test/v1/status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "1dd83911e7e99badb416f9aa45d6e9d39c5696b95af11fa123fb68c9a9b708db", + "host_mac_sha256": "c93c05efcaa8508f0a011da97717c1e0909e7398fdf189511238f612c4d5272b" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "callback_count": 0 + }, + "replay": { + "decision": "SUPPRESS", + "reason": "receipt_reused_or_unknown", + "callback_count": 0 + }, + "callback_count": 0 + }, + "change_during_approval": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "http-approval-aba", + "proposal": { + "arguments": { + "body": "{\"ready\":true}", + "headers": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "https://api.example.test/v1/status" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for http", + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + }, + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + } + ] + }, + "policy_sha256": "b33c4130ca3836294360269f6bbb066815fdbce5d2cb2b390a36119840ae2cf2", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "https://api.example.test/v1/status", + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "receipt_sha256": "f691a6c1bf656e57a06a0f66c179a7289360e08f5bf7eb3e62ad33dd77001cf1", + "host_mac_sha256": "0dc6338760eae38f61594db3ed13d86081c841fc254432fb1befb97c6dac26bd" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "receipt_suppressed", + "callback_count": 0 + }, + "callback_count": 0 + }, + "unchanged_epoch_control": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "http-unchanged-control", + "proposal": { + "arguments": { + "body": "{\"ready\":true}", + "headers": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "https://api.example.test/v1/status" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for http", + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + }, + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "https://api.example.test/v1/status", + "tool": "http_request" + } + ] + }, + "policy_sha256": "b33c4130ca3836294360269f6bbb066815fdbce5d2cb2b390a36119840ae2cf2", + "policy_epoch": 9, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "7e62423c4b27426e209e639122918eac96504edd2382c47a9400209b8b02a1e7", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "https://api.example.test/v1/status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "d1d40eb673129080094727df4a83e69840be56c6cc7acf99c02ca676fd6ebd72", + "host_mac_sha256": "db7ecd4dc18790905398648f26dfb82e54597594628de44d912b59dba11018b0" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "claim_boundary": "Project-authored local callbacks and public fixture key; the host epoch is simulated, not a durable provider.", + "receipt_sha256": "12913052ac0685e8e51601b62995a9ea4dd301fcdd918332d6c45c72a5cd71bf" + }, + { + "protocol": "aml-receipt-policy-epoch-evidence/1", + "adapter": "database", + "proposal": { + "protocol": "aml-proposed-action/1", + "tool": "database_write", + "effect": "write", + "resource": "db:records/r42", + "purpose": "Policy epoch fixture for database", + "arguments": { + "table": "records", + "key": "r42", + "value": { + "status": "ready" + } + } + }, + "original_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "tool": "database_write", + "effect": "write", + "resource": "db:records/r42", + "requires_approval": true + } + ] + }, + "intervening_policy": { + "protocol": "aml-action-policy/1", + "rules": [] + }, + "without_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "database-without-epoch", + "proposal": { + "arguments": { + "key": "r42", + "table": "records", + "value": { + "status": "ready" + } + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for database", + "resource": "db:records/r42", + "tool": "database_write" + }, + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "db:records/r42", + "tool": "database_write" + } + ] + }, + "policy_sha256": "1c700e497bd6c20512d37619e3e8940cc997b8fd236ccb797fef326e45095ea6", + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "db:records/r42", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "20c7bef41cc8416948f9da445788e4a98974707f25c6e6ffd92a1de1a4acf9af", + "host_mac_sha256": "bc7c119abbd8e89438fab627d2e7736594e4bfd1ee2954ed828bd74df0e9c265" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "with_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "database-epoch-bound", + "proposal": { + "arguments": { + "key": "r42", + "table": "records", + "value": { + "status": "ready" + } + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for database", + "resource": "db:records/r42", + "tool": "database_write" + }, + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "db:records/r42", + "tool": "database_write" + } + ] + }, + "policy_sha256": "1c700e497bd6c20512d37619e3e8940cc997b8fd236ccb797fef326e45095ea6", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "db:records/r42", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "e8bae28726cf5fa881a0b3029d99dc7d38ccc578909f9c250f62533f7216f746", + "host_mac_sha256": "7c3021837f15b6cb4d249a978e90c9f8b40e841acb17189d7bc28c094e884a8a" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "callback_count": 0 + }, + "replay": { + "decision": "SUPPRESS", + "reason": "receipt_reused_or_unknown", + "callback_count": 0 + }, + "callback_count": 0 + }, + "change_during_approval": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "database-approval-aba", + "proposal": { + "arguments": { + "key": "r42", + "table": "records", + "value": { + "status": "ready" + } + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for database", + "resource": "db:records/r42", + "tool": "database_write" + }, + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "db:records/r42", + "tool": "database_write" + } + ] + }, + "policy_sha256": "1c700e497bd6c20512d37619e3e8940cc997b8fd236ccb797fef326e45095ea6", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "db:records/r42", + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "receipt_sha256": "f44921f72aeaeab5e5e059603b3f18c9abc3322aa91aa077d559500041051f5c", + "host_mac_sha256": "cc4220cf27933997fbaca3fca79bfe7858ad572983b84367a29de266ba47ffdc" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "receipt_suppressed", + "callback_count": 0 + }, + "callback_count": 0 + }, + "unchanged_epoch_control": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "database-unchanged-control", + "proposal": { + "arguments": { + "key": "r42", + "table": "records", + "value": { + "status": "ready" + } + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for database", + "resource": "db:records/r42", + "tool": "database_write" + }, + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "db:records/r42", + "tool": "database_write" + } + ] + }, + "policy_sha256": "1c700e497bd6c20512d37619e3e8940cc997b8fd236ccb797fef326e45095ea6", + "policy_epoch": 9, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "64621bf5bbc7d8c10e1c4a22c58fb38a59fa4a51f9bff8970e4c78361054c927", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "db:records/r42", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "6d0e6d135003816f0cb4acb1c16da0e5983338b6813f40deae2ce15b4197aaf1", + "host_mac_sha256": "accb5559c777208aca1fc841812284d180abdb0be12d2c525a8fd5390734303f" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "claim_boundary": "Project-authored local callbacks and public fixture key; the host epoch is simulated, not a durable provider.", + "receipt_sha256": "727f6940102f87315ac0110a6dbd5c8fb949efb7786b2aae4ce1dfdf9764a27a" + }, + { + "protocol": "aml-receipt-policy-epoch-evidence/1", + "adapter": "ui", + "proposal": { + "protocol": "aml-proposed-action/1", + "tool": "render_ui", + "effect": "write", + "resource": "ui:#status", + "purpose": "Policy epoch fixture for ui", + "arguments": { + "root": "#status", + "text": "Ready" + } + }, + "original_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "tool": "render_ui", + "effect": "write", + "resource": "ui:#status", + "requires_approval": true + } + ] + }, + "intervening_policy": { + "protocol": "aml-action-policy/1", + "rules": [] + }, + "without_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "ui-without-epoch", + "proposal": { + "arguments": { + "root": "#status", + "text": "Ready" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for ui", + "resource": "ui:#status", + "tool": "render_ui" + }, + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "ui:#status", + "tool": "render_ui" + } + ] + }, + "policy_sha256": "a317e16fdca0d1aea78ab6d42dc5fa2e7daa4e4e5bdfa83fd2d4ce960faf0488", + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "ui:#status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "b38d655d3d712fb0799bff55755f955e1a582945a3c7f6119550743cdceeb891", + "host_mac_sha256": "2e14b334fcc6758d8a76d2cc285359613cd44c639d4f06f341e01e45483d3a06" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "with_epoch": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "ui-epoch-bound", + "proposal": { + "arguments": { + "root": "#status", + "text": "Ready" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for ui", + "resource": "ui:#status", + "tool": "render_ui" + }, + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "ui:#status", + "tool": "render_ui" + } + ] + }, + "policy_sha256": "a317e16fdca0d1aea78ab6d42dc5fa2e7daa4e4e5bdfa83fd2d4ce960faf0488", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "ui:#status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "811d01df21d69324b8f9b8c2b8bcb7f52694fed355ef595a92d97d1d9592b3f5", + "host_mac_sha256": "0df08a956e62416515bdb8fad8f6c4a39a588f7f1b72e9f5ce24e8ffb85fddaf" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "callback_count": 0 + }, + "replay": { + "decision": "SUPPRESS", + "reason": "receipt_reused_or_unknown", + "callback_count": 0 + }, + "callback_count": 0 + }, + "change_during_approval": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "ui-approval-aba", + "proposal": { + "arguments": { + "root": "#status", + "text": "Ready" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for ui", + "resource": "ui:#status", + "tool": "render_ui" + }, + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "ui:#status", + "tool": "render_ui" + } + ] + }, + "policy_sha256": "a317e16fdca0d1aea78ab6d42dc5fa2e7daa4e4e5bdfa83fd2d4ce960faf0488", + "policy_epoch": 1, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "ui:#status", + "decision": "SUPPRESS", + "reason": "policy_epoch_changed", + "receipt_sha256": "40732a82a689ebc28b456f12f8bbf33ce7ee3485a0c612715d7a00342d4d037f", + "host_mac_sha256": "c99eb102784d1f676ea4eb3ab6ddb0f0cb846e0e2862918c10b694fa9af6fe9c" + }, + "outcome": { + "decision": "SUPPRESS", + "reason": "receipt_suppressed", + "callback_count": 0 + }, + "callback_count": 0 + }, + "unchanged_epoch_control": { + "authorization_receipt": { + "protocol": "aml-host-action-authorization/1", + "nonce": "ui-unchanged-control", + "proposal": { + "arguments": { + "root": "#status", + "text": "Ready" + }, + "effect": "write", + "protocol": "aml-proposed-action/1", + "purpose": "Policy epoch fixture for ui", + "resource": "ui:#status", + "tool": "render_ui" + }, + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "host_policy": { + "protocol": "aml-action-policy/1", + "rules": [ + { + "effect": "write", + "requires_approval": true, + "resource": "ui:#status", + "tool": "render_ui" + } + ] + }, + "policy_sha256": "a317e16fdca0d1aea78ab6d42dc5fa2e7daa4e4e5bdfa83fd2d4ce960faf0488", + "policy_epoch": 9, + "plan": { + "protocol": "aml-action-plan/1", + "proposal_sha256": "8b80c62aaf56e3581699bb5eaaf21865b9a29d0321a76ca444f5f175b0e8fa12", + "decision": "requires_approval", + "reason": "exact_rule" + }, + "resolved_target": "ui:#status", + "decision": "ALLOW", + "reason": "approved_exact_rule", + "receipt_sha256": "95a4667befc829efa65e340da6bd13bb535f5254480316e1b9b5f92c8fa9ea67", + "host_mac_sha256": "9d8b8578319e7ea0c204a2040556e3b049bdd57c4dfff0c06d147f201fde2015" + }, + "outcome": { + "decision": "ALLOW", + "status": "DISPATCHED", + "callback_count": 1, + "result": { + "accepted": true + } + }, + "callback_count": 1 + }, + "claim_boundary": "Project-authored local callbacks and public fixture key; the host epoch is simulated, not a durable provider.", + "receipt_sha256": "07fd548f15fdb7bdc41e3a6085fa594f4c41ecb83e7e49f2328ce77fd1c7952c" + } + ], + "totals": { + "adapter_count": 4, + "comparison_callbacks": 4, + "epoch_bound_attack_callbacks": 0, + "approval_crossing_callbacks": 0, + "unchanged_control_callbacks": 4 + }, + "claim_boundary": "An unobserved policy A→B→A is detected only if the host supplies a reliable monotonic epoch. No external effects or independent validation." +} diff --git a/pilots/action-boundary/receipt-adapters.mjs b/pilots/action-boundary/receipt-adapters.mjs index c1c1588b..7695039a 100644 --- a/pilots/action-boundary/receipt-adapters.mjs +++ b/pilots/action-boundary/receipt-adapters.mjs @@ -49,15 +49,21 @@ export function uiResource(args) { return `ui:${args.root}`; } -export function createReceiptBoundAdapter({ tool, effect, policy, secret, resolveTarget, +export function createReceiptBoundAdapter({ tool, effect, policy, policyEpoch, secret, resolveTarget, authorize, execute, nonce = () => crypto.randomBytes(16).toString('hex') } = {}) { if (!nonempty(tool) || !nonempty(effect) || typeof policy !== 'function' || + (policyEpoch !== undefined && typeof policyEpoch !== 'function') || !Buffer.isBuffer(secret) || secret.length < 32 || typeof resolveTarget !== 'function' || typeof execute !== 'function' || typeof nonce !== 'function') throw TypeError('host adapter configuration required'); const issued = new Map(); const seenNonces = new Set(); const mac = digest => crypto.createHmac('sha256', secret).update(digest).digest('hex'); + const readEpoch = () => { + const value = policyEpoch(); + if (!Number.isSafeInteger(value) || value < 0) throw TypeError('invalid_policy_epoch'); + return value; + }; const authenticate = receipt => { try { if (!receipt || typeof receipt !== 'object' || !/^[0-9a-f]{64}$/.test(receipt.receipt_sha256) || @@ -70,6 +76,7 @@ export function createReceiptBoundAdapter({ tool, effect, policy, secret, resolv }; async function issue(proposal) { const frozen = copy(proposal); + const initialEpoch = policyEpoch === undefined ? undefined : readEpoch(); const hostPolicy = copy(policy()); const policySha = hash(hostPolicy); const plan = planAction(frozen, hostPolicy); @@ -105,13 +112,22 @@ export function createReceiptBoundAdapter({ tool, effect, policy, secret, resolv decision = 'SUPPRESS'; reason = 'policy_changed'; } } + if (decision === 'ALLOW' && policyEpoch !== undefined) { + let currentEpoch; + try { currentEpoch = readEpoch(); } catch { /* fail closed */ } + if (currentEpoch !== initialEpoch) { + decision = 'SUPPRESS'; reason = 'policy_epoch_changed'; + } + } } const token = nonce(); assert.ok(nonempty(token) && !seenNonces.has(token), 'nonce must be unique'); seenNonces.add(token); const body = { protocol: 'aml-host-action-authorization/1', nonce: token, proposal: frozen, proposal_sha256: proposalSha, host_policy: hostPolicy, - policy_sha256: policySha, plan, resolved_target: target, decision, reason }; + policy_sha256: policySha, + ...(policyEpoch === undefined ? {} : { policy_epoch: initialEpoch }), + plan, resolved_target: target, decision, reason }; const receiptSha = hash(body); const receipt = { ...body, receipt_sha256: receiptSha, host_mac_sha256: mac(receiptSha) }; if (decision === 'ALLOW') issued.set(token, { receiptSha, proposal: copy(frozen) }); @@ -142,6 +158,15 @@ export function createReceiptBoundAdapter({ tool, effect, policy, secret, resolv issued.delete(receipt.nonce); return blocked('target_mismatch'); } + if (policyEpoch !== undefined) { + let currentEpoch; + try { currentEpoch = readEpoch(); } + catch { issued.delete(receipt.nonce); return blocked('policy_epoch_unavailable'); } + if (currentEpoch !== receipt.policy_epoch) { + issued.delete(receipt.nonce); + return blocked('policy_epoch_changed'); + } + } // Consume before calling the provider: a throw can follow a partial effect. issued.delete(receipt.nonce); try { return { decision: 'ALLOW', status: 'DISPATCHED', callback_count: 1, diff --git a/publications/ACTION_BOUNDARY_PROGRESS.md b/publications/ACTION_BOUNDARY_PROGRESS.md index 5f45e9b9..2cc55e42 100644 --- a/publications/ACTION_BOUNDARY_PROGRESS.md +++ b/publications/ACTION_BOUNDARY_PROGRESS.md @@ -15,5 +15,6 @@ Counts below separate project-authored tests from live-site evidence and indepen | 2026-10-20 | [Receipt revocation cohort](ACTION_RECEIPT_REVOCATION.md) | 475 total beyond the original 13 | 504 indexed; four new local revocation records | 10 controlled pages | 16 blocked revocation paths across four adapters; zero callbacks | 0 verified by this artifact | | 2026-10-20 | [Pinned 500-receipt release](https://github.com/aruintelligence/aml-core/releases/tag/action-boundary-evidence-500-2026-10-20) and [Zenodo archive](https://doi.org/10.5281/zenodo.23490672) | 475 total beyond the original 13 | 500 in the released source archive | 10 controlled pages on one project website | Five project-maintained bindings | 0 verified by this artifact | | 2026-10-20 | [Corrected Zenodo version](https://doi.org/10.5281/zenodo.23491045) | 475 total beyond the original 13 | 504 in the archived Git source; both simulated provenance labels accepted by the receipt schema | 10 controlled pages on one project website | Five project-maintained bindings | 0 verified by this artifact | +| 2026-10-20 | [Policy-epoch comparison](ACTION_RECEIPT_POLICY_EPOCH.md) | 475 total beyond the original 13 | 508 indexed; four new local epoch records | 10 controlled pages | Unobserved A→B→A: 4 callbacks without epoch, 0 with epoch; 4 unchanged-epoch controls dispatch | 0 verified by this artifact | -Current stage: 475 new challenge cases, five maintained binding matches for both cohorts, ten controlled live page pilots, an append-only registry with 504 receipts, four minimal adapters, and a one-command reproduction package. The 504 receipt hashes are project-generated evidence, including 475 simulated challenge receipts and four local revocation records. The [500-receipt GitHub release](https://github.com/aruintelligence/aml-core/releases/tag/action-boundary-evidence-500-2026-10-20) pins commit `9477f7b56d2cd721a8062cd51260ca6c33fe7f10`; its [first Zenodo version](https://doi.org/10.5281/zenodo.23490672) has ZIP SHA-256 `ed7440fd18c36064f858fb8be19d1d72cb1384c91d8c9066710467e25d572891`. The [corrected 504-receipt Zenodo version](https://doi.org/10.5281/zenodo.23491045) archives commit `50095a6dec67498c2e867c902afc293a11b77343` with ZIP SHA-256 `5b6c3f27d15fda5f49b4ac8e3b57a786c83d4f8978892d7aed56a5e2fb7d3c34`. The [challenge report](../docs/action-boundary-expanded-report.json), [runtime matrices](../docs/action-stress-five-runtime-report.json), [pilot report](ACTION_BOUNDARY_CONTROLLED_PILOTS.md), [adapter report](ACTION_RECEIPT_ADAPTERS.md), [revocation report](ACTION_RECEIPT_REVOCATION.md), [registry](../registry/README.md), and [reproduction manifest](../reproduction/action-boundary/manifest.json) contain exact source and output file hashes; consult pinned Git commits for provenance. No independent third-party reproduction has been verified here. +Current stage: 475 new challenge cases, five maintained binding matches for both cohorts, ten controlled live page pilots, an append-only registry with 508 receipts, four minimal adapters, and a one-command reproduction package. The 508 receipt hashes are project-generated evidence, including 475 simulated challenge receipts, four local revocation records, and four policy-epoch comparisons. The [500-receipt GitHub release](https://github.com/aruintelligence/aml-core/releases/tag/action-boundary-evidence-500-2026-10-20) pins commit `9477f7b56d2cd721a8062cd51260ca6c33fe7f10`; its [first Zenodo version](https://doi.org/10.5281/zenodo.23490672) has ZIP SHA-256 `ed7440fd18c36064f858fb8be19d1d72cb1384c91d8c9066710467e25d572891`. The [corrected 504-receipt Zenodo version](https://doi.org/10.5281/zenodo.23491045) archives commit `50095a6dec67498c2e867c902afc293a11b77343` with ZIP SHA-256 `5b6c3f27d15fda5f49b4ac8e3b57a786c83d4f8978892d7aed56a5e2fb7d3c34`. Those archives predate the four epoch records. The [challenge report](../docs/action-boundary-expanded-report.json), [runtime matrices](../docs/action-stress-five-runtime-report.json), [pilot report](ACTION_BOUNDARY_CONTROLLED_PILOTS.md), [adapter report](ACTION_RECEIPT_ADAPTERS.md), [revocation report](ACTION_RECEIPT_REVOCATION.md), [epoch comparison](ACTION_RECEIPT_POLICY_EPOCH.md), [registry](../registry/README.md), and [reproduction manifest](../reproduction/action-boundary/manifest.json) contain exact source and output file hashes; consult pinned Git commits for provenance. No independent third-party reproduction has been verified here. diff --git a/publications/ACTION_RECEIPT_POLICY_EPOCH.md b/publications/ACTION_RECEIPT_POLICY_EPOCH.md new file mode 100644 index 00000000..e6948413 --- /dev/null +++ b/publications/ACTION_RECEIPT_POLICY_EPOCH.md @@ -0,0 +1,45 @@ +# Detect a policy change that returns to identical bytes + +An exact policy digest cannot reveal an unobserved sequence in which a rule is +revoked and restored before the next adapter call. In the local comparison, +the policy reads A at issuance and A at dispatch, so the original one-use +receipt invokes its callback once. This does not contradict the earlier +observed-revocation fix: no call was made while policy B was active. + +The receipt-bound adapters now accept an optional host-owned `policyEpoch()` +function returning a nonnegative safe integer. The host increments that value +for **every** policy or destination-mapping change and persists it across +workers and restarts. The adapter includes the initial value in the signed +authorization receipt and checks it after approval and immediately before +dispatch. A changed or unavailable epoch suppresses the call and spends the +grant. Invalid initial values fail issuance. No-epoch integrations retain +their previous receipt bytes and behavior. + +```js +const adapter = createEmailAdapter({ + provider: hostMailer, + policy: () => hostPolicyStore.readPolicy(), + policyEpoch: () => hostPolicyStore.readMonotonicEpoch(), + secret: hostPrivateKey, + authorize: hostApproval +}); +``` + +The [four-adapter comparison report](../docs/action-receipt-policy-epoch-report.json) +includes exact proposals, policies, complete receipts and hashes. With no +epoch, a local A→B→A sequence yields four callbacks. With the epoch, the same +sequence yields zero callbacks; four approval-time sequences also suppress +issuance. Four unchanged-epoch controls dispatch normally. The four new +receipts append after all 504 prior rows in the +[registry](../registry/README.md). + +```sh +node scripts/build-action-epoch-report.mjs --check +node --test test/action-receipt-adapters.test.js +node scripts/verify-action-receipt-registry.mjs --check +``` + +The epoch source in this report is an in-memory counter and the callbacks are +project-authored fakes. Production assurance depends on a durable, monotonic +epoch that advances with each relevant host change and cannot roll back. The +adapter cannot prove the host's epoch implementation or the external effect. diff --git a/publications/ACTION_RECEIPT_REVOCATION.md b/publications/ACTION_RECEIPT_REVOCATION.md index 8a708dde..7b9fe1fe 100644 --- a/publications/ACTION_RECEIPT_REVOCATION.md +++ b/publications/ACTION_RECEIPT_REVOCATION.md @@ -34,3 +34,5 @@ same bytes **without the adapter observing the change**, a digest check cannot detect that history. Production revocation needs a host-owned monotonic policy epoch or an explicit durable grant invalidation mechanism across processes. No external email, HTTP request, database write, or UI render is claimed. +The [policy-epoch follow-up](ACTION_RECEIPT_POLICY_EPOCH.md) adds a host-supplied +epoch and an A→B→A comparison without changing this original report. diff --git a/registry/README.md b/registry/README.md index 2896e525..669ec581 100644 --- a/registry/README.md +++ b/registry/README.md @@ -6,8 +6,9 @@ observed Chrome matrix receipt, ten controlled-site host dispatch receipts, and ten paired browser pilot receipts. Four local integration adapter receipts were then appended. A further 375 project-authored simulated stress receipts follow those 125 unchanged rows. Four local adapter revocation records were -then appended, preserving all 500 prior rows. These are **504 distinct -machine-verifiable receipts**, with their categories visible in every row. +then appended, preserving all 500 prior rows. Four local policy-epoch +comparison records follow, preserving all 504 earlier rows. These are **508 +distinct machine-verifiable receipts**, with their categories visible in every row. The new cases are test fixtures, not observed external effects. Each row follows the [entry schema](../protocol/action-receipt-registry-entry.schema.json) @@ -26,7 +27,7 @@ node scripts/verify-action-receipt-registry.mjs --check This recomputes the chain and every underlying receipt hash, replays the 100 expanded cases and 375 stress cases in the maintained bindings, and checks the browser observation, ten paired pilot evidence packages, four adapters, and -the four new revocation records. +the four revocation records, and the four policy-epoch comparisons. CI also compares the registry to the first parent commit, so a pull request cannot silently edit or remove earlier rows. To append a new source, add it to the end of the sources manifest and run diff --git a/registry/action-receipt-sources.json b/registry/action-receipt-sources.json index abc92bd7..9e5614a9 100644 --- a/registry/action-receipt-sources.json +++ b/registry/action-receipt-sources.json @@ -7,7 +7,8 @@ { "kind": "controlled_site_browser", "path": "docs/action-pilots/evidence/browser-receipts.jsonl", "count": 10 }, { "kind": "integration_adapter", "path": "docs/action-receipt-adapters-report.json", "collection": "adapters", "count": 4 }, { "kind": "simulated_stress", "path": "conformance/action-stress-375-receipts.jsonl", "count": 375 }, - { "kind": "integration_revocation", "path": "docs/action-receipt-revocation-report.json", "collection": "adapters", "count": 4 } + { "kind": "integration_revocation", "path": "docs/action-receipt-revocation-report.json", "collection": "adapters", "count": 4 }, + { "kind": "integration_policy_epoch", "path": "docs/action-receipt-policy-epoch-report.json", "collection": "adapters", "count": 4 } ], "claim_boundary": "A project-maintained hash-chain index over distinct receipts. The underlying verifiers check the proposal, host policy, outcomes and browser evidence. A Git commit and CI history expose later edits; a bare SHA-256 cannot authenticate the actor or prove an external side effect." } diff --git a/registry/action-receipts.jsonl b/registry/action-receipts.jsonl index 63db4ea9..54d9cf96 100644 --- a/registry/action-receipts.jsonl +++ b/registry/action-receipts.jsonl @@ -502,3 +502,7 @@ {"protocol":"aml-action-receipt-registry-entry/1","sequence":502,"id":"integration_revocation:http","kind":"integration_revocation","source_path":"docs/action-receipt-revocation-report.json","source_record":2,"source_file_sha256":"f3fa867546d596c1768868fa1e1d22c079f67c09821e59155c916cec921a7421","receipt_sha256":"15f41bfddd8d85c5ee8e5cb133154a38ebdc5178f5f43d9bd2bd009841b7b774","previous_entry_sha256":"3c3c09fbe68d2c2c84bf1f7a21af1a9a01297c74bfef77d068e18e3ac898b1c5","entry_sha256":"942f1a3069fda0618a7430bc631df262ec400652c038db8e81292ecd7afa0cbe"} {"protocol":"aml-action-receipt-registry-entry/1","sequence":503,"id":"integration_revocation:database","kind":"integration_revocation","source_path":"docs/action-receipt-revocation-report.json","source_record":3,"source_file_sha256":"f3fa867546d596c1768868fa1e1d22c079f67c09821e59155c916cec921a7421","receipt_sha256":"e676cded5b441beb5f7cbb9bce8476ecd07126dfad373d236c13b1dc58efb6ae","previous_entry_sha256":"942f1a3069fda0618a7430bc631df262ec400652c038db8e81292ecd7afa0cbe","entry_sha256":"84958bc6eacfbc52d8eaa8bbc6d4ba057ddb97bedac2fc84423d62930cf7a495"} {"protocol":"aml-action-receipt-registry-entry/1","sequence":504,"id":"integration_revocation:ui","kind":"integration_revocation","source_path":"docs/action-receipt-revocation-report.json","source_record":4,"source_file_sha256":"f3fa867546d596c1768868fa1e1d22c079f67c09821e59155c916cec921a7421","receipt_sha256":"d4749755ae6d0b20c33301f7a78f2b85f00b20babe62e82949c242884845fe5f","previous_entry_sha256":"84958bc6eacfbc52d8eaa8bbc6d4ba057ddb97bedac2fc84423d62930cf7a495","entry_sha256":"46e99b17062ffb16181a190ac1872e7c7353be16a977db85c3f009fa6dffbc11"} +{"protocol":"aml-action-receipt-registry-entry/1","sequence":505,"id":"integration_policy_epoch:email","kind":"integration_policy_epoch","source_path":"docs/action-receipt-policy-epoch-report.json","source_record":1,"source_file_sha256":"06d35b2d61e868c1e95d90c73dae12ef31a89fb67de9a19af1bb23039a1b3509","receipt_sha256":"2c4204ad0ed3493d531eec624066f8507b6623b87dc1f2577a8282af6032a586","previous_entry_sha256":"46e99b17062ffb16181a190ac1872e7c7353be16a977db85c3f009fa6dffbc11","entry_sha256":"3882e5462d5f2dfd2df56d14c8337a641bcecaf76c61a9796e1c3e1f8c30106f"} +{"protocol":"aml-action-receipt-registry-entry/1","sequence":506,"id":"integration_policy_epoch:http","kind":"integration_policy_epoch","source_path":"docs/action-receipt-policy-epoch-report.json","source_record":2,"source_file_sha256":"06d35b2d61e868c1e95d90c73dae12ef31a89fb67de9a19af1bb23039a1b3509","receipt_sha256":"12913052ac0685e8e51601b62995a9ea4dd301fcdd918332d6c45c72a5cd71bf","previous_entry_sha256":"3882e5462d5f2dfd2df56d14c8337a641bcecaf76c61a9796e1c3e1f8c30106f","entry_sha256":"c88a9567bd2f6bd3a25882dac3de780492539073fda7d89fd9a80dfd4ed62422"} +{"protocol":"aml-action-receipt-registry-entry/1","sequence":507,"id":"integration_policy_epoch:database","kind":"integration_policy_epoch","source_path":"docs/action-receipt-policy-epoch-report.json","source_record":3,"source_file_sha256":"06d35b2d61e868c1e95d90c73dae12ef31a89fb67de9a19af1bb23039a1b3509","receipt_sha256":"727f6940102f87315ac0110a6dbd5c8fb949efb7786b2aae4ce1dfdf9764a27a","previous_entry_sha256":"c88a9567bd2f6bd3a25882dac3de780492539073fda7d89fd9a80dfd4ed62422","entry_sha256":"972a75a51b7e5891d8f4b9d33b3337a8e0994fbfba6ddffb0b1ef9eeb39e8a56"} +{"protocol":"aml-action-receipt-registry-entry/1","sequence":508,"id":"integration_policy_epoch:ui","kind":"integration_policy_epoch","source_path":"docs/action-receipt-policy-epoch-report.json","source_record":4,"source_file_sha256":"06d35b2d61e868c1e95d90c73dae12ef31a89fb67de9a19af1bb23039a1b3509","receipt_sha256":"07fd548f15fdb7bdc41e3a6085fa594f4c41ecb83e7e49f2328ce77fd1c7952c","previous_entry_sha256":"972a75a51b7e5891d8f4b9d33b3337a8e0994fbfba6ddffb0b1ef9eeb39e8a56","entry_sha256":"fd667ade73e93df4357f0e485ca660fea3a8f6bc34b3ba2501e3cc7cc379d5eb"} diff --git a/reproduction/action-boundary/REPRODUCE.md b/reproduction/action-boundary/REPRODUCE.md index 8beb1dc2..208d00bd 100644 --- a/reproduction/action-boundary/REPRODUCE.md +++ b/reproduction/action-boundary/REPRODUCE.md @@ -13,15 +13,18 @@ bash reproduction/action-boundary/reproduce.sh The command verifies 475 exact project-authored challenge cases and receipts, including 375 stress cases, in the maintained bindings; the actual Chrome observation; the ten paired controlled-site sources, DOM snapshots and screenshots; the -four adapters and 28 original blocked variants plus 16 revocation paths; and the append-only registry. It +four adapters and 28 original blocked variants plus 16 observed revocation +paths and four policy-epoch comparisons; and the append-only registry. It recomputes the [file manifest](manifest.json) and prints the receipt count, registry chain root, and SHA-256 of the registry file. The full [specification](SPEC.md), raw artifacts and runnable implementations are in this repository at the same commit. -Expected at this checkpoint: **504 indexed receipts** with the first 500 rows +Expected at this checkpoint: **508 indexed receipts** with the first 504 rows unchanged, **100/100 expanded and 375/375 stress matches in each of five maintained bindings**, **ten paired browser pilots**, and **four original adapter callbacks with 28 blocked variants**, plus four -revocation records with 16 blocked paths and zero callbacks. These are +revocation records with 16 blocked paths and zero callbacks, plus four +policy-epoch comparisons where an A→B→A change goes from four local callbacks +to zero. These are project-maintained reproductions; no independent party is counted. diff --git a/reproduction/action-boundary/SPEC.md b/reproduction/action-boundary/SPEC.md index cc5e2f3e..34e6fb18 100644 --- a/reproduction/action-boundary/SPEC.md +++ b/reproduction/action-boundary/SPEC.md @@ -59,6 +59,14 @@ grant, so restoring the earlier policy or target cannot revive it. The those transitions for four local provider shapes. An unobserved change that returns to identical policy bytes requires a host epoch or durable revocation record; the digest alone cannot reveal it. +With an optional host-owned `policyEpoch` callback, the authorization receipt +also binds `policy_epoch` as a nonnegative safe integer. The host must advance +it monotonically for policy and target-mapping changes. The adapter checks it +after approval and immediately before dispatch; a mismatch or unavailable +value blocks and spends the grant. The [epoch comparison](../../docs/action-receipt-policy-epoch-report.json) +demonstrates an unobserved A→B→A sequence using in-memory host state. Without +the option, existing receipt bytes remain unchanged. Durability and monotonic +ordering across processes are host obligations, not verified by the adapter. ## Published evidence and limits diff --git a/reproduction/action-boundary/manifest.json b/reproduction/action-boundary/manifest.json index dde639e6..0ba88f36 100644 --- a/reproduction/action-boundary/manifest.json +++ b/reproduction/action-boundary/manifest.json @@ -1,13 +1,13 @@ { "protocol": "aml-action-boundary-release-manifest/1", "source": "aruintelligence/aml-core", - "file_count": 143, + "file_count": 146, "expected": { "challenge_cases": 475, "maintained_bindings": 5, "controlled_pages": 10, "adapter_kinds": 4, - "registry_receipts": 504 + "registry_receipts": 508 }, "files": [ { @@ -480,6 +480,11 @@ "bytes": 17107, "sha256": "1cbae8522048e94f55266e64b9e9d2808bdc011cbad155e74800e1c4385a68e8" }, + { + "path": "docs/action-receipt-policy-epoch-report.json", + "bytes": 37003, + "sha256": "06d35b2d61e868c1e95d90c73dae12ef31a89fb67de9a19af1bb23039a1b3509" + }, { "path": "docs/action-receipt-revocation-report.json", "bytes": 20756, @@ -537,8 +542,8 @@ }, { "path": "pilots/action-boundary/receipt-adapters.mjs", - "bytes": 9507, - "sha256": "e5957c39cc406d64a92d3834a24a56ba39aa33534323dc10d587d229d9af256a" + "bytes": 10564, + "sha256": "082b84dbfda28dd964adb91687ba666b16143180a6ab3a1c8b58d28cc79f5602" }, { "path": "protocol/action-boundary-evidence-receipt.schema.json", @@ -572,8 +577,8 @@ }, { "path": "publications/ACTION_BOUNDARY_PROGRESS.md", - "bytes": 4886, - "sha256": "0c641908c5885376fdc87ade1e29b65818c691ae2d9bc6881b883c6021d0248b" + "bytes": 5324, + "sha256": "16feccf3523172bd132b262a3534ff144ec44b1d5e05806905a482b6b4bc7172" }, { "path": "publications/ACTION_BOUNDARY_STRESS_375.md", @@ -585,46 +590,56 @@ "bytes": 2689, "sha256": "294fad032d467f4ccd39d36379359eef9e6bbb4d8285155486a009e03f40eee2" }, + { + "path": "publications/ACTION_RECEIPT_POLICY_EPOCH.md", + "bytes": 2183, + "sha256": "fb3b52d3b5cb66192b6ba307ea1906a18e9a9d002cf9013807e915aea227ba2e" + }, { "path": "publications/ACTION_RECEIPT_REVOCATION.md", - "bytes": 2063, - "sha256": "e30bbe0731d4d0551db94705a067bd5e588369ffbf510a9e59cd79f7efb9d9e9" + "bytes": 2218, + "sha256": "a30cbb3872e5f4498a1356111d32fb9db48cdedf9c28ad249ef9538f1b6ec2ab" }, { "path": "registry/README.md", - "bytes": 2042, - "sha256": "71d5d5809b78867adefd66d3491052386282c5597495b38af0b7466072dffc66" + "bytes": 2161, + "sha256": "cd2158f92490bb849e1f8be9fedf3f40959e33e1120adedd5a29584189471209" }, { "path": "registry/action-receipt-sources.json", - "bytes": 1203, - "sha256": "140c64a6a5ce310b0a1436f183da706aeb34cbda13063317a1fef311b2cd98d4" + "bytes": 1341, + "sha256": "6ebe37a5f8004ee8941a8b6ffca6c1342661891f840de25b70ed610e0c8783b3" }, { "path": "registry/action-receipts.jsonl", - "bytes": 288290, - "sha256": "2e739a3e846d920cfecc1b78e8897c55042bf00dd917ef6317d59d08d0d08ebc" + "bytes": 290537, + "sha256": "6e3591cde7b8f0a6b7fb4b4a8b8751336262a7babe5684e27f8c2b2f37dde71b" }, { "path": "reproduction/action-boundary/REPRODUCE.md", - "bytes": 1415, - "sha256": "03b0aa5a25c21d0f69e9ea3f2634dc85792947cf2473ae878d04cb049d909b35" + "bytes": 1559, + "sha256": "1eba042de8abe327e30f70f442db436d3af1ae0713a0e81db14382265f695ca8" }, { "path": "reproduction/action-boundary/SPEC.md", - "bytes": 5346, - "sha256": "b083465101b24f1794a1dffdc1178c7492bdf4d0dee987f4ad0892e2ef804d5e" + "bytes": 6002, + "sha256": "f50bc2ecf716948935b1f312396b06ee1f36156e5142368348d7fcc0bc260c6d" }, { "path": "reproduction/action-boundary/reproduce.sh", - "bytes": 1105, - "sha256": "e5d1b6f98f213615e1432a15c4797267660703248a1a6062f3b08dd3db7ea0d1" + "bytes": 1156, + "sha256": "35b58c0adc8addd4aaa2e2d4f7db41755042f49692ac827e816e5f0f1d320b67" }, { "path": "scripts/build-action-boundary-expanded.mjs", "bytes": 13125, "sha256": "eb1a5adbb3ff0434197b722023263921164e65f2320d298a818a2087311891df" }, + { + "path": "scripts/build-action-epoch-report.mjs", + "bytes": 7153, + "sha256": "0ddce68961c5b152077373277127659ac8a4219ad013cc9410a10de333145512" + }, { "path": "scripts/build-action-five-runtime-report.mjs", "bytes": 4240, @@ -637,8 +652,8 @@ }, { "path": "scripts/build-action-release-manifest.mjs", - "bytes": 4739, - "sha256": "f6508992af8d770f356117bd472bf100b0304aa44fb57a3023cf0fe36c52b98d" + "bytes": 4881, + "sha256": "b454fea767cd8d1fbe8900710120feb95c6578c2fdf7dd759bd309e5a9a5f410" }, { "path": "scripts/build-action-revocation-report.mjs", @@ -687,8 +702,8 @@ }, { "path": "scripts/verify-action-receipt-registry.mjs", - "bytes": 4677, - "sha256": "1b7c39984810440f02c8c600d900868b022bcbb6baf1d939f0af0608c6ccf560" + "bytes": 4745, + "sha256": "5d63a1602baf53e1c0d05b70fe62a4514a813c31dc8e25920d3f3126dfd11d72" }, { "path": "scripts/verify-action-stress-375.py", @@ -717,8 +732,8 @@ }, { "path": "test/action-receipt-adapters.test.js", - "bytes": 5755, - "sha256": "aceb5d2dec3ab0938098b43cdd3f34a5a68e5938b5614a63a1e1f876b24a30fa" + "bytes": 9112, + "sha256": "72baa84983a350131351adb8f427e3ed301b2c548b21f7015ee36bf1539a8f5e" }, { "path": "test/action-site-browser-evidence.test.js", @@ -727,5 +742,5 @@ } ], "claim_boundary": "File hashes and Git source are reproducibility metadata, not a DOI or an independent witness.", - "manifest_sha256": "91950fd91f12c30f2f84c33d7739784e91962c08fc8d410d8a8b0201d0b93151" + "manifest_sha256": "4ac1178abca00795ab6015e59b86da28cbab4564cae15eb43a8f975f315419b1" } diff --git a/reproduction/action-boundary/reproduce.sh b/reproduction/action-boundary/reproduce.sh index 7582b3be..58801a8e 100644 --- a/reproduction/action-boundary/reproduce.sh +++ b/reproduction/action-boundary/reproduce.sh @@ -13,6 +13,7 @@ node scripts/dispatch-controlled-pilot-pages.mjs --check node scripts/build-action-site-browser-receipts.mjs --check node scripts/build-action-receipt-adapters-report.mjs --check node scripts/build-action-revocation-report.mjs --check +node scripts/build-action-epoch-report.mjs --check node scripts/build-action-stress-375.mjs --check python3 scripts/verify-action-stress-375.py node scripts/build-action-stress-matrix.mjs --check diff --git a/scripts/build-action-epoch-report.mjs b/scripts/build-action-epoch-report.mjs new file mode 100644 index 00000000..efa696d4 --- /dev/null +++ b/scripts/build-action-epoch-report.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// Project-authored in-memory comparison of policy ABA with and without a +// host-owned monotonically increasing epoch. No external provider is called. +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { canonicalJSONStringify } from '../protocol/canonicalJson.js'; +import { createEmailAdapter, createHttpAdapter, createDatabaseAdapter, createUiAdapter, + mailResource, httpResource, databaseResource, uiResource } from '../pilots/action-boundary/receipt-adapters.mjs'; + +const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); +const hash = value => sha(canonicalJSONStringify(value)); +const secret = Buffer.from('public deterministic epoch fixture key, never use in production'); +const fixtures = [ + { id: 'email', create: createEmailAdapter, tool: 'send_email', effect: 'send', + resource: mailResource, args: { to: 'team@example.test', cc: [], bcc: [], subject: 'Update', body: 'Ready' }, + host: callback => ({ provider: { send: callback } }) }, + { id: 'http', create: createHttpAdapter, tool: 'http_request', effect: 'write', + resource: httpResource, args: { url: 'https://api.example.test/v1/status', method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: '{"ready":true}' }, + host: callback => ({ fetchImpl: callback }) }, + { id: 'database', create: createDatabaseAdapter, tool: 'database_write', effect: 'write', + resource: databaseResource, args: { table: 'records', key: 'r42', value: { status: 'ready' } }, + host: callback => ({ database: { put: callback } }) }, + { id: 'ui', create: createUiAdapter, tool: 'render_ui', effect: 'write', + resource: uiResource, args: { root: '#status', text: 'Ready' }, + host: callback => ({ renderer: { setText: callback } }) } +]; + +const rows = []; +for (const fixture of fixtures) { + const proposal = { protocol: 'aml-proposed-action/1', tool: fixture.tool, + effect: fixture.effect, resource: fixture.resource(fixture.args), + purpose: `Policy epoch fixture for ${fixture.id}`, arguments: fixture.args }; + const policy = { protocol: 'aml-action-policy/1', rules: [ + { tool: fixture.tool, effect: fixture.effect, resource: proposal.resource, requires_approval: true }] }; + const revokedPolicy = { protocol: 'aml-action-policy/1', rules: [] }; + const approve = async ({ proposal_sha256 }) => ({ approved: true, proposal_sha256 }); + + let beforeCalls = 0; + let beforePolicy = policy; + const before = fixture.create({ ...fixture.host(() => { beforeCalls++; return { accepted: true }; }), + policy: () => beforePolicy, authorize: approve, secret, + nonce: () => `${fixture.id}-without-epoch` }); + const beforeReceipt = await before.issue(proposal); + beforePolicy = revokedPolicy; + beforePolicy = policy; // no adapter call sees the intervening policy + const beforeOutcome = await before.dispatch(beforeReceipt, proposal); + assert.equal(beforeOutcome.status, 'DISPATCHED'); + assert.equal(beforeCalls, 1); + + let attackCalls = 0; + let attackPolicy = policy; + let attackEpoch = 1; + const guarded = fixture.create({ ...fixture.host(() => { attackCalls++; return { accepted: true }; }), + policy: () => attackPolicy, policyEpoch: () => attackEpoch, authorize: approve, secret, + nonce: () => `${fixture.id}-epoch-bound` }); + const guardedReceipt = await guarded.issue(proposal); + assert.equal(guardedReceipt.policy_epoch, 1); + attackPolicy = revokedPolicy; + attackEpoch = 2; + attackPolicy = policy; + attackEpoch = 3; + const attackOutcome = await guarded.dispatch(guardedReceipt, proposal); + const attackReplay = await guarded.dispatch(guardedReceipt, proposal); + assert.equal(attackOutcome.reason, 'policy_epoch_changed'); + assert.equal(attackReplay.reason, 'receipt_reused_or_unknown'); + assert.equal(attackCalls, 0); + + let approvalCalls = 0; + let approvalPolicy = policy; + let approvalEpoch = 1; + const crossing = fixture.create({ ...fixture.host(() => { approvalCalls++; return { accepted: true }; }), + policy: () => approvalPolicy, policyEpoch: () => approvalEpoch, secret, + authorize: async ({ proposal_sha256 }) => { + approvalPolicy = revokedPolicy; + approvalEpoch = 2; + approvalPolicy = policy; + approvalEpoch = 3; + return { approved: true, proposal_sha256 }; + }, nonce: () => `${fixture.id}-approval-aba` }); + const crossingReceipt = await crossing.issue(proposal); + const crossingOutcome = await crossing.dispatch(crossingReceipt, proposal); + assert.equal(crossingReceipt.decision, 'SUPPRESS'); + assert.equal(crossingReceipt.reason, 'policy_epoch_changed'); + assert.equal(crossingOutcome.reason, 'receipt_suppressed'); + assert.equal(approvalCalls, 0); + + let controlCalls = 0; + const control = fixture.create({ ...fixture.host(() => { controlCalls++; return { accepted: true }; }), + policy: () => policy, policyEpoch: () => 9, authorize: approve, secret, + nonce: () => `${fixture.id}-unchanged-control` }); + const controlReceipt = await control.issue(proposal); + const controlOutcome = await control.dispatch(controlReceipt, proposal); + assert.equal(controlOutcome.status, 'DISPATCHED'); + assert.equal(controlCalls, 1); + + const body = { protocol: 'aml-receipt-policy-epoch-evidence/1', adapter: fixture.id, + proposal, original_policy: policy, intervening_policy: revokedPolicy, + without_epoch: { authorization_receipt: beforeReceipt, outcome: beforeOutcome, + callback_count: beforeCalls }, + with_epoch: { authorization_receipt: guardedReceipt, outcome: attackOutcome, + replay: attackReplay, callback_count: attackCalls }, + change_during_approval: { authorization_receipt: crossingReceipt, + outcome: crossingOutcome, callback_count: approvalCalls }, + unchanged_epoch_control: { authorization_receipt: controlReceipt, + outcome: controlOutcome, callback_count: controlCalls }, + claim_boundary: 'Project-authored local callbacks and public fixture key; the host epoch is simulated, not a durable provider.' }; + rows.push({ ...body, receipt_sha256: hash(body) }); +} +const report = { protocol: 'aml-receipt-policy-epoch-report/1', adapters: rows, + totals: { adapter_count: rows.length, comparison_callbacks: rows.length, + epoch_bound_attack_callbacks: 0, approval_crossing_callbacks: 0, + unchanged_control_callbacks: rows.length }, + claim_boundary: 'An unobserved policy A→B→A is detected only if the host supplies a reliable monotonic epoch. No external effects or independent validation.' }; +const bytes = JSON.stringify(report, null, 2) + '\n'; +const file = new URL('../docs/action-receipt-policy-epoch-report.json', import.meta.url); +const mode = process.argv[2]; +if (mode === '--write') fs.writeFileSync(file, bytes); +else if (mode === '--check') assert.equal(fs.readFileSync(file, 'utf8'), bytes); +else throw Error('Usage: node scripts/build-action-epoch-report.mjs --write|--check'); +console.log(JSON.stringify({ adapters: rows.length, comparison_callbacks: rows.length, + epoch_bound_attack_callbacks: 0, approval_crossing_callbacks: 0, + unchanged_control_callbacks: rows.length, report_sha256: sha(bytes) })); diff --git a/scripts/build-action-release-manifest.mjs b/scripts/build-action-release-manifest.mjs index e6846219..f7a2d11c 100644 --- a/scripts/build-action-release-manifest.mjs +++ b/scripts/build-action-release-manifest.mjs @@ -30,6 +30,7 @@ const files = [ 'docs/action-pilots/README.md', 'docs/action-pilots/dispatch-receipts.jsonl', 'docs/action-receipt-adapters-report.json', 'docs/action-receipt-revocation-report.json', + 'docs/action-receipt-policy-epoch-report.json', 'registry/action-receipt-sources.json', 'registry/action-receipts.jsonl', 'registry/README.md', 'scripts/build-action-boundary-expanded.mjs', 'scripts/verify-action-boundary-expanded.mjs', @@ -44,6 +45,7 @@ const files = [ 'scripts/build-action-site-browser-receipts.mjs', 'scripts/build-action-receipt-adapters-report.mjs', 'scripts/build-action-revocation-report.mjs', + 'scripts/build-action-epoch-report.mjs', 'scripts/verify-action-receipt-registry.mjs', 'scripts/build-action-release-manifest.mjs', 'test/action-boundary-expanded.test.js', 'test/action-five-runtime.test.js', @@ -57,6 +59,7 @@ const files = [ 'publications/ACTION_BOUNDARY_STRESS_375.md', 'publications/ACTION_RECEIPT_ADAPTERS.md', 'publications/ACTION_RECEIPT_REVOCATION.md', + 'publications/ACTION_RECEIPT_POLICY_EPOCH.md', 'publications/ACTION_BOUNDARY_PROGRESS.md', 'reproduction/action-boundary/SPEC.md', 'reproduction/action-boundary/REPRODUCE.md', @@ -74,7 +77,7 @@ const records = unique.map(relative => { const body = { protocol: 'aml-action-boundary-release-manifest/1', source: 'aruintelligence/aml-core', file_count: records.length, expected: { challenge_cases: 475, maintained_bindings: 5, controlled_pages: 10, - adapter_kinds: 4, registry_receipts: 504 }, + adapter_kinds: 4, registry_receipts: 508 }, files: records, claim_boundary: 'File hashes and Git source are reproducibility metadata, not a DOI or an independent witness.' }; const output = JSON.stringify({ ...body, manifest_sha256: hash(canonicalJSONStringify(body)) }, null, 2) + '\n'; diff --git a/scripts/verify-action-receipt-registry.mjs b/scripts/verify-action-receipt-registry.mjs index 799de76a..4cfd64c8 100644 --- a/scripts/verify-action-receipt-registry.mjs +++ b/scripts/verify-action-receipt-registry.mjs @@ -77,6 +77,7 @@ if (mode === '--check') { ['node', ['scripts/build-action-site-browser-receipts.mjs', '--check']], ['node', ['scripts/build-action-receipt-adapters-report.mjs', '--check']], ['node', ['scripts/build-action-revocation-report.mjs', '--check']], + ['node', ['scripts/build-action-epoch-report.mjs', '--check']], ['node', ['scripts/build-action-stress-375.mjs', '--check']], ['python3', ['scripts/verify-action-stress-375.py']], ['node', ['scripts/build-action-stress-matrix.mjs', '--check']] diff --git a/test/action-receipt-adapters.test.js b/test/action-receipt-adapters.test.js index e0ce561e..0bbabe60 100644 --- a/test/action-receipt-adapters.test.js +++ b/test/action-receipt-adapters.test.js @@ -110,3 +110,75 @@ test('a grant observed under changed target mapping is spent before restoration' assert.equal(restored.callback_count, 0); assert.equal(calls, 0); }); + +test('a host policy epoch detects an unobserved revoke and restore', async () => { + let calls = 0; + let currentPolicy = policy; + let epoch = 1; + const adapter = createUiAdapter({ renderer: { setText: () => { calls++; } }, + policy: () => currentPolicy, policyEpoch: () => epoch, secret: Buffer.alloc(32, 13) }); + const receipt = await adapter.issue(proposal); + currentPolicy = { protocol: 'aml-action-policy/1', rules: [] }; + epoch = 2; + currentPolicy = policy; + epoch = 3; + const outcome = await adapter.dispatch(receipt, proposal); + assert.equal(outcome.reason, 'policy_epoch_changed'); + assert.equal(outcome.callback_count, 0); + assert.equal((await adapter.dispatch(receipt, proposal)).reason, 'receipt_reused_or_unknown'); + assert.equal(calls, 0); +}); + +test('a host epoch changed away and back during approval suppresses issuance', async () => { + let calls = 0; + let epoch = 1; + let currentPolicy = { protocol: 'aml-action-policy/1', rules: [ + { tool: 'render_ui', effect: 'write', resource: proposal.resource, requires_approval: true }] }; + const originalPolicy = currentPolicy; + const adapter = createUiAdapter({ renderer: { setText: () => { calls++; } }, + policy: () => currentPolicy, policyEpoch: () => epoch, secret: Buffer.alloc(32, 14), + authorize: async ({ proposal_sha256 }) => { + currentPolicy = { protocol: 'aml-action-policy/1', rules: [] }; + epoch = 2; + currentPolicy = originalPolicy; + epoch = 3; + return { approved: true, proposal_sha256 }; + } }); + const receipt = await adapter.issue(proposal); + assert.equal(receipt.decision, 'SUPPRESS'); + assert.equal(receipt.reason, 'policy_epoch_changed'); + assert.equal((await adapter.dispatch(receipt, proposal)).callback_count, 0); + assert.equal(calls, 0); +}); + +test('the policy epoch is signed and an unchanged epoch can dispatch', async () => { + let calls = 0; + const adapter = createUiAdapter({ renderer: { setText: () => { calls++; } }, + policy: () => policy, policyEpoch: () => 7, secret: Buffer.alloc(32, 15) }); + const receipt = await adapter.issue(proposal); + assert.equal(receipt.policy_epoch, 7); + const forged = { ...receipt, policy_epoch: 8 }; + assert.equal((await adapter.dispatch(forged, proposal)).reason, 'invalid_receipt'); + assert.equal((await adapter.dispatch(receipt, proposal)).status, 'DISPATCHED'); + assert.equal(calls, 1); +}); + +test('an unavailable epoch spends the grant and a malformed initial epoch fails closed', async () => { + let calls = 0; + let unavailable = false; + const adapter = createUiAdapter({ renderer: { setText: () => { calls++; } }, + policy: () => policy, policyEpoch: () => { + if (unavailable) throw Error('epoch store unavailable'); + return 4; + }, secret: Buffer.alloc(32, 16) }); + const receipt = await adapter.issue(proposal); + unavailable = true; + assert.equal((await adapter.dispatch(receipt, proposal)).reason, 'policy_epoch_unavailable'); + unavailable = false; + assert.equal((await adapter.dispatch(receipt, proposal)).reason, 'receipt_reused_or_unknown'); + assert.equal(calls, 0); + const bad = createUiAdapter({ renderer: { setText: () => { calls++; } }, + policy: () => policy, policyEpoch: () => Number.NaN, secret: Buffer.alloc(32, 17) }); + await assert.rejects(bad.issue(proposal), /invalid_policy_epoch/); + assert.equal(calls, 0); +});