From f0827c8f487092d94d20cc928fd4e11ec50f6ce2 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: Sun, 18 Oct 2026 21:51:28 -0700 Subject: [PATCH] Claim host mail operations once across local workers --- README.md | 2 + pilots/action-boundary/README.md | 2 + .../REVOCABLE_PREPARED_MAIL.md | 9 +-- .../file-revocation-handoff.mjs | 56 ++++++++++++++++--- .../action-boundary/revocable-mail-worker.mjs | 26 +++++++++ .../revocable-prepared-mail-tool.mjs | 16 ++++-- ...n-boundary-file-revocation-handoff.test.js | 49 +++++++++++++++- ...n-boundary-revocable-prepared-mail.test.js | 45 ++++++++++++++- 8 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 pilots/action-boundary/revocable-mail-worker.mjs diff --git a/README.md b/README.md index 74d0ab3f..a44fc694 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ **Prepared mail with revocation:** The [opt-in host adapter](pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md) combines the prepared To/CC/BCC request, exact policy, authenticated host approval hook, and file-backed authority. It observes the issuer epoch before awaiting approval and conditionally issues a grant afterward, so a separate worker's revocation during approval or just before callback invocation blocks the simulated send. The host must supply the real approval and provider callback; the tests do not send mail. +**One intended send across retries:** That adapter also requires a stable host operation ID. The local authority records it before granting the send, preventing two workers or repeated approvals from invoking a second callback for the same intent. A different ID permits a genuinely new send. [The integration guide](pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md) covers lost-send reconciliation and the trusted-store limits. + **Design your own boundary:** [Open the Action Boundary Studio](https://aruintelligence.github.io/aml-core/action-boundary-studio.html). Edit the proposed action **and** the host policy, inspect the live plan and canonical digest, then download a JSON calculation bundle. `node scripts/verify-action-studio-bundle.mjs examples/action-studio/changed-destination.json` rechecks a published example against the policy included in that bundle; the [guide](pilots/action-boundary/ACTION_STUDIO.md) explains the limits. The verifier checks consistency, not policy ownership or a real approval. [![Action Boundary Studio: editable proposal and policy produce a visible exact-rule decision and portable calculation bundle.](docs/action-studio-preview.svg)](https://aruintelligence.github.io/aml-core/action-boundary-studio.html) diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index eaf9402e..77f7e38c 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -44,6 +44,8 @@ The host must authenticate the approving human or service and check its real pol **Bind approval to a pre-revocation epoch:** [`createRevocablePreparedMailToolExecute`](REVOCABLE_PREPARED_MAIL.md) is an opt-in host adapter for a prepared mail request and this file store. `observeIssuer()` reads the tenant/issuer epoch under the authority lock before the host awaits authenticated approval. After exact digest approval and an unchanged policy check, `issue(..., { expectedEpoch })` conditionally creates the grant under that same lock; a revocation during approval blocks issuance without storing an unapproved grant. Revocation between issuance and callback invocation is checked again by `dispatch()`. The integration tests include cross-process revocation during both windows and an in-memory sender; no live mail provider is involved. +**Bind one host intent across retries:** The adapter now requires `operationIdFor(context)` to return a stable host-generated ID. `issue(..., { operationId })` writes a durable operation marker under the same authority lock **before** the grant, scoped to tenant and issuer. Another worker cannot issue a second grant for that operation, even when a fresh approval returns the same digest. Reusing the ID with a different request or policy blocks as a mismatch; a new intended send must use a new ID and approval. The optional `operationId` on the lower-level `issue()` preserves its older direct-call behavior when omitted. These markers need deliberate retention and operator reconciliation after uncertain or lost sends. The guarantee assumes the local store is preserved; a fresh instance cannot detect an older restored directory snapshot without an external anchor. + This reference is deliberately unavailable after a crashed lock. **Do not automatically remove the lock, replace the authority directory, or issue a replacement grant**: first reconcile the claimed operation against provider records and the persisted outcome, then follow an operator-controlled recovery procedure appropriate to the host. The pinned directory identity detects replacement only during the lifetime of that instance; a new instance cannot recognize an older copy of a host-owned directory without an external state anchor. The module does not supply that anchor, an operator recovery procedure, authenticated approvals, policy evaluation, distributed consensus, network filesystem locking, store tamper resistance against another process with the same OS identity, or protection for tools that bypass the gate. If revocation attempts find the lock busy, the host must retain the pending revocation and retry it; a busy response does not mean revocation took effect. Provider-side revocation-at-handoff and a production multi-machine claim remain unproven. [**Compare JavaScript and Python case by case**](https://aruintelligence.github.io/aml-core/action-cross-runtime.html?case=mutated-while-awaiting). `python_boundary.py` implements the public plan/dispatch subset without importing the JavaScript reference; `challenge-python-adapter.py` exercises it as an external process against all 13 published cases. The checked-in visual report contains actual outputs from both project-maintained code paths, not just expected labels. Run `node scripts/build-action-cross-runtime-report.mjs --check` to regenerate and compare it. A passing internal adapter is not an independently maintained outside witness. diff --git a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md index 486e6d67..c47f5b35 100644 --- a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md +++ b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md @@ -11,6 +11,7 @@ const send = createRevocablePreparedMailToolExecute({ authority, tenant: "tenant-a", issuer: "mail-host", actor: "reviewer-42", audience: "mail-provider", purpose: "Send release notice", + operationIdFor: context => context.hostPersistedOperationId, getPolicy: () => hostCurrentExactPolicy(), prepare: (toolArgs, context) => hostBuildProviderRequest(toolArgs, context), requestApproval: ({ proposal, proposal_sha256 }, context) => @@ -23,19 +24,19 @@ const outcome = await send(modelMailArguments, hostContext); // A host revocation route calls authority.revokeIssuer({ tenant: "tenant-a", issuer: "mail-host" }). ``` -The policy must have one exact `send_message` / `send` rule for the **prepared** To/CC/BCC manifest with `requires_approval: true`. The approval callback must authenticate its approver and return `{ approved: true, proposal_sha256 }` for the shown proposal. The digest includes the prepared subject and body. The host must construct its policy and identifiers independently of tool arguments. `execute` receives only the frozen prepared request and a stable idempotency key; it must submit exactly those fields and begin the actual provider handoff synchronously. A callback that merely schedules an asynchronous send reopens a revocation window. +The policy must have one exact `send_message` / `send` rule for the **prepared** To/CC/BCC manifest with `requires_approval: true`. The approval callback must authenticate its approver and return `{ approved: true, proposal_sha256 }` for the shown proposal. The digest includes the prepared subject and body. The host must construct its policy and identifiers independently of tool arguments. `operationIdFor` must synchronously return a stable, host-generated ID for this intended send; persist it across worker restarts and retries. Never derive the ID from the model's tool arguments or the proposal digest. A genuinely distinct send of the same content needs a distinct ID and a new approval. `execute` receives only the frozen prepared request and a stable idempotency key; it must submit exactly those fields and begin the actual provider handoff synchronously. A callback that merely schedules an asynchronous send reopens a revocation window. | Boundary step | Fail-closed behavior | | --- | --- | | Snapshot tool arguments; prepare and validate five-field request | Unknown delivery fields or invalid recipients throw before approval or callback. | | Evaluate exact policy; observe issuer epoch | Missing/ambiguous rule or unavailable authority blocks before approval. | | Await authenticated approval for the prepared proposal | Missing or mismatched digest blocks. No grant has been issued yet. | -| Re-read policy; issue grant only at observed epoch | Policy change or revocation during approval blocks; a newer epoch cannot silently reauthorize the old approval. | +| Re-read policy; issue grant only at observed epoch and for an unspent operation ID | Policy change or revocation during approval blocks. A second attempt with the same ID cannot mint a new grant, even with another matching approval. | | Locked claim and synchronous callback invocation | Revocation after issuance and before claim blocks. A replay blocks. A callback error remains an unknown, spent attempt. | -The returned `receipt.execution_status` is `blocked`, `dispatched`, or `unknown`, with `dispatch_count` from the authority. `dispatched` means the host callback returned, **not** provider acceptance or delivery. `unknown` must not trigger an automatic retry. An approval can remain pending indefinitely unless the host enforces its own approval timeout; the grant TTL starts at conditional issuance. A fresh action begun after revocation may be approved under the newer epoch because `revokeIssuer` invalidates old work, rather than suspending an issuer. Every policy revocation that must invalidate pending approvals must also advance the same issuer epoch. +The returned `receipt.execution_status` is `blocked`, `dispatched`, or `unknown`, with `dispatch_count` from the authority. `dispatched` means the host callback returned, **not** provider acceptance or delivery. `unknown` must not trigger an automatic retry. A repeated ID blocks with `operation_already_claimed`; a changed prepared request or policy under that ID blocks with `operation_payload_mismatch`. The operation is spent **before** its grant is written. An error or crash in that gap can lose a send, so investigate the provider and local records before authorizing a genuinely new operation ID. The operation record does not expire with the short-lived grant and must not be automatically deleted. An approval can remain pending indefinitely unless the host enforces its own approval timeout; the grant TTL starts at conditional issuance. A fresh action begun after revocation may be approved under the newer epoch because `revokeIssuer` invalidates old work, rather than suspending an issuer. Every policy revocation that must invalidate pending approvals must also advance the same issuer epoch. -The adapter does not authenticate a human, assess policy meaning, inspect the provider's actual routing, prevent another code path from sending, or coordinate machines. Use a private trusted local filesystem shared by all participating workers; the file store intentionally strands a crashed lock for operator reconciliation. See [the file authority contract](README.md#serialize-multiple-local-workers) for its storage and crash limits. The included tests use an in-memory sender and a second Node process for revocation, not a live provider or outside audit. +The adapter does not authenticate a human, assess policy meaning, inspect the provider's actual routing, prevent another code path from sending, or coordinate machines. Use a private trusted local filesystem shared by all participating workers; the file store intentionally strands a crashed lock for operator reconciliation. Its one-operation property also depends on preserving that store: a restored older copy can replay an operation after a fresh process starts. Plan retention and an external state anchor before production use. See [the file authority contract](README.md#serialize-multiple-local-workers) for its storage and crash limits. The included tests use an in-memory sender and separate Node processes for revocation and duplicate operation claims, not a live provider or outside audit. ```sh node --test test/action-boundary-revocable-prepared-mail.test.js test/action-boundary-file-revocation-handoff.test.js diff --git a/pilots/action-boundary/file-revocation-handoff.mjs b/pilots/action-boundary/file-revocation-handoff.mjs index f80fdbdd..e54131e5 100644 --- a/pilots/action-boundary/file-revocation-handoff.mjs +++ b/pilots/action-boundary/file-revocation-handoff.mjs @@ -117,18 +117,21 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() privateDirectory(directory, true); const epochs = path.join(directory, "epochs"); const grants = path.join(directory, "grants"); + const operations = path.join(directory, "operations"); const claimed = path.join(directory, "claimed"); const outcomes = path.join(directory, "outcomes"); const lock = path.join(directory, "authority.lock"); const clockFile = path.join(directory, "clock.json"); - for (const child of [epochs, grants, claimed, outcomes]) privateDirectory(child, true); + for (const child of [epochs, grants, operations, claimed, outcomes]) privateDirectory(child, true); const pinned = new Map(); - for (const child of [directory, epochs, grants, claimed, outcomes]) { + for (const child of [directory, epochs, grants, operations, claimed, outcomes]) { verifyAuthorityPath(child, { record: pinned }); } function checkDirectories() { - for (const child of [directory, epochs, grants, claimed, outcomes]) privateDirectory(child, false, pinned); + for (const child of [directory, epochs, grants, operations, claimed, outcomes]) { + privateDirectory(child, false, pinned); + } } function acquire() { @@ -201,25 +204,53 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() return locked(() => ({ epoch: currentEpoch(issuerHash(tenant, issuer)) })); } - function issue(input, { ttlMs = 60_000, expectedEpoch } = {}) { + function issue(input, { ttlMs = 60_000, expectedEpoch, operationId } = {}) { if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > 300_000) { throw new TypeError("TTL must be between 1 ms and five minutes"); } if (expectedEpoch !== undefined && (!Number.isSafeInteger(expectedEpoch) || expectedEpoch < 0)) { throw new TypeError("Invalid expected issuer epoch"); } + if (operationId !== undefined && !identity(operationId)) { + throw new TypeError("Invalid host operation ID"); + } const bound = binding(input); return locked(() => { - const epoch = currentEpoch(issuerHash(bound.context.tenant, bound.context.issuer)); + const issuer = issuerHash(bound.context.tenant, bound.context.issuer); + const epoch = currentEpoch(issuer); if (expectedEpoch !== undefined && epoch !== expectedEpoch) throw Error("issuer_revoked"); const issuedAt = hostTime(); if (!Number.isSafeInteger(issuedAt + ttlMs)) throw Error("host_clock_unavailable"); + const operationHash = operationId === undefined ? null : hash(canonicalJSONStringify([ + bound.context.tenant, bound.context.issuer, operationId + ])); + if (operationHash) { + const previous = readRecord(path.join(operations, `${operationHash}.json`)); + if (previous) { + if (previous.protocol === "aml-file-authority-operation/1" && + previous.operation_sha256 === operationHash && + DIGEST.test(previous.binding_sha256) && previous.binding_sha256 !== bound.sha256) { + throw Error("operation_payload_mismatch"); + } + throw Error("operation_already_claimed"); + } + } const grantId = randomUUID(); + if (operationHash) { + // Spend the host intent before the grant. A crash here can lose an + // action, but it cannot send the same operation twice on this store. + createExclusive(path.join(operations, `${operationHash}.json`), { + protocol: "aml-file-authority-operation/1", operation_sha256: operationHash, + grant_id: grantId, issuer_sha256: issuer, binding_sha256: bound.sha256, + epoch + }, operations); + } createExclusive(path.join(grants, `${grantId}.json`), { protocol: "aml-file-authority-grant/1", grant_id: grantId, - issuer_sha256: issuerHash(bound.context.tenant, bound.context.issuer), + issuer_sha256: issuer, binding_sha256: bound.sha256, epoch, issued_at_ms: issuedAt, - expires_at_ms: issuedAt + ttlMs + expires_at_ms: issuedAt + ttlMs, + ...(operationHash ? { operation_sha256: operationHash } : {}) }, grants); return { grant_id: grantId, binding_sha256: bound.sha256, prepared_request_sha256: bound.request.sha256, policy_sha256: bound.policy.sha256 }; @@ -242,6 +273,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() if (!record) decision = denied("blocked", "grant_missing"); else if (record.protocol !== "aml-file-authority-grant/1" || record.grant_id !== grant_id || !DIGEST.test(record.binding_sha256) || !DIGEST.test(record.issuer_sha256) || + (record.operation_sha256 !== undefined && !DIGEST.test(record.operation_sha256)) || !Number.isSafeInteger(record.epoch) || record.epoch < 0 || !Number.isSafeInteger(record.issued_at_ms) || !Number.isSafeInteger(record.expires_at_ms) || record.issued_at_ms >= record.expires_at_ms) { @@ -249,6 +281,8 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() } else if (record.binding_sha256 !== bound.sha256 || record.issuer_sha256 !== issuerHash(bound.context.tenant, bound.context.issuer)) { decision = denied("blocked", "binding_mismatch"); + } else if (record.operation_sha256 && !operationMatches(record)) { + decision = denied("indeterminate", "operation_record_unavailable"); } else { const claimFile = path.join(claimed, `${grant_id}.json`); if (fs.existsSync(claimFile)) decision = denied("blocked", "grant_spent_or_uncertain"); @@ -303,5 +337,13 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() return outcome; } + function operationMatches(record) { + const saved = readRecord(path.join(operations, `${record.operation_sha256}.json`)); + return saved && saved.protocol === "aml-file-authority-operation/1" && + saved.operation_sha256 === record.operation_sha256 && saved.grant_id === record.grant_id && + saved.issuer_sha256 === record.issuer_sha256 && + saved.binding_sha256 === record.binding_sha256 && saved.epoch === record.epoch; + } + return { observeIssuer, issue, revokeIssuer, dispatch }; } diff --git a/pilots/action-boundary/revocable-mail-worker.mjs b/pilots/action-boundary/revocable-mail-worker.mjs new file mode 100644 index 00000000..b8fa1d44 --- /dev/null +++ b/pilots/action-boundary/revocable-mail-worker.mjs @@ -0,0 +1,26 @@ +// Test-only separate host worker. All callbacks append to a local fixture log. +import fs from "node:fs"; +import path from "node:path"; +import { createFileRevocationHandoff } from "./file-revocation-handoff.mjs"; +import { createRevocablePreparedMailToolExecute } from "./revocable-prepared-mail-tool.mjs"; +import { mailTargetResource } from "./mail-target-manifest.mjs"; + +const [directory, operationId] = process.argv.slice(2); +const request = { to: "team@example.test", subject: "Release", body: "Approved text" }; +const policy = { protocol: "aml-action-policy/1", rules: [{ tool: "send_message", + effect: "send", resource: mailTargetResource(request).resource, requires_approval: true }] }; +const send = createRevocablePreparedMailToolExecute({ + authority: createFileRevocationHandoff({ directory }), + tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", audience: "mail-service", + purpose: "Send release", operationIdFor: () => operationId, + getPolicy: () => policy, prepare: args => args, + requestApproval: async ({ proposal_sha256 }) => ({ approved: true, proposal_sha256 }), + execute: () => { + fs.appendFileSync(path.join(directory, "effects.log"), "send\n"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 80); + return { simulated: true }; + } +}); +const result = await send(request); +process.stdout.write(JSON.stringify({ status: result.receipt.execution_status, + reason: result.receipt.reason, dispatch_count: result.dispatch_count }) + "\n"); diff --git a/pilots/action-boundary/revocable-prepared-mail-tool.mjs b/pilots/action-boundary/revocable-prepared-mail-tool.mjs index f647f22f..642c4021 100644 --- a/pilots/action-boundary/revocable-prepared-mail-tool.mjs +++ b/pilots/action-boundary/revocable-prepared-mail-tool.mjs @@ -9,10 +9,11 @@ import { mailTargetResource } from "./mail-target-manifest.mjs"; const snapshot = value => JSON.parse(canonicalJSONStringify(value)); export function createRevocablePreparedMailToolExecute({ authority, tenant, issuer, - actor, audience, purpose, getPolicy, prepare, requestApproval, execute, + actor, audience, purpose, operationIdFor, getPolicy, prepare, requestApproval, execute, ttlMs = 60_000 } = {}) { if (![tenant, issuer, actor, audience, purpose].every(validHandoffIdentity) || - typeof getPolicy !== "function" || typeof prepare !== "function" || + typeof operationIdFor !== "function" || typeof getPolicy !== "function" || + typeof prepare !== "function" || typeof requestApproval !== "function" || typeof execute !== "function" || !authority || typeof authority.observeIssuer !== "function" || typeof authority.issue !== "function" || @@ -23,6 +24,10 @@ export function createRevocablePreparedMailToolExecute({ authority, tenant, issu const identity = Object.freeze({ tenant, issuer, actor, audience, tool: "send_message" }); return async function revocablePreparedMailExecute(args, context) { + // Host operation identity must survive process retries. Never derive it + // from the model's request or from its proposal digest. + const operationId = operationIdFor(context); + if (!validHandoffIdentity(operationId)) throw new TypeError("invalid_host_operation_id"); // Validate the untrusted input before preparation, then bind the complete // provider request (including BCC and content) after host preparation. const source = snapshot(args); @@ -70,10 +75,11 @@ export function createRevocablePreparedMailToolExecute({ authority, tenant, issu // Conditional issuance checks the SAME epoch observed before approval. // Revocation during the wait blocks even if a new grant could otherwise // be issued at the newer epoch. - grant = authority.issue(binding, { ttlMs, expectedEpoch: observed.epoch }); + grant = authority.issue(binding, { ttlMs, expectedEpoch: observed.epoch, operationId }); } catch (error) { - return blocked(error?.message === "issuer_revoked" ? "issuer_revoked" : - "authority_issue_unavailable"); + const reason = ["issuer_revoked", "operation_already_claimed", "operation_payload_mismatch"] + .includes(error?.message) ? error.message : "authority_issue_unavailable"; + return blocked(reason); } // There is no await between the final policy read, conditional issuance, diff --git a/test/action-boundary-file-revocation-handoff.test.js b/test/action-boundary-file-revocation-handoff.test.js index ae1660a2..eb5d8810 100644 --- a/test/action-boundary-file-revocation-handoff.test.js +++ b/test/action-boundary-file-revocation-handoff.test.js @@ -68,6 +68,53 @@ test("conditional issuance rejects an epoch observed before cross-process revoca assert.throws(() => host.issue(context(), { expectedEpoch: -1 }), /Invalid expected issuer epoch/); })); +test("one host operation cannot mint a second grant, but a new intent can", () => + withAsyncDirectory(async directory => { + const host = createFileRevocationHandoff({ directory }); + const first = host.issue(context(), { operationId: "host-intent-001" }); + assert.ok(first.grant_id); + assert.throws(() => host.issue(context(), { operationId: "host-intent-001" }), + /operation_already_claimed/); + assert.throws(() => host.issue({ ...context(), preparedRequest: { + ...context().preparedRequest, body: "changed" + } }, { operationId: "host-intent-001" }), /operation_payload_mismatch/); + const second = host.issue(context(), { operationId: "host-intent-002" }); + assert.notEqual(second.grant_id, first.grant_id); + assert.equal(fs.readdirSync(path.join(directory, "operations")).length, 2); + const anotherTenant = host.issue({ ...context(), tenant: "tenant-b" }, + { operationId: "host-intent-001" }); + assert.ok(anotherTenant.grant_id); + assert.throws(() => host.issue(context(), { operationId: "host-intent-001 " }), + /Invalid host operation ID/); + })); + +test("an operation marker survives a missing grant and suppresses a fresh instance", () => + withAsyncDirectory(async directory => { + const original = createFileRevocationHandoff({ directory }); + const grant = original.issue(context(), { operationId: "host-intent-orphan-004" }); + fs.rmSync(path.join(directory, "grants", `${grant.grant_id}.json`)); + const restarted = createFileRevocationHandoff({ directory }); + assert.throws(() => restarted.issue(context(), { operationId: "host-intent-orphan-004" }), + /operation_already_claimed/); + let calls = 0; + const outcome = await restarted.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([outcome.status, outcome.dispatch_count, calls], ["blocked", 0, 0]); + })); + +test("an unavailable operation record suppresses a grant before callback", () => + withAsyncDirectory(async directory => { + const host = createFileRevocationHandoff({ directory }); + const grant = host.issue(context(), { operationId: "host-intent-003" }); + fs.rmSync(path.join(directory, "operations", fs.readdirSync(path.join(directory, + "operations"))[0])); + let calls = 0; + const result = await host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([result.status, result.reason, result.dispatch_count, calls], + ["indeterminate", "operation_record_unavailable", 0, 0]); + })); + test("one changed BCC, tenant, or policy cannot reuse the approved request", () => withAsyncDirectory(async directory => { const host = createFileRevocationHandoff({ directory }); @@ -209,7 +256,7 @@ test("a replaced private root cannot replay a spent grant in the same authority" const snapshot = path.join(parent, "snapshot"); fs.cpSync(directory, snapshot, { recursive: true }); fs.chmodSync(snapshot, 0o700); - for (const child of ["epochs", "grants", "claimed", "outcomes"]) { + for (const child of ["epochs", "grants", "operations", "claimed", "outcomes"]) { fs.chmodSync(path.join(snapshot, child), 0o700); } fs.chmodSync(path.join(snapshot, "clock.json"), 0o600); diff --git a/test/action-boundary-revocable-prepared-mail.test.js b/test/action-boundary-revocable-prepared-mail.test.js index d8943aa2..88ad2254 100644 --- a/test/action-boundary-revocable-prepared-mail.test.js +++ b/test/action-boundary-revocable-prepared-mail.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +10,8 @@ import { createRevocablePreparedMailToolExecute } from "../pilots/action-boundar import { mailTargetResource } from "../pilots/action-boundary/mail-target-manifest.mjs"; const worker = new URL("../pilots/action-boundary/file-revocation-worker.mjs", import.meta.url).pathname; +const sendWorker = new URL("../pilots/action-boundary/revocable-mail-worker.mjs", import.meta.url).pathname; +const exec = promisify(execFile); const input = () => ({ to: "team@example.test", subject: "Release", body: "Approved text" }); const policyFor = request => ({ protocol: "aml-action-policy/1", rules: [{ tool: "send_message", effect: "send", resource: mailTargetResource(request).resource, @@ -18,7 +21,8 @@ const approved = async ({ proposal_sha256 }) => ({ approved: true, proposal_sha2 const make = (authority, options = {}) => createRevocablePreparedMailToolExecute({ authority, tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", audience: "mail-service", purpose: "Send release", getPolicy: () => policyFor(input()), - prepare: async request => request, requestApproval: approved, execute: () => true, + operationIdFor: () => "host-intent-mail-001", prepare: async request => request, + requestApproval: approved, execute: () => true, ...options }); async function withDirectory(run) { @@ -45,6 +49,37 @@ test("approved prepared request reaches one callback with a stable key and froze assert.match(observed.idempotencyKey, /^[a-f0-9]{64}$/); })); +test("one approval response cannot send the same host operation twice", () => + withDirectory(async (directory, authority) => { + let sends = 0, operationId = "host-intent-mail-004"; + const send = make(authority, { operationIdFor: () => operationId, + execute: () => { sends++; return { simulated: true }; } }); + assert.equal((await send(input())).receipt.execution_status, "dispatched"); + const repeated = await send(input()); + assert.deepEqual([repeated.receipt.reason, repeated.dispatch_count, sends], + ["operation_already_claimed", 0, 1]); + const changed = await send({ ...input(), body: "different text" }); + assert.deepEqual([changed.receipt.reason, changed.dispatch_count, sends], + ["operation_payload_mismatch", 0, 1]); + operationId = "host-intent-mail-005"; + assert.equal((await send(input())).receipt.execution_status, "dispatched"); + assert.equal(sends, 2); + assert.equal(fs.readdirSync(path.join(directory, "operations")).length, 2); + })); + +test("two real workers competing for one intent produce at most one send", () => + withDirectory(async directory => { + const results = await Promise.all([0, 1].map(() => exec(process.execPath, + [sendWorker, directory, "host-intent-shared-006"]))); + const rows = results.map(result => JSON.parse(result.stdout)); + assert.equal(rows.filter(row => row.dispatch_count === 1).length, 1); + assert.equal(rows.filter(row => row.dispatch_count === 0).length, 1); + assert.equal(fs.readFileSync(path.join(directory, "effects.log"), "utf8"), "send\n"); + const again = JSON.parse((await exec(process.execPath, + [sendWorker, directory, "host-intent-shared-006"])).stdout); + assert.deepEqual([again.reason, again.dispatch_count], ["operation_already_claimed", 0]); + })); + test("host-added BCC without an exact rule cannot be approved or sent", () => withDirectory(async (_, authority) => { let approvals = 0, sends = 0; @@ -139,10 +174,14 @@ test("expiry, denial, invalid preparation, and callback uncertainty fail closed" execute: () => { sends++; } }); await assert.rejects(invalid(input()), /unsupported_mail_arguments/); time = 1011; - const uncertain = make(authority, { execute: () => { sends++; throw Error("timeout"); } }); + const uncertain = make(authority, { operationIdFor: () => "host-intent-uncertain-002", + execute: () => { sends++; throw Error("timeout"); } }); const outcome = await uncertain(input()); assert.deepEqual([outcome.receipt.execution_status, outcome.dispatch_count, sends], ["unknown", 1, 1]); + const retry = await uncertain(input()); + assert.deepEqual([retry.receipt.reason, retry.dispatch_count, sends], + ["operation_already_claimed", 0, 1]); })); test("a malformed authority observation or outcome cannot report a successful send", () =>