From f734518bf246928d71ef92040a9f5feeac4b0fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Jacob=20Read=20IV=20=E2=80=94=20Creator=20of=20?= =?UTF-8?q?=C4=80ML=E2=84=A2?= Date: Tue, 13 Oct 2026 22:20:28 -0700 Subject: [PATCH] Bind action receipts to one validated dispatch snapshot --- .github/workflows/action-boundary-vectors.yml | 14 + README.md | 2 + docs/action-dispatch-mutants.json | 248 +++++++++++++++++- docs/action-snapshot-proof.json | 44 ++++ docs/dispatch-matrix.html | 2 +- docs/policy-tuple.html | 10 +- docs/snapshot-binding.html | 34 +++ docs/snapshot-binding.js | 99 +++++++ pilots/action-boundary/DISPATCH_MATRIX.md | 17 +- pilots/action-boundary/README.md | 4 +- pilots/action-boundary/boundary.mjs | 48 ++-- pilots/action-boundary/dispatch-mutants.mjs | 36 ++- pilots/action-boundary/snapshot-proof.mjs | 79 ++++++ .../action-boundary/verify_snapshot_proof.py | 52 ++++ test/action-boundary-mutation-proof.test.js | 21 +- test/action-boundary-snapshot-proof.test.js | 37 +++ test/snapshot-binding-view.test.js | 38 +++ 17 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 docs/action-snapshot-proof.json create mode 100644 docs/snapshot-binding.html create mode 100644 docs/snapshot-binding.js create mode 100644 pilots/action-boundary/snapshot-proof.mjs create mode 100644 pilots/action-boundary/verify_snapshot_proof.py create mode 100644 test/action-boundary-snapshot-proof.test.js create mode 100644 test/snapshot-binding-view.test.js diff --git a/.github/workflows/action-boundary-vectors.yml b/.github/workflows/action-boundary-vectors.yml index 78609b54..f305b781 100644 --- a/.github/workflows/action-boundary-vectors.yml +++ b/.github/workflows/action-boundary-vectors.yml @@ -7,16 +7,21 @@ on: - "protocol/canonicalJson.js" - "docs/action-dispatch-matrix.json" - "docs/action-policy-tuple.json" + - "docs/action-snapshot-proof.json" - "docs/action-dispatch-mutants.json" - "docs/dispatch-matrix.html" - "docs/dispatch-matrix.js" - "docs/policy-tuple.html" - "docs/policy-tuple.js" + - "docs/snapshot-binding.html" + - "docs/snapshot-binding.js" - "test/action-boundary-dispatch-matrix.test.js" - "test/action-boundary-policy-tuple.test.js" + - "test/action-boundary-snapshot-proof.test.js" - "test/action-boundary-mutation-proof.test.js" - "test/dispatch-matrix-view.test.js" - "test/policy-tuple-view.test.js" + - "test/snapshot-binding-view.test.js" - ".github/workflows/action-boundary-vectors.yml" pull_request: paths: @@ -24,16 +29,21 @@ on: - "protocol/canonicalJson.js" - "docs/action-dispatch-matrix.json" - "docs/action-policy-tuple.json" + - "docs/action-snapshot-proof.json" - "docs/action-dispatch-mutants.json" - "docs/dispatch-matrix.html" - "docs/dispatch-matrix.js" - "docs/policy-tuple.html" - "docs/policy-tuple.js" + - "docs/snapshot-binding.html" + - "docs/snapshot-binding.js" - "test/action-boundary-dispatch-matrix.test.js" - "test/action-boundary-policy-tuple.test.js" + - "test/action-boundary-snapshot-proof.test.js" - "test/action-boundary-mutation-proof.test.js" - "test/dispatch-matrix-view.test.js" - "test/policy-tuple-view.test.js" + - "test/snapshot-binding-view.test.js" - ".github/workflows/action-boundary-vectors.yml" permissions: @@ -57,9 +67,13 @@ jobs: - run: python3 pilots/action-boundary/verify_dispatch_matrix.py docs/action-dispatch-matrix.json - run: node pilots/action-boundary/policy-tuple.mjs --check - run: python3 pilots/action-boundary/verify_policy_tuple.py docs/action-policy-tuple.json + - run: node pilots/action-boundary/snapshot-proof.mjs --check + - run: python3 pilots/action-boundary/verify_snapshot_proof.py docs/action-snapshot-proof.json - run: node pilots/action-boundary/dispatch-mutants.mjs --check - run: node --test test/action-boundary-dispatch-matrix.test.js - run: node --test test/action-boundary-policy-tuple.test.js + - run: node --test test/action-boundary-snapshot-proof.test.js - run: node --test test/action-boundary-mutation-proof.test.js - run: node --test test/dispatch-matrix-view.test.js - run: node --test test/policy-tuple-view.test.js + - run: node --test test/snapshot-binding-view.test.js diff --git a/README.md b/README.md index 8dada365..f8b2fde6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ **Two code paths, one published contract:** [Explore the JavaScript ↔ Python comparison](https://aruintelligence.github.io/aml-core/action-cross-runtime.html?case=mutated-while-awaiting). Both project-maintained implementations produce exact matching plans, receipts, callback counts, and frozen proposals on all 13 action-boundary cases. Run `node scripts/build-action-cross-runtime-report.mjs --check` to compare a fresh run with the visual page's checked-in machine report. This is cross-language engineering evidence for a limited JSON subset, not an independent external witness. +**A receipt must name the dispatched bytes:** [Inspect the snapshot-binding counterexample](https://aruintelligence.github.io/aml-core/snapshot-binding.html?mode=requires_approval#comparison). A changing JavaScript getter made the reference boundary hash one proposal for its receipt and pass another to the local callback. The boundary now canonicalizes and validates the caller proposal once, then uses that frozen value throughout planning, approval, receipt, and dispatch. Two local cases and a separate Python oracle check the fix; an exact source mutation reintroduces the mismatch. The three bounded suites detect 10/10 selected source changes, not arbitrary bugs or real delivery. + [![JavaScript and Python match on 13 published action-boundary cases; project-maintained evidence only.](docs/cross-runtime-proof.svg)](https://aruintelligence.github.io/aml-core/action-cross-runtime.html) **After a timeout, no blind retry:** [Explore the dispatch ledger](https://aruintelligence.github.io/aml-core/dispatch-ledger.html?scenario=crash). A host-generated operation ID is bound to the exact proposal and claimed before a simulated callback. A two-process race yields one callback; a crash or callback error remains `unknown`, while a changed payload cannot reuse the ID. The six-scenario report is regenerated in CI. This is a local, project-maintained fail-closed pilot—not a distributed exactly-once or delivery guarantee. diff --git a/docs/action-dispatch-mutants.json b/docs/action-dispatch-mutants.json index e1003454..0120a461 100644 --- a/docs/action-dispatch-mutants.json +++ b/docs/action-dispatch-mutants.json @@ -2,11 +2,13 @@ "protocol": "aml-action-dispatch-mutants/1", "provenance": "project-authored exact source substitutions and local callbacks", "limits": "Selected mutations and finite fixtures only. No external side effect, arbitrary bug coverage, outside witness, or formal proof of production safety.", - "boundary_source_sha256": "4a7d72c4df073c669312f614cad109ea487772c852e4cb00880e566fc9ad329f", + "boundary_source_sha256": "e0a16067f023a5281b22115f142f84c83fad29aeeec6327370cf0989aaecc11e", "matrix_report_sha256": "2ccdb17341e4406960ec8e6f519d3d1a5dca7f8a4efa8a57d5c9b1b5c0b7c075", "tuple_report_sha256": "988d17e701fff4d0b18cf68449d415786cf757a7fea93bc3bfd4f83d511635dd", + "snapshot_report_sha256": "12b891289014973e5d86327dc912d179b1537140557d4d120784116193f9477b", "matrix_vectors": 384, "tuple_cases": 8, + "snapshot_cases": 2, "resource_probe_reference": { "execution_status": "blocked", "reason": "no_exact_rule", @@ -14,11 +16,13 @@ "callback_count": 0 }, "summary": { - "selected_mutants": 9, + "selected_mutants": 10, "matrix_detected": 7, "tuple_detected": 3, "tuple_only_detected": 2, - "combined_detected": 9, + "snapshot_detected": 3, + "snapshot_only_detected": 1, + "combined_detected": 10, "undetected": 0 }, "mutations": [ @@ -76,6 +80,8 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -138,6 +144,49 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 1, + "snapshot_changes": [ + { + "index": 1, + "mode": "requires_approval", + "reference": { + "mode": "requires_approval", + "accessor_reads": 1, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + "mutant": { + "mode": "requires_approval", + "accessor_reads": 1, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + } + } + ], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -200,6 +249,8 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -262,6 +313,8 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -324,6 +377,89 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 2, + "snapshot_changes": [ + { + "index": 0, + "mode": "allow", + "reference": { + "mode": "allow", + "accessor_reads": 1, + "approval_requests": 0, + "approval_digest": null, + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "allow", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + "mutant": { + "mode": "allow", + "accessor_reads": 2, + "approval_requests": 0, + "approval_digest": null, + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "Changed on second read", + "callback_sha256": "3ccbe74259301cf7521a83652ae2e8f2fdf0f56c9542e97574bff66e296633cc", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "allow", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": false + } + }, + { + "index": 1, + "mode": "requires_approval", + "reference": { + "mode": "requires_approval", + "accessor_reads": 1, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + "mutant": { + "mode": "requires_approval", + "accessor_reads": 2, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "Changed on second read", + "callback_sha256": "3ccbe74259301cf7521a83652ae2e8f2fdf0f56c9542e97574bff66e296633cc", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": false + } + } + ], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -386,6 +522,8 @@ }, "tuple_changed_cases": 0, "first_changed_tuple": null, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -475,6 +613,8 @@ "callback_count": 1 } }, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": true, "resource_probe_result": { "execution_status": "dispatched", @@ -517,6 +657,8 @@ "callback_count": 1 } }, + "snapshot_changed_cases": 0, + "snapshot_changes": [], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", @@ -559,6 +701,106 @@ "callback_count": 1 } }, + "snapshot_changed_cases": 0, + "snapshot_changes": [], + "resource_probe_changed": false, + "resource_probe_result": { + "execution_status": "blocked", + "reason": "no_exact_rule", + "policy_decision": "deny", + "callback_count": 0 + } + }, + { + "id": "double_snapshot_receipt_split", + "change": "Read a changing caller proposal again after planning", + "matrix_changed_rows": 0, + "first_changed_vector": null, + "tuple_changed_cases": 0, + "first_changed_tuple": null, + "snapshot_changed_cases": 2, + "snapshot_changes": [ + { + "index": 0, + "mode": "allow", + "reference": { + "mode": "allow", + "accessor_reads": 1, + "approval_requests": 0, + "approval_digest": null, + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "allow", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + "mutant": { + "mode": "allow", + "accessor_reads": 2, + "approval_requests": 0, + "approval_digest": null, + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "Changed on second read", + "callback_sha256": "3ccbe74259301cf7521a83652ae2e8f2fdf0f56c9542e97574bff66e296633cc", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "allow", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": false + } + }, + { + "index": 1, + "mode": "requires_approval", + "reference": { + "mode": "requires_approval", + "accessor_reads": 1, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + "mutant": { + "mode": "requires_approval", + "accessor_reads": 2, + "approval_requests": 1, + "approval_digest": "3ccbe74259301cf7521a83652ae2e8f2fdf0f56c9542e97574bff66e296633cc", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "Changed on second read", + "callback_sha256": "3ccbe74259301cf7521a83652ae2e8f2fdf0f56c9542e97574bff66e296633cc", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": false + } + } + ], "resource_probe_changed": false, "resource_probe_result": { "execution_status": "blocked", diff --git a/docs/action-snapshot-proof.json b/docs/action-snapshot-proof.json new file mode 100644 index 00000000..285c61ed --- /dev/null +++ b/docs/action-snapshot-proof.json @@ -0,0 +1,44 @@ +{ + "protocol": "aml-action-snapshot-proof/1", + "provenance": "project-authored stateful accessor and local callback fixtures", + "limits": "Two local fixtures with a changing JavaScript getter. No human approval, provider effect, arbitrary object behavior, or production safety proof.", + "boundary_source_sha256": "e0a16067f023a5281b22115f142f84c83fad29aeeec6327370cf0989aaecc11e", + "rows": [ + { + "mode": "allow", + "accessor_reads": 1, + "approval_requests": 0, + "approval_digest": null, + "claim_attempts": 0, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "allow", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + }, + { + "mode": "requires_approval", + "accessor_reads": 1, + "approval_requests": 1, + "approval_digest": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "claim_attempts": 1, + "callback_count": 1, + "callback_body": "First observed body", + "callback_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "receipt": { + "protocol": "aml-action-dispatch/1", + "proposal_sha256": "92450646537d3defc7e639d7450a8cdbdd15fb12288668fa9f31eb8a2482b6f5", + "policy_decision": "requires_approval", + "execution_status": "dispatched", + "reason": "host_dispatch_returned" + }, + "receipt_matches_callback": true + } + ] +} diff --git a/docs/dispatch-matrix.html b/docs/dispatch-matrix.html index 41c015b3..51d0a63d 100644 --- a/docs/dispatch-matrix.html +++ b/docs/dispatch-matrix.html @@ -33,7 +33,7 @@
FOUR IMPORTANT EDGES

What a receipt hides.

These counts describe the finite matrix, not a production rate. Each card opens one exact example below.

THE COMPLETE CROSS-PRODUCT

Every local path.

Each square is one vector. Columns are policy states; rows are approval-hook behaviors. Each row has an original-scope lane and an other-resource-rule lane. Squares follow the four claim behaviors, with a returning callback then a throwing callback. Select one to inspect its path.

Loading vectors…
DispatchedUnknownBlocked↗ callback returns · ! callback throws

Colors are supported by text labels and each square has an accessible name. Horizontal scrolling reveals all four policies on narrow screens.

-
SOURCE MUTATION CHALLENGE

A new gap, then a smaller proof.

The first mutation challenge found a resource-rule gap and expanded this matrix from 192 to 384 cases. A new challenge found two more blind spots: weakened tool and effect checks. An eight-case rule-match lattice covers those comparisons.

Explore the three-field gate ↗ · Inspect the mutation report ↗
9/9SELECTED GUARD CHANGES
DETECTED ACROSS BOTH SUITES
192OTHER-RESOURCE CASES
BLOCKED BEFORE CALLBACK

The 384-case matrix detects seven selected source changes, including all 192 other-resource rows under a weakened resource check. The smaller three-field lattice detects tool and effect changes the matrix cannot see. These are finite project-authored fixtures, not arbitrary defect coverage.

+
SOURCE MUTATION CHALLENGE

A new gap, then a smaller proof.

The first mutation challenge found a resource-rule gap and expanded this matrix from 192 to 384 cases. A new challenge found two more blind spots: weakened tool and effect checks. An eight-case rule-match lattice covers those comparisons.

Explore the three-field gate ↗ · Inspect the receipt-binding fix ↗ · Mutation report ↗
10/10SELECTED SOURCE CHANGES
DETECTED ACROSS THREE SUITES
192OTHER-RESOURCE CASES
BLOCKED BEFORE CALLBACK

The 384-case matrix detects seven selected source changes, including all 192 other-resource rows under a weakened resource check. The smaller three-field lattice detects tool and effect changes the matrix cannot see. A changing-getter fixture exposes a receipt that once hashed different bytes than the callback received. These are finite project-authored fixtures, not arbitrary defect coverage.

What was actually checked?

The Node runner calls the reference dispatchAction for all 384 local fixtures. A separate project-authored Python oracle checks each row, including receipt, digest, claim consumption, callback target, and marker write. CI rejects five deliberately falsified reports.

Reproduce with node pilots/action-boundary/dispatch-matrix.mjs --check, python3 pilots/action-boundary/verify_dispatch_matrix.py docs/action-dispatch-matrix.json, , python3 pilots/action-boundary/verify_policy_tuple.py docs/action-policy-tuple.json, and node pilots/action-boundary/dispatch-mutants.mjs --check. Read the method and limits ↗

Scope: Finite project-authored callbacks and selected mutations, no human approval, provider call, arbitrary interleavings, real delivery, outside witness, or formal proof of production safety. A browser view of the report does not independently verify its provenance. Never silently retry an unknown result.

diff --git a/docs/policy-tuple.html b/docs/policy-tuple.html index 30b40ec4..8d226632 100644 --- a/docs/policy-tuple.html +++ b/docs/policy-tuple.html @@ -13,9 +13,9 @@ .evidence{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin:0 0 80px}.evidence div{border:1px solid var(--line);background:#122a39;border-radius:14px;padding:20px 22px}.evidence strong{display:block;color:var(--amber);font-size:2.2rem;line-height:1.1;letter-spacing:-.05em}.evidence div:first-child strong{color:var(--mint)}.evidence span{display:block;color:var(--soft);font-size:.8rem;margin-top:7px}.section-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin:0 0 23px}.section-head h2{font-size:clamp(2.4rem,4.6vw,4.8rem);line-height:1;letter-spacing:-.06em;margin:8px 0 0}.section-head p{max-width:490px;color:var(--soft);font-size:.92rem;margin:0} .lattice{margin-bottom:22px}.tuple-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.tuple-panel{border:1px solid #547787;border-radius:23px;background:#102837;padding:clamp(17px,2.8vw,28px)}.tuple-panel[data-scope=other]{background:#222b37;border-color:#786e71}.panel-eyebrow{display:block;color:var(--mint);font:800 .69rem ui-monospace,monospace;letter-spacing:.12em}.tuple-panel[data-scope=other] .panel-eyebrow{color:var(--amber)}.tuple-panel h3{font-size:1.4rem;letter-spacing:-.035em;margin:6px 0 18px}.panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.tuple-cell{display:flex;flex-direction:column;align-items:start;text-align:left;min-height:129px;padding:16px;border:1px solid #657c84;border-radius:12px;background:#172d3a;color:var(--ink);transition:transform .16s,border-color .16s,background .16s}.tuple-cell:hover{transform:translateY(-2px);border-color:#d6e5e4}.tuple-cell[aria-pressed=true]{box-shadow:0 0 0 2px var(--ink);border-color:var(--ink)}.tuple-cell[data-status=dispatched]{background:linear-gradient(140deg,#28594c,#1b3d42);border-color:#81baa0}.cell-index{font:800 .67rem ui-monospace,monospace;color:#bad1d2;letter-spacing:.1em}.tuple-cell strong{display:block;font-size:1rem;line-height:1.2;letter-spacing:-.02em;margin:12px 0 auto}.cell-status{font:900 .68rem ui-monospace,monospace;color:var(--coral);letter-spacing:.06em;margin-top:14px}.tuple-cell[data-status=dispatched] .cell-status{color:var(--mint)}.lattice-note{font-size:.82rem;color:var(--soft);margin:17px 0 0} .inspection{scroll-margin-top:20px;background:linear-gradient(140deg,#183946,#0f2636 65%,#202b38);border:1px solid #6c9294;border-radius:24px;padding:clamp(22px,4vw,42px);margin:0 0 85px}.inspection[hidden]{display:none}.inspection-top{display:flex;justify-content:space-between;align-items:start;gap:20px;flex-wrap:wrap}.inspection h2{font-size:clamp(2rem,4vw,3.8rem);letter-spacing:-.06em;line-height:1.05;margin:7px 0 8px;text-transform:capitalize}.inspection-top p{color:var(--soft);margin:0;max-width:640px}.status{display:inline-block;background:#e4aaa3;border-radius:7px;padding:9px 13px;color:#14222c;font:900 .8rem ui-monospace,monospace;letter-spacing:.07em}.status[data-status=dispatched]{background:var(--mint)}.result{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:26px 0}.result div{border:1px solid var(--line);background:#0b2030;border-radius:11px;padding:15px;min-width:0}.result span{display:block;font:800 .68rem ui-monospace,monospace;color:var(--sky);letter-spacing:.09em}.result strong{display:block;margin-top:9px;font-size:.92rem;overflow-wrap:anywhere}.table-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:11px}.bindings{border-collapse:collapse;width:100%;min-width:600px;text-align:left}.bindings th,.bindings td{border-bottom:1px solid #476272;padding:13px 16px;font-size:.86rem;overflow-wrap:anywhere}.bindings thead th{background:#102d3a;color:var(--sky);font:800 .7rem ui-monospace,monospace;letter-spacing:.08em}.bindings tbody tr:last-child>*{border-bottom:0}.bindings tbody th{color:var(--ink);font:850 .75rem ui-monospace,monospace}.binding-exact td:last-child{color:var(--mint);font-weight:900}.binding-other td:last-child{color:var(--coral);font-weight:900}.digest{font-size:.78rem;color:var(--soft);overflow-wrap:anywhere}.digest code{color:var(--sky)}.case-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.case-actions button{border:1px solid var(--sky);border-radius:8px;background:#103846;color:var(--ink);padding:9px 15px;font-weight:820}.case-actions output{font-size:.8rem;color:var(--mint)}details{margin-top:17px}summary{cursor:pointer;color:var(--sky);font-size:.82rem;font-weight:800}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:400px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:#091a28;padding:16px;font:12px/1.5 ui-monospace,monospace} - .challenge{border:1px solid #a89578;border-radius:23px;background:radial-gradient(circle at 95% 10%,#bd875833,transparent 50%),linear-gradient(120deg,#2b3b3a,#1c2c37);padding:clamp(24px,4vw,45px);display:grid;grid-template-columns:.9fr 1.1fr;gap:45px;margin-bottom:76px}.challenge h2{font-size:clamp(2.2rem,4vw,4.1rem);letter-spacing:-.06em;line-height:1.04;margin:10px 0 15px}.challenge p{color:#dfebe8;margin:0 0 15px}.challenge a{font-weight:800}.challenge-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;align-self:center}.challenge-metrics div{border:1px solid #718982;background:#122d37bf;border-radius:12px;padding:18px}.challenge-metrics strong{display:block;font-size:clamp(2.4rem,4vw,4rem);letter-spacing:-.07em;line-height:1;color:var(--mint)}.challenge-metrics div:last-child strong{color:var(--amber)}.challenge-metrics span{display:block;font:.74rem/1.35 ui-monospace,monospace;color:#d3e3df;margin-top:8px}.method{border-top:1px solid var(--line);padding:36px 0 60px;display:grid;grid-template-columns:.8fr 1.2fr;gap:32px}.method h2{font-size:clamp(1.9rem,3vw,2.8rem);letter-spacing:-.05em;margin:0}.method p{color:var(--soft);font-size:.92rem;margin:0 0 15px}.method code{color:var(--mint);overflow-wrap:anywhere}.foot{border-top:1px solid var(--line);padding:25px 0 55px;color:var(--soft);font-size:.78rem} + .challenge{border:1px solid #a89578;border-radius:23px;background:radial-gradient(circle at 95% 10%,#bd875833,transparent 50%),linear-gradient(120deg,#2b3b3a,#1c2c37);padding:clamp(24px,4vw,45px);display:grid;grid-template-columns:.9fr 1.1fr;gap:45px;margin-bottom:76px}.challenge h2{font-size:clamp(2.2rem,4vw,4.1rem);letter-spacing:-.06em;line-height:1.04;margin:10px 0 15px}.challenge p{color:#dfebe8;margin:0 0 15px}.challenge a{font-weight:800}.challenge-metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;align-self:center}.challenge-metrics div{border:1px solid #718982;background:#122d37bf;border-radius:12px;padding:18px}.challenge-metrics strong{display:block;font-size:clamp(2.4rem,4vw,4rem);letter-spacing:-.07em;line-height:1;color:var(--mint)}.challenge-metrics div:last-child strong{color:var(--amber)}.challenge-metrics span{display:block;font:.74rem/1.35 ui-monospace,monospace;color:#d3e3df;margin-top:8px}.method{border-top:1px solid var(--line);padding:36px 0 60px;display:grid;grid-template-columns:.8fr 1.2fr;gap:32px}.method h2{font-size:clamp(1.9rem,3vw,2.8rem);letter-spacing:-.05em;margin:0}.method p{color:var(--soft);font-size:.92rem;margin:0 0 15px}.method code{color:var(--mint);overflow-wrap:anywhere}.foot{border-top:1px solid var(--line);padding:25px 0 55px;color:var(--soft);font-size:.78rem} @media(max-width:850px){.hero,.challenge,.method{grid-template-columns:1fr}.hero{padding:55px 0}.formula{max-width:650px}.challenge{gap:20px}.section-head{display:block}.section-head p{margin-top:14px}.tuple-grid{grid-template-columns:1fr}} - @media(max-width:570px){.wrap{width:min(100% - 28px,1250px)}.top{align-items:start;flex-direction:column}.top nav{gap:13px}.hero h1{font-size:clamp(3.3rem,14vw,5.5rem)}.evidence{grid-template-columns:1fr;margin-bottom:60px}.evidence div{padding:14px 18px}.evidence strong{font-size:1.8rem}.panel-grid{grid-template-columns:1fr 1fr;gap:6px}.tuple-cell{min-height:140px;padding:12px}.tuple-cell strong{font-size:.86rem}.cell-status{font-size:.58rem}.result{grid-template-columns:1fr}.challenge-metrics{grid-template-columns:1fr 1fr}.challenge-metrics div:last-child{grid-column:1/-1}} + @media(max-width:570px){.wrap{width:min(100% - 28px,1250px)}.top{align-items:start;flex-direction:column}.top nav{gap:13px}.hero h1{font-size:clamp(3.3rem,14vw,5.5rem)}.evidence{grid-template-columns:1fr;margin-bottom:60px}.evidence div{padding:14px 18px}.evidence strong{font-size:1.8rem}.panel-grid{grid-template-columns:1fr 1fr;gap:6px}.tuple-cell{min-height:140px;padding:12px}.tuple-cell strong{font-size:.86rem}.cell-status{font-size:.58rem}.result{grid-template-columns:1fr}.challenge-metrics{grid-template-columns:1fr 1fr}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.tuple-cell{transition:none}} @@ -24,11 +24,11 @@
ĀML™ / ĀRU INTELLIGENCE
ENGINEERING EVIDENCE / POLICY RULE BINDING

One altered field.
Zero callbacks.

A host rule must match the proposed tool, effect, and resource together. Change any one field in this local fixture and the callback is never entered. Explore all eight combinations, including the cases the larger dispatch matrix missed.

-
1 / 8Exact rule; local callback returned
7 / 8Rule mismatch; no callback entry
9 / 9Selected source changes exposed across both suites
+
1 / 8Exact rule; local callback returned
7 / 8Rule mismatch; no callback entry
10 / 10Selected source changes exposed across three suites
THREE BINARY FIELD COMPARISONS

The rule-match lattice.

Each panel fixes the resource relationship. Its four cells vary tool and effect. Select any cell to inspect the actual proposal, policy rule, receipt, and callback count.

Loading the checked-in eight-case report…

CASE 01 / 08

Loading the selected case…

RECEIPT REASON
LOCAL CALLBACK
CALLBACK TARGET
FIELDPROPOSALPOLICY RULECOMPARISON

Canonical proposal SHA-256:

Full JSON ↗
Inspect this raw case
-
SOURCE MUTATION CHALLENGE

The blind spots became tests.

The 384-case dispatch matrix detects seven selected source changes. Two more changes, which make tool or effect comparisons unconditional, leave that matrix untouched. This eight-case lattice exposes each of them in one row. Together the suites detect all nine selected changes.

Inspect the source-bound mutation report ↗
7DETECTED BY THE DISPATCH MATRIX
2ADDITIONAL CHANGES EXPOSED HERE
0SELECTED CHANGES UNDETECTED ACROSS BOTH
-

How to reproduce it.

The Node runner calls the reference dispatchAction once per tool/effect/resource combination. A separately coded Python oracle checks all eight rows, including the canonical proposal digest, field bindings, receipts, and callback entries. CI also rejects deliberately false reports and reruns nine one-at-a-time source substitutions against both checked-in suites.

Run node pilots/action-boundary/policy-tuple.mjs --check, python3 pilots/action-boundary/verify_policy_tuple.py docs/action-policy-tuple.json, and node pilots/action-boundary/dispatch-mutants.mjs --check. Read the method and limits ↗

Scope: One valid allow rule, one returning in-memory callback, three binary field comparisons, and selected source mutations. There is no human approval, provider call, delivery, arbitrary policy set, outside witness, or formal proof of production safety. This browser view presents a checked-in report; it does not independently verify provenance.

+
SOURCE MUTATION CHALLENGE

The blind spots became tests.

The 384-case dispatch matrix detects seven selected source changes. Two more changes, which make tool or effect comparisons unconditional, leave that matrix untouched. This eight-case lattice exposes each of them in one row. A changing-getter fixture exposes one more receipt-binding flaw, now fixed. The three suites detect all ten selected changes.

Inspect the receipt-binding fix ↗ · Mutation report ↗
7DETECTED BY THE DISPATCH MATRIX
2ADDITIONAL CHANGES EXPOSED HERE
1ADDITIONAL SNAPSHOT REGRESSION EXPOSED
0SELECTED CHANGES UNDETECTED
+

How to reproduce it.

The Node runner calls the reference dispatchAction once per tool/effect/resource combination. A separately coded Python oracle checks all eight rows, including the canonical proposal digest, field bindings, receipts, and callback entries. CI also rejects deliberately false reports and reruns ten one-at-a-time source substitutions across three checked-in suites.

Run node pilots/action-boundary/policy-tuple.mjs --check, python3 pilots/action-boundary/verify_policy_tuple.py docs/action-policy-tuple.json, and node pilots/action-boundary/dispatch-mutants.mjs --check. Read the method and limits ↗

Scope: One valid allow rule, one returning in-memory callback, three binary field comparisons, and selected source mutations. There is no human approval, provider call, delivery, arbitrary policy set, outside witness, or formal proof of production safety. This browser view presents a checked-in report; it does not independently verify provenance.

diff --git a/docs/snapshot-binding.html b/docs/snapshot-binding.html new file mode 100644 index 00000000..da8cbc35 --- /dev/null +++ b/docs/snapshot-binding.html @@ -0,0 +1,34 @@ + + + + + + + + The receipt and the bytes · ĀML™ + + + +
+
ĀML™ / ĀRU INTELLIGENCE
+
+
ENGINEERING EVIDENCE / RECEIPT BINDING

The receipt said A.The callback saw B.

A changing JavaScript getter exposed a real mismatch in the reference action boundary: it hashed the proposal for the receipt, then read the caller's object again before dispatch. The host callback received different bytes. The corrected boundary validates and dispatches one canonical snapshot.

+
2 → 1Proposal accessor reads before and after the fix
2 / 2Allow and approval-required paths now bind receipt to callback
10 / 10Selected source changes exposed across three finite suites
+
PINNED REGRESSION / CURRENT REFERENCE

Compare the exact bytes.

The left side reintroduces the second read through one exact source mutation. The right side runs the current boundary. Select the policy path to see both recorded outcomes.

ONE CHANGING GETTER / TWO POLICY MODES

Loading…

REINTRODUCED TWO-READ REGRESSION

Receipt split.

Planning hashes the first value. The second snapshot goes to the callback.

  • CALLER GETTER
  • CALLBACK BODY
  • RECEIPT SHA-256
  • CALLBACK SHA-256
  • APPROVAL FIXTURE
Inspect raw regression result
CURRENT SINGLE-SNAPSHOT BOUNDARY

Receipt bound.

Validation, policy, approval, receipt, and callback use the same frozen proposal.

  • CALLER GETTER
  • CALLBACK BODY
  • RECEIPT SHA-256
  • CALLBACK SHA-256
  • APPROVAL FIXTURE
Inspect raw current result

Loading the checked-in reports…

Mutation report ↗
+
THE IMPLEMENTATION CHANGE

Freeze once. Trust those bytes.

The proposal is serialized once, and the serialized value is validated. Planning and every later host step use that frozen value. A receipt is now tied to the bytes supplied to the local callback, even when the caller exposes a changing property.

Inspect the boundary source ↗

01 / CANONICALIZERead the caller object once
02 / VALIDATE + PLANCheck the serialized proposal and host rule
03 / HOST PATHBind approval, receipt, and callback to frozen bytes
+

What was checked?

The Node fixture uses one changing getter in both an allow rule and an approval-required rule. A source substitution restores the second read and reruns all three finite suites. It changes both snapshot cases but none of the 384 static dispatch vectors or eight static tuple cases. A separately coded Python oracle checks the current snapshot report, its source digest, callback payload digest, and receipt relationship.

Run node pilots/action-boundary/snapshot-proof.mjs --check, python3 pilots/action-boundary/verify_snapshot_proof.py docs/action-snapshot-proof.json, and node pilots/action-boundary/dispatch-mutants.mjs --check. Read the reproduction method ↗

Scope: Project-authored in-memory fixtures and selected mutations. The approval hook is a fixture, not a person. There is no provider effect, real delivery, arbitrary JavaScript object coverage, outside witness, or formal proof of production safety. This page presents checked-in observations; it does not independently verify provenance.

+
+
+ + + diff --git a/docs/snapshot-binding.js b/docs/snapshot-binding.js new file mode 100644 index 00000000..91da13fc --- /dev/null +++ b/docs/snapshot-binding.js @@ -0,0 +1,99 @@ +// Presents checked-in project fixtures. The Python oracle verifies the current report in CI. +export const SNAPSHOT_MODES = Object.freeze(["allow", "requires_approval"]); + +export function inspectSnapshotEvidence(report, mutants) { + const mutation = mutants?.mutations?.find(item => item.id === "double_snapshot_receipt_split"); + if (report?.protocol !== "aml-action-snapshot-proof/1" || + report.provenance !== "project-authored stateful accessor and local callback fixtures" || + mutants?.protocol !== "aml-action-dispatch-mutants/1" || + mutants.boundary_source_sha256 !== report.boundary_source_sha256 || + mutants.snapshot_cases !== 2 || mutants.summary?.combined_detected !== 10 || + !Array.isArray(report.rows) || report.rows.length !== 2 || + mutation?.snapshot_changed_cases !== 2 || !Array.isArray(mutation.snapshot_changes) || + mutation.snapshot_changes.length !== 2) { + throw new TypeError("Unexpected snapshot evidence format"); + } + report.rows.forEach((current, index) => { + const change = mutation.snapshot_changes[index]; + const previous = change?.mutant; + if (current.mode !== SNAPSHOT_MODES[index] || change.index !== index || + change.mode !== current.mode || JSON.stringify(change.reference) !== JSON.stringify(current) || + current.accessor_reads !== 1 || current.callback_body !== "First observed body" || + current.callback_count !== 1 || current.receipt_matches_callback !== true || + current.receipt?.proposal_sha256 !== current.callback_sha256 || + previous?.accessor_reads !== 2 || previous.callback_body !== "Changed on second read" || + previous.callback_count !== 1 || previous.receipt_matches_callback !== false || + previous.receipt?.proposal_sha256 === previous.callback_sha256 || + current.receipt?.execution_status !== "dispatched" || + previous.receipt?.execution_status !== "dispatched") { + throw new TypeError(`Malformed snapshot comparison ${index}`); + } + }); + return mutation.snapshot_changes; +} + +export function snapshotSelection(search) { + const value = new URLSearchParams(search).get("mode"); + return SNAPSHOT_MODES.includes(value) ? value : "allow"; +} + +export function snapshotUrl(currentUrl, mode) { + if (!SNAPSHOT_MODES.includes(mode)) throw new RangeError("Unknown snapshot mode"); + const url = new URL(currentUrl); + url.searchParams.set("mode", mode); + url.hash = "comparison"; + return url.href; +} + +if (typeof document !== "undefined" && document.getElementById("comparison")) { + const $ = id => document.getElementById(id); + const state = $("report-state"); + try { + const responses = await Promise.all([ + fetch("./action-snapshot-proof.json"), fetch("./action-dispatch-mutants.json") + ]); + if (responses.some(response => !response.ok)) throw new Error("HTTP report error"); + const [report, mutants] = await Promise.all(responses.map(response => response.json())); + const changes = inspectSnapshotEvidence(report, mutants); + let selected = snapshotSelection(location.search); + const short = hash => `${hash.slice(0, 12)}…${hash.slice(-8)}`; + function render(mode, updateUrl = true) { + selected = mode; + const index = SNAPSHOT_MODES.indexOf(mode); + const now = report.rows[index]; + const before = changes[index].mutant; + $("mode-title").textContent = mode === "allow" ? "Allow rule" : "Approval-required rule"; + $("mode-note").textContent = mode === "allow" ? + "A host allow rule permits this local callback. Both receipts say dispatched, but only the corrected one identifies what reached it." : + "A fixture approval echoes the offered digest and its one-use claim succeeds. The second read still splits the receipt from the callback in the regression."; + document.querySelectorAll("[data-mode]").forEach(button => + button.setAttribute("aria-pressed", String(button.dataset.mode === mode))); + for (const [prefix, row] of [["before", before], ["after", now]]) { + $(`${prefix}-reads`).textContent = `${row.accessor_reads} ${row.accessor_reads === 1 ? "read" : "reads"}`; + $(`${prefix}-body`).textContent = row.callback_body; + $(`${prefix}-receipt`).textContent = short(row.receipt.proposal_sha256); + $(`${prefix}-callback`).textContent = short(row.callback_sha256); + $(`${prefix}-verdict`).textContent = row.receipt_matches_callback ? "BOUND TO CALLBACK" : "RECEIPT SPLIT"; + $(`${prefix}-verdict`).dataset.bound = String(row.receipt_matches_callback); + $(`${prefix}-approval`).textContent = row.approval_requests ? + `${row.approval_requests} fixture request · ${row.claim_attempts} claim` : "No approval hook"; + $(`${prefix}-raw`).textContent = JSON.stringify(row, null, 2); + } + if (updateUrl) history.replaceState(null, "", snapshotUrl(location.href, mode)); + } + document.querySelectorAll("[data-mode]").forEach(button => + button.addEventListener("click", () => render(button.dataset.mode))); + $("copy-link").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(snapshotUrl(location.href, selected)); + $("copy-status").textContent = "Comparison link copied."; + } catch { $("copy-status").textContent = "Clipboard unavailable. Copy the address bar URL."; } + }); + state.textContent = "Current report and source mutation loaded"; + render(selected, false); + } catch (error) { + state.textContent = `Evidence unavailable: ${error.message}`; + state.dataset.error = "true"; + $("comparison").hidden = true; + } +} diff --git a/pilots/action-boundary/DISPATCH_MATRIX.md b/pilots/action-boundary/DISPATCH_MATRIX.md index 402a4a58..8b8833dd 100644 --- a/pilots/action-boundary/DISPATCH_MATRIX.md +++ b/pilots/action-boundary/DISPATCH_MATRIX.md @@ -9,6 +9,9 @@ node --test test/action-boundary-dispatch-matrix.test.js node pilots/action-boundary/policy-tuple.mjs --check python3 pilots/action-boundary/verify_policy_tuple.py docs/action-policy-tuple.json node --test test/action-boundary-policy-tuple.test.js +node pilots/action-boundary/snapshot-proof.mjs --check +python3 pilots/action-boundary/verify_snapshot_proof.py docs/action-snapshot-proof.json +node --test test/action-boundary-snapshot-proof.test.js node pilots/action-boundary/dispatch-mutants.mjs --check node --test test/action-boundary-mutation-proof.test.js ``` @@ -27,12 +30,18 @@ The larger matrix varies policy-rule resource, but its fixtures always use the p This is exhaustive over three binary field relationships with one rule and one callback. It does not cover arbitrary policies, approval timing, or a provider effect. The matrix and tuple checks answer different bounded questions. +## Single-snapshot receipt binding + +A local changing getter exposed a real reference-boundary defect. The earlier dispatch path called `planAction(proposal, policy)`, then serialized the original caller proposal again before the callback. With a getter returning a first body and a different second body, the receipt's `proposal_sha256` described the **first** body while the host callback received the **second**. An approval-required path could also offer the second digest to its fixture approval hook while returning a receipt with the first digest. Both calls returned `dispatched` despite the mismatch. + +The corrected boundary canonicalizes the caller object once, validates the parsed serialized value, plans against that frozen value, and supplies those bytes to approval, policy rechecks, receipt, and callback. The [two-case current report](../../docs/action-snapshot-proof.json) covers allow and approval-required rules. In both, the getter is read once, the callback sees its first value, and the receipt digest equals the independently recomputed callback digest. A [separately coded Python oracle](verify_snapshot_proof.py) checks the source digest and every recorded field, rejecting a split digest, second read, or inflated scope. The [before/after page](../../docs/snapshot-binding.html) shows the current report beside the exact source mutation that reintroduces the second read. It is a project-authored fixture, not a model of all JavaScript objects or external effects. + ## Source mutation challenge -The [checked-in mutation report](../../docs/action-dispatch-mutants.json) loads nine exact, one-at-a-time substitutions of `boundary.mjs` and reruns both local suites against each changed module. A replacement must match exactly once, so a changed source guard makes the check fail until the substitutions are reviewed. The report records the source, matrix, and tuple SHA-256 digests, changed-row counts, and the first differing case with both outcomes. The two Python oracles check the unchanged baselines; the mutation comparisons use those checked-in baselines. +The [checked-in mutation report](../../docs/action-dispatch-mutants.json) loads ten exact, one-at-a-time substitutions of `boundary.mjs` and reruns all three local suites against each changed module. A replacement must match exactly once, so a changed source guard makes the check fail until the substitutions are reviewed. The report records source and report SHA-256 digests, changed-row counts, and differing cases with both outcomes. The three Python oracles check the unchanged baselines; the mutation comparisons use those checked-in baselines. -Seven selected substitutions change matrix rows: bypassing the approval digest check (8 rows), skipping the one-use claim (16), skipping the policy recheck after approval (8), skipping the recheck after claim (4), passing the caller's mutable object to the callback (2), calling an uncertain callback failure blocked (26), and accepting another resource's rule (192). Weakening the tool equality or effect equality changes **zero** matrix rows, but each changes **one** tuple case: respectively the other-tool or other-effect rule. The tuple also detects the weakened resource equality in one case. Across both suites, **9/9 selected source changes** produce a changed observation. These counts are differences from the baselines, not real-world rates or independent defect counts. +Seven selected substitutions change matrix rows: bypassing the approval digest check (8 rows), skipping the one-use claim (16), skipping the policy recheck after approval (8), skipping the recheck after claim (4), passing the caller's mutable object to the callback (2), calling an uncertain callback failure blocked (26), and accepting another resource's rule (192). Weakening the tool equality or effect equality changes **zero** matrix rows, but each changes **one** tuple case. The tuple also detects the weakened resource equality. Reintroducing a second proposal snapshot changes **zero** static matrix or tuple rows, but both changing-getter cases reveal the receipt split. Across all three suites, **10/10 selected source changes** produce a changed observation. These counts are differences from the baselines, not real-world rates or independent defect counts. -The resource substitution exposed a gap in the earlier 192-case matrix: every present rule named the proposal's resource. The new resource axis closes that gap; all 192 other-resource rows block before callback in the reference implementation. The later tool and effect substitutions exposed the two remaining field comparisons that the 384-case matrix did not vary. The eight-case tuple check closes those selected gaps. The separate local resource-substitution probe remains a direct regression check: a proposal for `outside@example.test` with a rule only for `team@example.test` blocks in the reference boundary, while the resource mutant dispatches once. CI reruns both suites and that probe. Reproduce with `node pilots/action-boundary/dispatch-mutants.mjs --check`; after an intentional source or fixture change, review `--write` output and rerun both Python oracles. +The resource substitution exposed a gap in the earlier 192-case matrix: every present rule named the proposal's resource. The new resource axis closes that gap; all 192 other-resource rows block before callback in the reference implementation. The later tool and effect substitutions exposed the two remaining field comparisons that the 384-case matrix did not vary. The eight-case tuple check closes those selected gaps. The double-snapshot regression exposed a separate binding fault that static JSON inputs cannot exercise; the changing-getter check covers it. The local resource-substitution probe remains a direct regression check. CI reruns all three suites and that probe. Reproduce with `node pilots/action-boundary/dispatch-mutants.mjs --check`; after an intentional source or fixture change, review `--write` output and rerun all three Python oracles. -**Limits:** Each suite is exhaustive only over its declared finite axes and one local callback attempt per case. The mutation challenge samples nine chosen source changes, not arbitrary bugs or equivalent mutants. It does not explore arbitrary interleavings, crashes, malicious host hooks, providers, network effects, human authorization, or delivery. The Python oracles are independently coded but project-authored; they are not outside witnesses or formal proof of production safety. Real hosts must own policy, authentication, one-use claims, operation idempotency, and reconciliation. Never silently retry an unknown result. +**Limits:** Each suite is exhaustive only over its declared finite axes and one local callback attempt per case. The mutation challenge samples ten chosen source changes, not arbitrary bugs or equivalent mutants. It does not explore arbitrary interleavings, crashes, malicious host hooks, providers, network effects, human authorization, or delivery. The Python oracles are independently coded but project-authored; they are not outside witnesses or formal proof of production safety. Real hosts must own policy, authentication, one-use claims, operation idempotency, and reconciliation. Never silently retry an unknown result. diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index 9aa60dff..388b7ff8 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -18,7 +18,9 @@ Agents can propose a tool call; the host must decide whether to dispatch it. Thi [**Explore the three-field gate**](../../docs/policy-tuple.html) to inspect all eight combinations of matching and mismatching tool, effect, and resource bindings. The [local tuple report](../../docs/action-policy-tuple.json) has its own separately coded Python oracle. Exactly one case reaches a local callback; the other seven stop at policy. -[**Challenge nine selected source changes**](DISPATCH_MATRIX.md#source-mutation-challenge) by applying exact one-at-a-time substitutions to the reference boundary. Seven change rows in the 384-vector matrix; the new tuple check exposes the two weakened tool and effect guards that matrix misses. The [mutation report](../../docs/action-dispatch-mutants.json) records source and report digests and the first differing outcomes. It does not measure general defect coverage. +[**Inspect the receipt-binding fix**](../../docs/snapshot-binding.html) for a concrete double-read counterexample: a changing getter made the receipt hash one body while the local callback received another. Dispatch now canonicalizes and validates once. A [two-case report](../../docs/action-snapshot-proof.json) and separate Python oracle check allow and approval-required paths; no message is sent. + +[**Challenge ten selected source changes**](DISPATCH_MATRIX.md#source-mutation-challenge) by applying exact one-at-a-time substitutions to the reference boundary. Seven change rows in the 384-vector matrix; the tuple check exposes weakened tool and effect guards; the changing-getter check catches the reintroduced second read. The [mutation report](../../docs/action-dispatch-mutants.json) records source and report digests and differing outcomes. It does not measure general defect coverage. [**Bind all recipients in a mail function tool**](MAIL_GUARDED_TOOL.md) with `createMailGuardedToolExecute` when the host callback accepts To, CC, or BCC. The [seven-case visual comparison](https://aruintelligence.github.io/aml-core/mail-tool-integration.html?case=added_bcc#cases) executes both adapters with in-memory callbacks. It is a narrow host-side integration example, not a live provider or authenticated approval. diff --git a/pilots/action-boundary/boundary.mjs b/pilots/action-boundary/boundary.mjs index 91bdd92f..dced15ea 100644 --- a/pilots/action-boundary/boundary.mjs +++ b/pilots/action-boundary/boundary.mjs @@ -10,15 +10,19 @@ const plain = value => value && typeof value === "object" && !Array.isArray(valu const keysAre = (value, keys) => Object.keys(value).sort().join("|") === [...keys].sort().join("|"); function snapshot(proposal) { - if (!plain(proposal) || !keysAre(proposal, ["protocol", "tool", "effect", "resource", "purpose", "arguments"]) || - proposal.protocol !== PROTOCOL || !["tool", "resource", "purpose"].every(k => - typeof proposal[k] === "string" && proposal[k].trim() && proposal[k].length <= 256) || - !EFFECTS.has(proposal.effect) || !plain(proposal.arguments)) { - throw new Error("invalid_proposal"); - } + if (!plain(proposal)) throw new Error("invalid_proposal"); + // Canonicalization reads each caller property once. Validate the actual + // serialized value rather than an earlier read from an accessor or proxy. const bytes = canonicalJSONStringify(proposal); if (Buffer.byteLength(bytes, "utf8") > 65536) throw new Error("proposal_too_large"); - return { bytes, digest: hex(bytes), value: JSON.parse(bytes) }; + const value = JSON.parse(bytes); + if (!plain(value) || !keysAre(value, ["protocol", "tool", "effect", "resource", "purpose", "arguments"]) || + value.protocol !== PROTOCOL || !["tool", "resource", "purpose"].every(k => + typeof value[k] === "string" && value[k].trim() && value[k].length <= 256) || + !EFFECTS.has(value.effect) || !plain(value.arguments)) { + throw new Error("invalid_proposal"); + } + return { bytes, digest: hex(bytes), value }; } function ruleFor(proposal, policy) { @@ -38,29 +42,35 @@ function ruleFor(proposal, policy) { return matching[0]; } +function planFrozen(frozen, policy) { + const rule = ruleFor(frozen.value, policy); + return { protocol: "aml-action-plan/1", proposal_sha256: frozen.digest, + decision: !rule ? "deny" : rule.requires_approval ? "requires_approval" : "allow", + reason: rule ? "exact_rule" : "no_exact_rule" }; +} + +const deniedPlan = error => ({ protocol: "aml-action-plan/1", proposal_sha256: null, + decision: "deny", reason: error.message }); + export function planAction(proposal, policy) { - try { - const frozen = snapshot(proposal); - const rule = ruleFor(frozen.value, policy); - return { protocol: "aml-action-plan/1", proposal_sha256: frozen.digest, - decision: !rule ? "deny" : rule.requires_approval ? "requires_approval" : "allow", - reason: rule ? "exact_rule" : "no_exact_rule" }; - } catch (error) { - return { protocol: "aml-action-plan/1", proposal_sha256: null, - decision: "deny", reason: error.message }; - } + try { return planFrozen(snapshot(proposal), policy); } + catch (error) { return deniedPlan(error); } } // All callbacks and policy must be supplied by the trusted host. A model must // never supply them. consumeApproval is an optional atomic, one-use host claim. export async function dispatchAction(proposal, { policy, requestApproval, consumeApproval, executeTool } = {}) { - const plan = planAction(proposal, policy); + let frozen; + let plan; + try { + frozen = snapshot(proposal); + plan = planFrozen(frozen, policy); + } catch (error) { plan = deniedPlan(error); } const receipt = (status, reason) => ({ protocol: "aml-action-dispatch/1", proposal_sha256: plan.proposal_sha256, policy_decision: plan.decision, execution_status: status, reason }); if (plan.decision === "deny") return { receipt: receipt("blocked", plan.reason) }; if (typeof executeTool !== "function") return { receipt: receipt("blocked", "missing_host_dispatch") }; - const frozen = snapshot(proposal); let approval; if (plan.decision === "requires_approval") { if (typeof requestApproval !== "function") return { receipt: receipt("blocked", "approval_unavailable") }; diff --git a/pilots/action-boundary/dispatch-mutants.mjs b/pilots/action-boundary/dispatch-mutants.mjs index 9df1615d..7c38e921 100644 --- a/pilots/action-boundary/dispatch-mutants.mjs +++ b/pilots/action-boundary/dispatch-mutants.mjs @@ -9,10 +9,12 @@ import { fileURLToPath } from "node:url"; import { dispatchAction } from "./boundary.mjs"; import { buildDispatchMatrix } from "./dispatch-matrix.mjs"; import { buildPolicyTuple } from "./policy-tuple.mjs"; +import { buildSnapshotProof, runSnapshotProbe } from "./snapshot-proof.mjs"; const sourceUrl = new URL("./boundary.mjs", import.meta.url); const matrixUrl = new URL("../../docs/action-dispatch-matrix.json", import.meta.url); const tupleUrl = new URL("../../docs/action-policy-tuple.json", import.meta.url); +const snapshotUrl = new URL("../../docs/action-snapshot-proof.json", import.meta.url); const reportUrl = new URL("../../docs/action-dispatch-mutants.json", import.meta.url); const digest = bytes => crypto.createHash("sha256").update(bytes).digest("hex"); @@ -70,6 +72,12 @@ export const mutations = Object.freeze([ change: "Accept a policy rule for another effect", before: "rule.effect === proposal.effect", after: "true" + }, + { + id: "double_snapshot_receipt_split", + change: "Read a changing caller proposal again after planning", + before: 'if (typeof executeTool !== "function") return { receipt: receipt("blocked", "missing_host_dispatch") };', + after: 'if (typeof executeTool !== "function") return { receipt: receipt("blocked", "missing_host_dispatch") };\n frozen = snapshot(proposal);' } ]); @@ -116,15 +124,19 @@ export async function resourceSubstitutionProbe(dispatch = dispatchAction) { } export async function buildDispatchMutants() { - const [source, matrixBytes, tupleBytes] = await Promise.all([ - readFile(sourceUrl, "utf8"), readFile(matrixUrl, "utf8"), readFile(tupleUrl, "utf8") + const [source, matrixBytes, tupleBytes, snapshotBytes] = await Promise.all([ + readFile(sourceUrl, "utf8"), readFile(matrixUrl, "utf8"), + readFile(tupleUrl, "utf8"), readFile(snapshotUrl, "utf8") ]); const reference = JSON.parse(matrixBytes); const tupleReference = JSON.parse(tupleBytes); + const snapshotReference = JSON.parse(snapshotBytes); assert.deepEqual(await buildDispatchMatrix(), reference, "Matrix report is stale; regenerate and verify it before mutation testing"); assert.deepEqual(await buildPolicyTuple(), tupleReference, "Policy tuple report is stale; regenerate and verify it before mutation testing"); + assert.deepEqual(await buildSnapshotProof(), snapshotReference, + "Snapshot report is stale; regenerate and verify it before mutation testing"); const baselineProbe = await resourceSubstitutionProbe(); assert.deepEqual(baselineProbe, { execution_status: "blocked", reason: "no_exact_rule", policy_decision: "deny", callback_count: 0 }); @@ -139,6 +151,13 @@ export async function buildDispatchMutants() { const tupleDifferences = tupleRows.flatMap((row, index) => JSON.stringify(row) === JSON.stringify(tupleReference.rows[index]) ? [] : [index]); const firstTuple = tupleDifferences[0]; + const snapshotRows = await runSnapshotProbe(dispatch); + const snapshotDifferences = snapshotRows.flatMap((row, index) => + JSON.stringify(row) === JSON.stringify(snapshotReference.rows[index]) ? [] : [index]); + const snapshotChanges = snapshotDifferences.map(index => ({ + index, mode: snapshotRows[index].mode, + reference: snapshotReference.rows[index], mutant: snapshotRows[index] + })); const probe = await resourceSubstitutionProbe(dispatch); results.push({ id: mutation.id, change: mutation.change, matrix_changed_rows: differences.length, @@ -155,6 +174,8 @@ export async function buildDispatchMutants() { mutant: { receipt: tupleRows[firstTuple].receipt, callback_count: tupleRows[firstTuple].callback_count } }, + snapshot_changed_cases: snapshotDifferences.length, + snapshot_changes: snapshotChanges, resource_probe_changed: JSON.stringify(probe) !== JSON.stringify(baselineProbe), resource_probe_result: probe }); } @@ -162,14 +183,17 @@ export async function buildDispatchMutants() { matrix_detected: results.filter(row => row.matrix_changed_rows > 0).length, tuple_detected: results.filter(row => row.tuple_changed_cases > 0).length, tuple_only_detected: results.filter(row => row.matrix_changed_rows === 0 && row.tuple_changed_cases > 0).length, - combined_detected: results.filter(row => row.matrix_changed_rows > 0 || row.tuple_changed_cases > 0).length, - undetected: results.filter(row => row.matrix_changed_rows === 0 && row.tuple_changed_cases === 0).length }; + snapshot_detected: results.filter(row => row.snapshot_changed_cases > 0).length, + snapshot_only_detected: results.filter(row => row.matrix_changed_rows === 0 && row.tuple_changed_cases === 0 && row.snapshot_changed_cases > 0).length, + combined_detected: results.filter(row => row.matrix_changed_rows > 0 || row.tuple_changed_cases > 0 || row.snapshot_changed_cases > 0).length, + undetected: results.filter(row => row.matrix_changed_rows === 0 && row.tuple_changed_cases === 0 && row.snapshot_changed_cases === 0).length }; return { protocol: "aml-action-dispatch-mutants/1", provenance: "project-authored exact source substitutions and local callbacks", limits: "Selected mutations and finite fixtures only. No external side effect, arbitrary bug coverage, outside witness, or formal proof of production safety.", boundary_source_sha256: digest(source), matrix_report_sha256: digest(matrixBytes), - tuple_report_sha256: digest(tupleBytes), matrix_vectors: reference.rows.length, - tuple_cases: tupleReference.rows.length, resource_probe_reference: baselineProbe, + tuple_report_sha256: digest(tupleBytes), snapshot_report_sha256: digest(snapshotBytes), + matrix_vectors: reference.rows.length, tuple_cases: tupleReference.rows.length, + snapshot_cases: snapshotReference.rows.length, resource_probe_reference: baselineProbe, summary, mutations: results }; } diff --git a/pilots/action-boundary/snapshot-proof.mjs b/pilots/action-boundary/snapshot-proof.mjs new file mode 100644 index 00000000..84914c35 --- /dev/null +++ b/pilots/action-boundary/snapshot-proof.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Stateful accessors are local adversarial fixtures. No provider or person is contacted. +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { canonicalJSONStringify } from "../../protocol/canonicalJson.js"; +import { dispatchAction } from "./boundary.mjs"; + +const reportUrl = new URL("../../docs/action-snapshot-proof.json", import.meta.url); +const sourceUrl = new URL("./boundary.mjs", import.meta.url); +const digest = value => crypto.createHash("sha256").update(value, "utf8").digest("hex"); +export const modes = Object.freeze(["allow", "requires_approval"]); + +export async function runSnapshotProbe(dispatch = dispatchAction) { + const rows = []; + for (const mode of modes) { + let accessorReads = 0; + const args = { to: "team@example.test" }; + Object.defineProperty(args, "body", { enumerable: true, get() { + return ++accessorReads === 1 ? "First observed body" : "Changed on second read"; + } }); + const proposal = { protocol: "aml-proposed-action/1", tool: "send_message", effect: "send", + resource: "team@example.test", purpose: "Snapshot binding fixture", arguments: args }; + const policy = { protocol: "aml-action-policy/1", rules: [ + { tool: "send_message", effect: "send", resource: "team@example.test", + requires_approval: mode === "requires_approval" } + ] }; + let approvalRequests = 0; + let approvalDigest = null; + let claimAttempts = 0; + let callbackCount = 0; + let callbackProposal = null; + const { receipt } = await dispatch(proposal, { policy, + requestApproval: async ({ proposal_sha256 }) => { + approvalRequests++; + approvalDigest = proposal_sha256; + return { approved: true, proposal_sha256 }; + }, + consumeApproval: async () => { claimAttempts++; return true; }, + executeTool: async frozen => { + callbackCount++; + callbackProposal = frozen; + return { local_marker: true }; + } + }); + const callbackDigest = callbackProposal ? digest(canonicalJSONStringify(callbackProposal)) : null; + rows.push({ mode, accessor_reads: accessorReads, approval_requests: approvalRequests, + approval_digest: approvalDigest, claim_attempts: claimAttempts, callback_count: callbackCount, + callback_body: callbackProposal?.arguments.body ?? null, callback_sha256: callbackDigest, + receipt, receipt_matches_callback: receipt.proposal_sha256 === callbackDigest }); + } + return rows; +} + +export async function buildSnapshotProof() { + return { protocol: "aml-action-snapshot-proof/1", + provenance: "project-authored stateful accessor and local callback fixtures", + limits: "Two local fixtures with a changing JavaScript getter. No human approval, provider effect, arbitrary object behavior, or production safety proof.", + boundary_source_sha256: digest(await readFile(sourceUrl, "utf8")), + rows: await runSnapshotProbe() }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (!(args.length === 0 || (args.length === 1 && ["--write", "--check"].includes(args[0])))) { + throw new Error("Use no argument, --write, or --check"); + } + const report = await buildSnapshotProof(); + const json = JSON.stringify(report, null, 2) + "\n"; + if (args[0] === "--write") { + await writeFile(reportUrl, json, "utf8"); + console.log(`Wrote ${report.rows.length} local snapshot binding cases.`); + } else if (args[0] === "--check") { + assert.equal(await readFile(reportUrl, "utf8"), json, "Snapshot report is stale; run --write"); + console.log(`${report.rows.length} local snapshot binding cases match the checked-in report.`); + } else process.stdout.write(json); +} diff --git a/pilots/action-boundary/verify_snapshot_proof.py b/pilots/action-boundary/verify_snapshot_proof.py new file mode 100644 index 00000000..13c5f029 --- /dev/null +++ b/pilots/action-boundary/verify_snapshot_proof.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Independent expectation for the serialized proposal that reaches a local callback.""" +import hashlib +import json +import pathlib +import sys + +MODES = ("allow", "requires_approval") +BASE = {"protocol": "aml-proposed-action/1", "tool": "send_message", "effect": "send", + "resource": "team@example.test", "purpose": "Snapshot binding fixture", + "arguments": {"to": "team@example.test", "body": "First observed body"}} +DIGEST = hashlib.sha256(json.dumps(BASE, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8")).hexdigest() +PROVENANCE = "project-authored stateful accessor and local callback fixtures" +LIMITS = ("Two local fixtures with a changing JavaScript getter. No human approval, " + "provider effect, arbitrary object behavior, or production safety proof.") + + +def expected(mode): + approval = mode == "requires_approval" + return {"mode": mode, "accessor_reads": 1, "approval_requests": int(approval), + "approval_digest": DIGEST if approval else None, "claim_attempts": int(approval), + "callback_count": 1, "callback_body": "First observed body", + "callback_sha256": DIGEST, + "receipt": {"protocol": "aml-action-dispatch/1", "proposal_sha256": DIGEST, + "policy_decision": mode, "execution_status": "dispatched", + "reason": "host_dispatch_returned"}, + "receipt_matches_callback": True} + + +def verify(report, source_bytes): + if (set(report) != {"protocol", "provenance", "limits", "boundary_source_sha256", "rows"} + or report.get("protocol") != "aml-action-snapshot-proof/1" + or report.get("provenance") != PROVENANCE or report.get("limits") != LIMITS + or report.get("boundary_source_sha256") != hashlib.sha256(source_bytes).hexdigest()): + raise ValueError("snapshot protocol, source binding, provenance, or limits changed") + rows = report.get("rows") + if not isinstance(rows, list) or len(rows) != len(MODES): + raise ValueError("snapshot cases missing or repeated") + for index, (mode, observed) in enumerate(zip(MODES, rows)): + if observed != expected(mode): + raise ValueError(f"snapshot case {index} ({mode}) differs") + return {"protocol": "aml-action-snapshot-oracle/1", "cases_verified": len(MODES), + "scope": "Project-authored local getter fixture; not an outside or formal proof"} + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: python3 verify_snapshot_proof.py ") + path = pathlib.Path(__file__).with_name("boundary.mjs") + data = json.load(sys.stdin if sys.argv[1] == "-" else pathlib.Path(sys.argv[1]).open(encoding="utf-8")) + print(json.dumps(verify(data, path.read_bytes()), sort_keys=True)) diff --git a/test/action-boundary-mutation-proof.test.js b/test/action-boundary-mutation-proof.test.js index 03d42512..ee0c790f 100644 --- a/test/action-boundary-mutation-proof.test.js +++ b/test/action-boundary-mutation-proof.test.js @@ -8,17 +8,26 @@ const checkedIn = JSON.parse(readFileSync( test("selected source mutations match the checked-in, source-bound report", async () => { assert.deepEqual(await buildDispatchMutants(), checkedIn); - assert.deepEqual(checkedIn.summary, { selected_mutants: 9, + assert.deepEqual(checkedIn.summary, { selected_mutants: 10, matrix_detected: 7, tuple_detected: 3, tuple_only_detected: 2, - combined_detected: 9, undetected: 0 }); + snapshot_detected: 3, snapshot_only_detected: 1, + combined_detected: 10, undetected: 0 }); assert.equal(checkedIn.matrix_vectors, 384); assert.equal(checkedIn.tuple_cases, 8); + assert.equal(checkedIn.snapshot_cases, 2); assert.deepEqual(checkedIn.mutations.filter(row => row.matrix_changed_rows === 0).map(row => row.id), - ["tool_wildcard", "effect_wildcard"]); - assert.deepEqual(checkedIn.mutations.slice(-3).map(row => + ["tool_wildcard", "effect_wildcard", "double_snapshot_receipt_split"]); + assert.deepEqual(checkedIn.mutations.slice(-4, -1).map(row => [row.id, row.matrix_changed_rows, row.tuple_changed_cases, row.first_changed_tuple.index]), - [["resource_wildcard", 192, 1, 1], ["tool_wildcard", 0, 1, 4], - ["effect_wildcard", 0, 1, 2]]); + [["resource_wildcard", 192, 1, 1], ["tool_wildcard", 0, 1, 4], + ["effect_wildcard", 0, 1, 2]]); + assert.deepEqual(checkedIn.mutations.at(-1).snapshot_changes.map(change => change.mode), + ["allow", "requires_approval"]); + assert.deepEqual(checkedIn.mutations.at(-1).snapshot_changes[0].reference.callback_body, + "First observed body"); + assert.equal(checkedIn.mutations.at(-1).snapshot_changes[0].mutant.callback_body, + "Changed on second read"); + assert.equal(checkedIn.mutations.at(-1).snapshot_changes[0].mutant.receipt_matches_callback, false); assert.equal(checkedIn.mutations.find(row => row.id === "resource_wildcard").resource_probe_result.callback_count, 1); }); diff --git a/test/action-boundary-snapshot-proof.test.js b/test/action-boundary-snapshot-proof.test.js new file mode 100644 index 00000000..d1995830 --- /dev/null +++ b/test/action-boundary-snapshot-proof.test.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildSnapshotProof } from "../pilots/action-boundary/snapshot-proof.mjs"; + +const report = JSON.parse(readFileSync(new URL("../docs/action-snapshot-proof.json", import.meta.url), "utf8")); +const oracle = fileURLToPath(new URL("../pilots/action-boundary/verify_snapshot_proof.py", import.meta.url)); +const verify = value => spawnSync("python3", [oracle, "-"], { + input: JSON.stringify(value), encoding: "utf8" +}); + +test("one canonical snapshot binds the receipt and callback in both approval modes", async () => { + assert.deepEqual(await buildSnapshotProof(), report); + const result = verify(report); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).cases_verified, 2); + assert.deepEqual(report.rows.map(row => [row.mode, row.accessor_reads, + row.callback_body, row.receipt_matches_callback]), [ + ["allow", 1, "First observed body", true], + ["requires_approval", 1, "First observed body", true] + ]); +}); + +test("the oracle rejects a split receipt, a second accessor read, and an inflated scope", () => { + const split = structuredClone(report); + split.rows[0].callback_sha256 = "0".repeat(64); + split.rows[0].receipt_matches_callback = false; + assert.notEqual(verify(split).status, 0); + const doubleRead = structuredClone(report); + doubleRead.rows[1].accessor_reads = 2; + assert.notEqual(verify(doubleRead).status, 0); + const inflated = structuredClone(report); + inflated.limits = "Production safety proof"; + assert.notEqual(verify(inflated).status, 0); +}); diff --git a/test/snapshot-binding-view.test.js b/test/snapshot-binding-view.test.js new file mode 100644 index 00000000..d2dd14cb --- /dev/null +++ b/test/snapshot-binding-view.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { inspectSnapshotEvidence, snapshotSelection, snapshotUrl } from "../docs/snapshot-binding.js"; + +const report = JSON.parse(readFileSync(new URL("../docs/action-snapshot-proof.json", import.meta.url), "utf8")); +const mutants = JSON.parse(readFileSync(new URL("../docs/action-dispatch-mutants.json", import.meta.url), "utf8")); +const page = readFileSync(new URL("../docs/snapshot-binding.html", import.meta.url), "utf8"); +const script = readFileSync(new URL("../docs/snapshot-binding.js", import.meta.url)); + +test("the site pairs both current cases with the reintroduced two-read source mutation", () => { + const changes = inspectSnapshotEvidence(report, mutants); + assert.deepEqual(changes.map(item => [item.mode, item.mutant.accessor_reads, + item.mutant.receipt_matches_callback]), [ + ["allow", 2, false], ["requires_approval", 2, false] + ]); + for (const mode of ["allow", "requires_approval"]) { + const url = snapshotUrl("https://example.test/snapshot-binding.html", mode); + assert.equal(snapshotSelection(new URL(url).search), mode); + assert.equal(new URL(url).hash, "#comparison"); + } + assert.equal(snapshotSelection("?mode=bogus"), "allow"); + assert.throws(() => snapshotUrl("https://example.test", "bogus"), RangeError); +}); + +test("the viewer rejects an invented receipt relationship and pairs its current script", () => { + const falseReport = structuredClone(report); + falseReport.rows[1].receipt_matches_callback = false; + assert.throws(() => inspectSnapshotEvidence(falseReport, mutants), /comparison/); + for (const required of ["action-snapshot-proof.json", "action-dispatch-mutants.json", + "before-receipt", "after-callback", "no provider effect", "formal proof of production safety", + "does not independently verify provenance"]) { + assert.ok(page.toLowerCase().includes(required.toLowerCase()), required); + } + const version = createHash("sha256").update(script).digest("hex").slice(0, 12); + assert.ok(page.includes(`src="snapshot-binding.js?v=${version}"`)); +});