diff --git a/docs/AGENT_TOOL_QUICKSTART.md b/docs/AGENT_TOOL_QUICKSTART.md index 44cf4356..d17070a4 100644 --- a/docs/AGENT_TOOL_QUICKSTART.md +++ b/docs/AGENT_TOOL_QUICKSTART.md @@ -14,7 +14,7 @@ node pilots/action-boundary/guarded-tool-demo.mjs The first fixture has an exact policy rule and a **simulated** approval; its callback returns a `would_send_to` object. The second changes the destination and is blocked before the callback. There is no network request, message delivery, or human authentication. -For the fourteen-case host rehearsal, list the case IDs and ordered receipt statuses, then inspect one case: +For the fifteen-case host rehearsal, list the case IDs and ordered outcomes, then inspect one case: ```bash node pilots/action-boundary/guarded-tool-rehearsal.mjs --list @@ -22,7 +22,7 @@ node pilots/action-boundary/guarded-tool-rehearsal.mjs --case claim_revoked node pilots/action-boundary/guarded-tool-rehearsal.mjs --check ``` -The list prints one tab-separated ID and status sequence per fixture; a two-attempt case prints both statuses in order. These local fixture callbacks are not human approvals, provider calls, delivery evidence, or proof of production safety. An `unknown` receipt may follow a side effect; do not silently retry it. +The list prints one tab-separated ID and outcome sequence per fixture; a two-attempt case prints both statuses in order. The `mapper_mutated` case prints `pre-plan error`: its host mapper changes its argument snapshot, so the adapter throws before a plan or receipt exists. These local fixture callbacks are not human approvals, provider calls, delivery evidence, or proof of production safety. An `unknown` receipt may follow a side effect; do not silently retry it. The [host allow case](https://aruintelligence.github.io/aml-core/agent-start.html?case=host_allow#rehearsal) shows an exact rule with `requires_approval: false`: one local callback returns with zero approval requests and zero claim attempts. Only the host can choose that policy; the fixture is not a recommendation to waive approval for a real send. @@ -70,6 +70,8 @@ const execute = createGuardedToolExecute({ `requestApproval` must return `{ approved: true, proposal_sha256 }` for the exact frozen proposal, or refuse, and identify a host-issued one-use grant. The `consumeApproval` hook must atomically claim that grant, bound to the digest, and return exactly `true` once. The placeholder above throws, so a copied sketch cannot dispatch until the host implements the claim. The approval callback may be omitted for a policy rule that allows an action without approval. If an approval-required rule has no callback, dispatch blocks. The adapter passes the framework's second `execute` argument as `context` to the host callbacks, and calls the tool with the frozen canonical arguments. A host mapping error throws before an action plan or dispatch receipt exists and before any tool invocation. Handle it in the host error path rather than fabricating a blocked receipt. +The [mapper mutation case](https://aruintelligence.github.io/aml-core/agent-start.html?case=mapper_mutated#rehearsal) records a pre-plan exception and zero approval requests, claim attempts, and tool callbacks. Its `adapter_error` is an observed local exception, not a receipt reason. + The adapter captures the tool arguments once before calling `resourceFor`. Keep that host mapper synchronous and read-only: it receives a snapshot, and a mutation throws before approval or dispatch. The policy resource and provider callback must describe the same destination. If your provider callback adds recipients or rewrites a request, use the prepared request adapters below so the complete provider request is checked before dispatch. **If your mail callback uses CC or BCC**, the To-only `resourceFor` above is incomplete for that expanded callback. Use the [complete-recipient mail adapter](../pilots/action-boundary/MAIL_GUARDED_TOOL.md) for its narrow `to`/`cc`/`bcc` schema. The [seven-case integration lab](https://aruintelligence.github.io/aml-core/mail-tool-integration.html?case=added_bcc#cases) runs both function-tool wrappers with a BCC-capable in-memory callback. It does not send mail or approve anything. diff --git a/docs/agent-start.html b/docs/agent-start.html index 13f04edf..b34bd640 100644 --- a/docs/agent-start.html +++ b/docs/agent-start.html @@ -15,7 +15,7 @@ :root{color-scheme:dark;--bg:#07111f;--ink:#f2f8fb;--muted:#b9cad4;--cyan:#83edec;--gold:#f5cc7e;--rose:#ff9eb2;--line:#355269;font:16px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;color:var(--ink);background:radial-gradient(circle at 83% 4%,#18424c,transparent 37rem),radial-gradient(circle at 0 47%,#172b45,transparent 35rem),var(--bg)}a{color:var(--cyan)}button{font:inherit;cursor:pointer}button:focus-visible,a:focus-visible{outline:3px solid var(--gold);outline-offset:3px}.wrap{width:min(1130px,calc(100% - 36px));margin:auto}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:22px 0;border-bottom:1px solid #ffffff24}.brand{font-weight:950;letter-spacing:.13em;color:var(--ink);text-decoration:none}.top nav{display:flex;gap:18px;flex-wrap:wrap}.top nav a{font-weight:800;font-size:.85rem;text-decoration:none;color:var(--muted)}.top nav a:hover{color:var(--cyan)} h1,h2,h3{letter-spacing:-.04em;line-height:1.08}.hero{padding:clamp(52px,8vw,97px) 0 55px;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(270px,.7fr);gap:30px;align-items:end}.eyebrow{font:800 .73rem ui-monospace,monospace;letter-spacing:.15em;color:var(--cyan)}h1{font-size:clamp(3.1rem,7.4vw,6.7rem);margin:15px 0 20px}h1 em{font-style:normal;color:var(--cyan)}.lead{color:#d2e1e9;font-size:1.16rem;max-width:720px;margin:0}.jump{display:inline-flex;margin-top:28px;padding:12px 19px;border-radius:999px;background:var(--cyan);color:#081723;text-decoration:none;font-weight:900}.case{border:1px solid #527682;border-radius:20px;padding:23px;background:radial-gradient(circle at 70% 0,#1c64726b,transparent 70%),#0b1d2c;box-shadow:0 20px 65px #0005}.case small{font:800 .7rem ui-monospace,monospace;letter-spacing:.13em;color:var(--gold)}.case strong{display:block;font:850 1.27rem/1.3 ui-monospace,monospace;overflow-wrap:anywhere;margin:18px 0;color:var(--ink)}.case p{color:var(--muted);margin:0 0 16px}.case a{font-weight:800;font-size:.85rem} .flow{border:1px solid #517582;border-radius:25px;overflow:hidden;background:linear-gradient(130deg,#102536,#0a1525);box-shadow:0 26px 90px #0007}.bar{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:17px 23px;border-bottom:1px solid var(--line);font:.74rem ui-monospace,monospace;letter-spacing:.13em;color:#c8dce5}.dots{display:flex;gap:7px}.dots i{width:8px;height:8px;background:#587586;border-radius:50%}.dots i:first-child{background:var(--cyan)}.stage{display:grid;grid-template-columns:94px minmax(0,1fr);border-bottom:1px solid var(--line)}.stage:last-child{border-bottom:0}.number{font:900 1.65rem ui-monospace,monospace;color:var(--cyan);padding:30px;border-right:1px solid var(--line);background:#061320}.content{padding:29px clamp(20px,4vw,46px)}.content h2{font-size:clamp(1.5rem,3vw,2.15rem);margin:0 0 11px}.content>p{color:var(--muted);max-width:780px;margin:0 0 18px}.code-row{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap}.copy{border:1px solid var(--cyan);background:#123745;color:var(--cyan);font-weight:850;padding:9px 14px;border-radius:9px}.copy:hover{background:#1d5464}.code{display:block;white-space:pre;overflow:auto;padding:19px;border:1px solid var(--line);border-radius:12px;background:#020b16;color:#dcf4f4;font:12px/1.7 ui-monospace,SFMono-Regular,monospace;margin:16px 0 11px}.small{color:var(--muted);font-size:.83rem}.small strong{color:var(--ink)}.outcomes{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:20px 0 0}.outcome{border:1px solid var(--line);border-radius:12px;padding:16px;background:#ffffff08}.outcome b{display:block;font:.73rem ui-monospace,monospace;letter-spacing:.09em;color:var(--gold)}.outcome strong{display:block;margin:8px 0;color:var(--ink);font-size:1.13rem}.outcome p{margin:0;color:var(--muted);font-size:.82rem}.sketch{max-height:500px}.status{min-height:1.3em;color:var(--gold);font-size:.81rem;margin:8px 0 0}.links{display:flex;flex-wrap:wrap;gap:12px 20px;margin-top:20px}.links a{font-weight:800}.preflight{margin-top:18px;padding:14px 16px;border:1px solid #416779;border-radius:12px;background:#83edec0b;color:#d9e9ef;font-size:.82rem}.preflight strong{color:var(--cyan)}.notice{border-left:3px solid var(--gold);padding:14px 18px;background:#f5cc7e10;color:#e1d7bf;border-radius:0 11px 11px 0;max-width:780px}.next{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin:50px 0 75px}.next a{padding:22px;border:1px solid var(--line);border-radius:16px;background:#ffffff08;text-decoration:none}.next a:hover{border-color:var(--cyan)}.next span{display:block;color:var(--gold);font:.72rem ui-monospace,monospace;letter-spacing:.1em}.next b{display:block;color:var(--ink);font-size:1.15rem;margin:11px 0 7px}.next small{color:var(--muted);font-size:.84rem}.foot{border-top:1px solid var(--line);color:var(--muted);padding:25px 0 50px;font-size:.84rem}.foot strong{color:var(--ink)} - .rehearsal{margin-top:27px;border:1px solid #557988;border-radius:19px;overflow:hidden;background:#071522}.rehearsal-head{display:flex;justify-content:space-between;gap:14px;align-items:start;flex-wrap:wrap;padding:22px;border-bottom:1px solid var(--line);background:linear-gradient(100deg,#154050,#101d33)}.rehearsal-head span{font:800 .7rem ui-monospace,monospace;letter-spacing:.13em;color:var(--cyan)}.rehearsal-head h3{font-size:1.55rem;margin:7px 0}.rehearsal-head p{color:var(--muted);margin:0;max-width:630px;font-size:.87rem}.rehearsal-head a{font-size:.78rem;font-weight:800}.receipt-legend{display:flex;flex-wrap:wrap;gap:7px;list-style:none;margin:15px 0 0;padding:0}.receipt-legend li{border:1px solid var(--line);border-radius:999px;background:#081d2c;padding:6px 9px;color:var(--muted);font-size:.68rem}.receipt-legend b{font:800 .65rem ui-monospace,monospace;letter-spacing:.05em;margin-right:5px}.receipt-legend [data-tone="go"] b{color:#a9f1c0}.receipt-legend [data-tone="stop"] b{color:var(--rose)}.receipt-legend [data-tone="hold"] b{color:var(--gold)}.case-picker{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:17px;border-bottom:1px solid var(--line)}.case-picker button{text-align:left;border:1px solid var(--line);border-radius:11px;padding:12px;background:#ffffff07;color:var(--ink);font-weight:800;font-size:.8rem;line-height:1.3}.case-picker button span{display:block;font:700 .62rem ui-monospace,monospace;letter-spacing:.1em;color:var(--gold);margin-bottom:5px}.case-picker button[data-tone="go"] span{color:#a9f1c0}.case-picker button[data-tone="stop"] span{color:var(--rose)}.case-picker button[aria-checked="true"]{border-color:var(--cyan);background:#83edec1c;box-shadow:inset 0 0 0 1px #83edec55}.case-picker button:hover{border-color:var(--cyan)}.case-detail{padding:22px}.case-detail h4{font-size:1.35rem;margin:5px 0}.case-detail>p{color:var(--muted);margin:0 0 18px}.case-detail .eyebrow{font-size:.64rem}.attempts{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:11px}.attempt{border:1px solid var(--line);border-radius:12px;background:#ffffff06;padding:16px}.attempt[data-tone="stop"]{border-color:#a66275;background:#ff9eb20b}.attempt[data-tone="go"]{border-color:#70a98f;background:#a9f1c00b}.attempt[data-tone="hold"]{border-color:#b89d67;background:#f5cc7e0b}.attempt small{display:block;color:var(--muted);font:700 .65rem ui-monospace,monospace;letter-spacing:.1em}.attempt strong{display:block;margin:8px 0;font-size:1.27rem}.attempt[data-tone="stop"] strong{color:var(--rose)}.attempt[data-tone="go"] strong{color:#a9f1c0}.attempt[data-tone="hold"] strong{color:var(--gold)}.attempt .receipt-meaning{margin:0 0 9px;color:var(--muted);font-size:.75rem}.attempt code{display:block;color:#d4e4eb;font:.72rem/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.case-metrics{display:flex;gap:9px;flex-wrap:wrap;margin-top:17px}.case-metrics span{border:1px solid var(--line);border-radius:999px;padding:7px 11px;color:#d8e9ef;font:.72rem ui-monospace,monospace}.case-detail .limit{margin:17px 0 0;color:var(--muted);font-size:.73rem}.host-move{margin-top:19px;padding:16px 18px;border-left:3px solid var(--gold);border-radius:0 10px 10px 0;background:#f5cc7e0d}.host-move span{display:block;color:var(--gold);font:800 .65rem ui-monospace,monospace;letter-spacing:.12em}.host-move p{margin:7px 0 0;color:#e4e1d9;font-size:.88rem} + .rehearsal{margin-top:27px;border:1px solid #557988;border-radius:19px;overflow:hidden;background:#071522}.rehearsal-head{display:flex;justify-content:space-between;gap:14px;align-items:start;flex-wrap:wrap;padding:22px;border-bottom:1px solid var(--line);background:linear-gradient(100deg,#154050,#101d33)}.rehearsal-head span{font:800 .7rem ui-monospace,monospace;letter-spacing:.13em;color:var(--cyan)}.rehearsal-head h3{font-size:1.55rem;margin:7px 0}.rehearsal-head p{color:var(--muted);margin:0;max-width:630px;font-size:.87rem}.rehearsal-head a{font-size:.78rem;font-weight:800}.receipt-legend{display:flex;flex-wrap:wrap;gap:7px;list-style:none;margin:15px 0 0;padding:0}.receipt-legend li{border:1px solid var(--line);border-radius:999px;background:#081d2c;padding:6px 9px;color:var(--muted);font-size:.68rem}.receipt-legend b{font:800 .65rem ui-monospace,monospace;letter-spacing:.05em;margin-right:5px}.receipt-legend [data-tone="go"] b{color:#a9f1c0}.receipt-legend [data-tone="stop"] b{color:var(--rose)}.receipt-legend [data-tone="hold"] b{color:var(--gold)}.receipt-legend [data-tone="preflight"] b{color:#b7aeff}.case-picker{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:17px;border-bottom:1px solid var(--line)}.case-picker button{text-align:left;border:1px solid var(--line);border-radius:11px;padding:12px;background:#ffffff07;color:var(--ink);font-weight:800;font-size:.8rem;line-height:1.3}.case-picker button span{display:block;font:700 .62rem ui-monospace,monospace;letter-spacing:.1em;color:var(--gold);margin-bottom:5px}.case-picker button[data-tone="go"] span{color:#a9f1c0}.case-picker button[data-tone="stop"] span{color:var(--rose)}.case-picker button[data-tone="preflight"] span{color:#b7aeff}.case-picker button[aria-checked="true"]{border-color:var(--cyan);background:#83edec1c;box-shadow:inset 0 0 0 1px #83edec55}.case-picker button:hover{border-color:var(--cyan)}.case-detail{padding:22px}.case-detail h4{font-size:1.35rem;margin:5px 0}.case-detail>p{color:var(--muted);margin:0 0 18px}.case-detail .eyebrow{font-size:.64rem}.attempts{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:11px}.attempt{border:1px solid var(--line);border-radius:12px;background:#ffffff06;padding:16px}.attempt[data-tone="stop"]{border-color:#a66275;background:#ff9eb20b}.attempt[data-tone="go"]{border-color:#70a98f;background:#a9f1c00b}.attempt[data-tone="hold"]{border-color:#b89d67;background:#f5cc7e0b}.attempt[data-tone="preflight"]{border-color:#9185cc;background:#b7aeff0b}.attempt small{display:block;color:var(--muted);font:700 .65rem ui-monospace,monospace;letter-spacing:.1em}.attempt strong{display:block;margin:8px 0;font-size:1.27rem}.attempt[data-tone="stop"] strong{color:var(--rose)}.attempt[data-tone="go"] strong{color:#a9f1c0}.attempt[data-tone="hold"] strong{color:var(--gold)}.attempt[data-tone="preflight"] strong{color:#b7aeff}.attempt .receipt-meaning{margin:0 0 9px;color:var(--muted);font-size:.75rem}.attempt code{display:block;color:#d4e4eb;font:.72rem/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.case-metrics{display:flex;gap:9px;flex-wrap:wrap;margin-top:17px}.case-metrics span{border:1px solid var(--line);border-radius:999px;padding:7px 11px;color:#d8e9ef;font:.72rem ui-monospace,monospace}.case-detail .limit{margin:17px 0 0;color:var(--muted);font-size:.73rem}.host-move{margin-top:19px;padding:16px 18px;border-left:3px solid var(--gold);border-radius:0 10px 10px 0;background:#f5cc7e0d}.host-move span{display:block;color:var(--gold);font:800 .65rem ui-monospace,monospace;letter-spacing:.12em}.host-move p{margin:7px 0 0;color:#e4e1d9;font-size:.88rem} .attempt .trace-label{margin-top:17px}.hook-trace{list-style:none;margin:8px 0 0;padding:12px 0 0;border-top:1px solid var(--line)}.hook-trace li{position:relative;margin:0 0 8px;padding-left:17px;color:var(--muted);font-size:.73rem}.hook-trace li:before{content:"";position:absolute;left:0;top:.44em;width:6px;height:6px;border-radius:50%;background:var(--cyan)}.hook-trace li:last-child{margin:0}.hook-trace .none:before{background:#8297a5} .case-command-row{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-top:21px}.case-command-row span{font:800 .67rem ui-monospace,monospace;letter-spacing:.12em;color:var(--cyan)}.case-actions{display:flex;gap:8px;flex-wrap:wrap}.case-command-row button{border:1px solid #608697;background:#113344;color:var(--cyan);border-radius:9px;padding:7px 11px;font-size:.77rem;font-weight:800}.case-command-row button:hover{background:#1a5264}.case-detail .case-command{margin:9px 0 0;padding:12px 14px}.case-detail .case-copy-status{min-height:1.2em;margin:5px 0 0;font-size:.73rem;color:var(--gold)} @media(max-width:790px){.hero{grid-template-columns:1fr}.case{max-width:520px}.next{grid-template-columns:1fr}.case-picker{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:530px){.top{flex-wrap:wrap}.top nav{gap:9px}.top nav a{font-size:.75rem}.stage{grid-template-columns:1fr}.number{padding:10px 19px;border-right:0;border-bottom:1px solid var(--line)}.bar{font-size:.63rem;flex-wrap:wrap;gap:8px 12px}.content{padding:20px}.hero h1{font-size:clamp(3rem,12vw,4.5rem)}.case-picker{grid-template-columns:1fr}.rehearsal-head,.case-detail{padding:18px}.attempts{grid-template-columns:minmax(0,1fr)}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} @@ -32,7 +32,7 @@ git rev-parse HEAD node pilots/action-boundary/guarded-tool-demo.mjs node pilots/action-boundary/guarded-tool-rehearsal.mjs --check

-
HOST REHEARSAL / CONTROLLED CASES

Where does the call stop?

Choose a condition. Use arrow keys, Home, or End after focusing a case. These are recorded results from the project adapter running local fixtures, with approval and callback counts visible.

  • DISPATCHEDfixture returned
  • BLOCKEDno tool callback
  • UNKNOWNoutcome uncertain
Jump to selected result ↓Inspect the complete report ↗
OBSERVED IN THE LOCAL REHEARSAL

Loading the report…

Checking the project-authored snapshot.

HOST DESIGN MOVE

REPRODUCE OR SHARE THIS CASE
node pilots/action-boundary/guarded-tool-rehearsal.mjs --case changed_destination

The hook trace records which fixture hooks ran; it does not expose every internal policy check. A dispatched receipt means the fixture callback returned. An unknown receipt does not mean no side effect occurred. A claim-hook error can leave a grant spent without entering the tool callback. An approval-hook error can leave host authorization state changed. In-memory fixture writes are local markers, not provider delivery. Neither proves real approval, service delivery, or production safety.

+
HOST REHEARSAL / CONTROLLED CASES

Where does the call stop?

Choose a condition. Use arrow keys, Home, or End after focusing a case. These are recorded results from the project adapter running local fixtures, with approval and callback counts visible.

  • DISPATCHEDfixture returned
  • BLOCKEDno tool callback
  • UNKNOWNoutcome uncertain
  • HOST ERRORno plan or receipt
Jump to selected result ↓Inspect the complete report ↗
OBSERVED IN THE LOCAL REHEARSAL

Loading the report…

Checking the project-authored snapshot.

HOST DESIGN MOVE

REPRODUCE OR SHARE THIS CASE
node pilots/action-boundary/guarded-tool-rehearsal.mjs --case changed_destination

The hook trace records which fixture hooks ran; it does not expose every internal policy check. A host mapper error occurs before a plan or receipt exists. A dispatched receipt means the fixture callback returned. An unknown receipt does not mean no side effect occurred. A claim-hook error can leave a grant spent without entering the tool callback. An approval-hook error can leave host authorization state changed. In-memory fixture writes are local markers, not provider delivery. Neither proves real approval, service delivery, or production safety.

02

Move the boundary into your host.

The real host owns the tool identity, effect, resource mapping, policy, authenticated approval, one-use claim, and service call. This sketch deliberately throws until you implement the three host functions. It imports the project preview from your checkout.

import { createGuardedToolExecute } from "./pilots/action-boundary/guarded-tool.mjs"; @@ -65,6 +65,6 @@
Scope: This is a project-authored, simulated function-tool path from a preview checkout. It does not intercept every agent tool, authenticate a human, send mail, prove delivery, or certify a production integration. ĀML™ / ĀRU Meaning Language™ is stewarded by ĀRU Intelligence Inc. · Claims and evidence.
- + diff --git a/docs/agent-start.js b/docs/agent-start.js index 7cbbd7df..0d4720ce 100644 --- a/docs/agent-start.js +++ b/docs/agent-start.js @@ -60,23 +60,29 @@ if (typeof document !== "undefined" && document.getElementById("rehearsal")) { const attemptCard = (attempt, index) => { const card = document.createElement("div"); card.className = "attempt"; - card.dataset.tone = attempt.execution_status === "dispatched" ? "go" : + card.dataset.tone = attempt.adapter_error ? "preflight" : + attempt.execution_status === "dispatched" ? "go" : attempt.execution_status === "unknown" ? "hold" : "stop"; const label = document.createElement("small"); - label.textContent = `ATTEMPT ${String(index + 1).padStart(2, "0")} · PLAN ${attempt.policy_decision.toUpperCase().replaceAll("_", " ")}`; + label.textContent = attempt.adapter_error ? + `ATTEMPT ${String(index + 1).padStart(2, "0")} · BEFORE PLAN` : + `ATTEMPT ${String(index + 1).padStart(2, "0")} · PLAN ${attempt.policy_decision.toUpperCase().replaceAll("_", " ")}`; const status = document.createElement("strong"); - status.textContent = attempt.execution_status.toUpperCase(); + status.textContent = attempt.adapter_error ? "HOST ERROR" : attempt.execution_status.toUpperCase(); const meaning = document.createElement("p"); meaning.className = "receipt-meaning"; - meaning.textContent = attempt.execution_status === "dispatched" ? + meaning.textContent = attempt.adapter_error ? + "The adapter rejected the host mapper. No plan or dispatch receipt exists." : + attempt.execution_status === "dispatched" ? "Local fixture callback returned; external delivery is unverified." : attempt.execution_status === "unknown" ? "Local fixture callback threw; a side effect may have occurred." : "Local fixture callback was not entered for this attempt."; const reason = document.createElement("code"); - reason.textContent = `receipt.reason = ${attempt.reason}`; + reason.textContent = attempt.adapter_error ? + `adapter error: ${attempt.adapter_error}` : `receipt.reason = ${attempt.reason}`; const digest = document.createElement("code"); - digest.textContent = `proposal SHA-256: ${attempt.proposal_sha256}`; + if (!attempt.adapter_error) digest.textContent = `proposal SHA-256: ${attempt.proposal_sha256}`; const result = document.createElement("code"); if (attempt.result) result.textContent = `Fixture target: ${attempt.result.would_send_to}`; const traceLabel = document.createElement("small"); @@ -91,7 +97,8 @@ if (typeof document !== "undefined" && document.getElementById("rehearsal")) { step.textContent = event; trace.append(step); } - card.append(label, status, meaning, reason, digest); + card.append(label, status, meaning, reason); + if (!attempt.adapter_error) card.append(digest); if (attempt.result) card.append(result); card.append(traceLabel, trace); return card; @@ -106,7 +113,7 @@ if (typeof document !== "undefined" && document.getElementById("rehearsal")) { } }); - fetch("guarded-tool-rehearsal.json?v=a2c836a065eb").then(response => { + fetch("guarded-tool-rehearsal.json?v=bf33ac500b17").then(response => { if (!response.ok) throw new Error("Report request failed"); return response.json(); }).then(report => { @@ -119,10 +126,12 @@ if (typeof document !== "undefined" && document.getElementById("rehearsal")) { button.setAttribute("role", "radio"); button.dataset.case = item.id; const statuses = item.attempts.map(attempt => attempt.execution_status); - button.dataset.tone = statuses.includes("unknown") ? "hold" : + button.dataset.tone = item.attempts.some(attempt => attempt.adapter_error) ? "preflight" : + statuses.includes("unknown") ? "hold" : statuses.every(status => status === "blocked") ? "stop" : "go"; const state = document.createElement("span"); - state.textContent = `${String(index + 1).padStart(2, "0")} / ${statuses.join(" → ").toUpperCase()}`; + state.textContent = `${String(index + 1).padStart(2, "0")} / ${item.attempts.map(attempt => + attempt.adapter_error ? "HOST ERROR" : attempt.execution_status.toUpperCase()).join(" → ")}`; button.append(state, document.createTextNode(item.title)); button.addEventListener("click", () => show(item.id, true)); button.addEventListener("keydown", event => { diff --git a/docs/guarded-tool-rehearsal.json b/docs/guarded-tool-rehearsal.json index d9f2909d..49810fa1 100644 --- a/docs/guarded-tool-rehearsal.json +++ b/docs/guarded-tool-rehearsal.json @@ -83,6 +83,26 @@ "callback_count": 0, "fixture_state_changes": 0 }, + { + "id": "mapper_mutated", + "title": "Host mapper mutates the proposal", + "change": "The host destination mapper changes its canonical argument snapshot. The adapter throws before producing a plan or dispatch receipt.", + "host_move": "Keep resourceFor synchronous and read-only. Treat mapper failures as host errors before planning; never present them as blocked dispatch receipts.", + "destination": "team@example.test", + "attempts": [ + { + "adapter_error": "resourceFor must not mutate arguments", + "host_hook_events": [ + "Host mapper changed its argument snapshot" + ] + } + ], + "approval_requests": 0, + "claim_attempts": 0, + "claims_consumed": 0, + "callback_count": 0, + "fixture_state_changes": 0 + }, { "id": "approval_unavailable", "title": "No approval hook", diff --git a/pilots/action-boundary/guarded-tool-rehearsal.mjs b/pilots/action-boundary/guarded-tool-rehearsal.mjs index 64e9443c..ad58b663 100644 --- a/pilots/action-boundary/guarded-tool-rehearsal.mjs +++ b/pilots/action-boundary/guarded-tool-rehearsal.mjs @@ -27,6 +27,12 @@ const fixtures = [ host_move: "Derive the policy resource from the exact provider destination and keep the rule narrow.", options: { to: "other@example.test" } }, + { + id: "mapper_mutated", title: "Host mapper mutates the proposal", + change: "The host destination mapper changes its canonical argument snapshot. The adapter throws before producing a plan or dispatch receipt.", + host_move: "Keep resourceFor synchronous and read-only. Treat mapper failures as host errors before planning; never present them as blocked dispatch receipts.", + options: { mapper: "mutate" } + }, { id: "approval_unavailable", title: "No approval hook", change: "The host supplies no way to request approval.", @@ -133,7 +139,13 @@ async function runFixture({ id, title, change, host_move, options }) { }; const guarded = createGuardedToolExecute({ tool: "send_message", effect: "send", purpose: "Send a status update", - resourceFor: args => args.to, policy, requestApproval, + resourceFor: mapped => { + if (options.mapper === "mutate") { + mapped.to = "other@example.test"; + hookEvents.push("Host mapper changed its argument snapshot"); + } + return mapped.to; + }, policy, requestApproval, consumeApproval: async () => { claimAttempts++; const accepted = claimAttempts === 1; @@ -166,6 +178,12 @@ async function runFixture({ id, title, change, host_move, options }) { const originalDestination = args.to; async function attempt() { hookEvents = []; + if (options.mapper === "mutate") { + await assert.rejects(guarded(args), { name: "TypeError", + message: "resourceFor must not mutate arguments" }); + return { adapter_error: "resourceFor must not mutate arguments", + host_hook_events: [...hookEvents] }; + } const { plan, receipt, result } = await guarded(args); return { policy_decision: plan.decision, @@ -201,6 +219,7 @@ export function assertGuardedToolRehearsal(report) { host_allow: [["allow", "dispatched", "host_dispatch_returned"], 1], approved_route: [["requires_approval", "dispatched", "host_dispatch_returned"], 1], changed_destination: [["deny", "blocked", "no_exact_rule"], 0], + mapper_mutated: [0], approval_unavailable: [["requires_approval", "blocked", "approval_unavailable"], 0], approval_declined: [["requires_approval", "blocked", "approval_missing_or_mismatched"], 0], approval_hook_error: [["requires_approval", "blocked", "approval_error"], 0], @@ -230,12 +249,21 @@ export function assertGuardedToolRehearsal(report) { const count = values.at(-1); const attempts = values.slice(0, -1); assert.equal(item.callback_count, count, `${id}: callback count`); - assert.deepEqual(item.attempts.map(({ policy_decision, execution_status, reason }) => - [policy_decision, execution_status, reason]), attempts, `${id}: receipts`); - assert.ok(item.attempts.every(attempt => /^[0-9a-f]{64}$/.test(attempt.proposal_sha256)), - `${id}: proposal digest`); + if (id === "mapper_mutated") { + assert.deepEqual(item.attempts, [{ + adapter_error: "resourceFor must not mutate arguments", + host_hook_events: ["Host mapper changed its argument snapshot"] + }]); + } else { + assert.deepEqual(item.attempts.map(({ policy_decision, execution_status, reason }) => + [policy_decision, execution_status, reason]), attempts, `${id}: receipts`); + assert.ok(item.attempts.every(attempt => /^[0-9a-f]{64}$/.test(attempt.proposal_sha256)), + `${id}: proposal digest`); + } } assert.equal(byId.changed_destination.approval_requests, 0); + assert.equal(byId.mapper_mutated.approval_requests, 0); + assert.equal(byId.mapper_mutated.claim_attempts, 0); assert.equal(byId.host_allow.approval_requests, 0); assert.equal(byId.host_allow.claim_attempts, 0); assert.equal(byId.host_allow.claims_consumed, 0); @@ -295,7 +323,7 @@ export function selectGuardedToolCase(report, id) { export function listGuardedToolCases(report) { return report.cases.map(item => - `${item.id}\t${item.attempts.map(attempt => attempt.execution_status).join(" -> ")}` + `${item.id}\t${item.attempts.map(attempt => attempt.adapter_error ? "pre-plan error" : attempt.execution_status).join(" -> ")}` ).join("\n") + "\n"; } diff --git a/test/guarded-tool-rehearsal.test.js b/test/guarded-tool-rehearsal.test.js index 4c2edee4..c74cd1c8 100644 --- a/test/guarded-tool-rehearsal.test.js +++ b/test/guarded-tool-rehearsal.test.js @@ -14,6 +14,14 @@ test("checked-in browser examples come from the live host adapter rehearsal", as } } const hostAllow = report.cases.find(item => item.id === "host_allow"); + const mapper = report.cases.find(item => item.id === "mapper_mutated"); + assert.deepEqual(mapper.attempts, [{ + adapter_error: "resourceFor must not mutate arguments", + host_hook_events: ["Host mapper changed its argument snapshot"] + }]); + assert.equal(mapper.approval_requests, 0); + assert.equal(mapper.claim_attempts, 0); + assert.equal(mapper.callback_count, 0); assert.equal(hostAllow.attempts[0].policy_decision, "allow"); assert.equal(hostAllow.attempts[0].execution_status, "dispatched"); assert.equal(hostAllow.approval_requests, 0); @@ -69,8 +77,9 @@ test("one selected case keeps the same observed receipt and callback count", asy test("case list prints each fixture ID and its ordered receipt statuses", async () => { const report = await buildGuardedToolRehearsal(); const listed = listGuardedToolCases(report); - assert.equal(listed.split("\n").filter(Boolean).length, 14); + assert.equal(listed.split("\n").filter(Boolean).length, 15); assert.match(listed, /^host_allow\tdispatched$/m); + assert.match(listed, /^mapper_mutated\tpre-plan error$/m); assert.match(listed, /^approval_hook_error\tblocked$/m); assert.match(listed, /^approval_declined\tblocked$/m); assert.match(listed, /^approval_reused\tdispatched -> blocked$/m);