From 7dc55c492fb8de6f7e2533000802149e2f6b2147 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:37 -0700 Subject: [PATCH 1/2] Anchor one-use action claims outside local authority state --- pilots/action-boundary/README.md | 14 +- .../REVOCABLE_PREPARED_MAIL.md | 4 +- .../file-revocation-handoff.mjs | 43 +++++- .../file-revocation-worker.mjs | 6 +- .../revocable-prepared-mail-tool.mjs | 6 +- .../action-boundary/testing-claim-anchor.mjs | 29 ++++ ...n-boundary-file-revocation-handoff.test.js | 130 ++++++++++++++++++ 7 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 pilots/action-boundary/testing-claim-anchor.mjs diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index eaf9402e..6d60ae62 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -42,9 +42,21 @@ The local guarantee ends at callback invocation. An asynchronous callback can wa The host must authenticate the approving human or service and check its real policy **before** calling `issue()`. It must build the exact provider request before approval, then pass the same trusted context and prepared request to `dispatch()`. Tenant, issuer, actor, audience, and tool must be stable canonical IDs; leading/trailing whitespace, control characters, and non-NFC forms are rejected, but the host still owns identity normalization and authentication. A caller-supplied `now` is ignored. The callback receives a frozen copy of that request and a stable idempotency key, but the host must use its provider's idempotency contract and verify that the callback starts the actual handoff before yielding. A successful `returned` result means only that the callback returned. A thrown or rejected callback is `outcome_unknown`; no automatic retry is permitted. +**Anchor a one-use claim outside the local authority store:** An optional host-owned `claimOnce` hook can preserve a spent grant across an older copy of the authority directory and a process restart. Pass it to `createFileRevocationHandoff({ directory, claimOnce })`. It receives a frozen `{ protocol: "aml-file-authority-external-claim/1", claim_key_sha256, binding_sha256 }` record. The claim key is the stable SHA-256 idempotency key for the tenant and grant ID; the binding digest identifies the exact approved request and host context. The host must implement an atomic, durable, linearizable insert-if-absent keyed by `claim_key_sha256`, retain it independently of authority-directory rollback, and return **exactly `true` only after a new claim is committed**, `false` when the key already exists, or throw on uncertainty. A repeated key with a different binding must never return `true`. For example, a host may adapt a trusted transactional store: + +```js +const authority = createFileRevocationHandoff({ + directory: privateLocalDirectory, + claimOnce: ({ claim_key_sha256, binding_sha256 }) => + trustedStore.insertIfAbsent(claim_key_sha256, binding_sha256) +}); +``` + +The local spent marker is synced **before** `claimOnce` runs. The local authority lock remains held while an asynchronous claim is pending, and the host clock, grant expiry, and directory identity are checked again before the callback. A `false` result blocks with `external_claim_spent`; a throw, rejection, or nonboolean result is `external_claim_unavailable` and indeterminate. Neither case invokes the callback; both leave the local grant spent. A busy revocation must be retained and retried. A claim that hangs leaves the lock busy; after a crash, reconcile it against the external store and provider before recovery. [`testing-claim-anchor.mjs`](testing-claim-anchor.mjs) uses a separate local file directory only to reproduce a rollback across processes in tests; it is not a production anchor or protection against same-user tampering. + **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. -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. +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, the optional external claim, 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. The optional hook protects a *spent grant* only while its independently held claims remain durable and unchanged. It does **not** detect rollback of issuer revocation epochs for *unspent* grants, protect a replaced or rolled-back external store, or make the local store tamper resistant. The module does not supply a production external store, 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..03b4bdd8 100644 --- a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md +++ b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md @@ -31,11 +31,11 @@ The policy must have one exact `send_message` / `send` rule for the **prepared** | 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. | -| Locked claim and synchronous callback invocation | Revocation after issuance and before claim blocks. A replay blocks. A callback error remains an unknown, spent attempt. | +| Locked claim and synchronous callback invocation | Revocation after issuance and before claim blocks. A replay blocks. A callback error remains an unknown, spent attempt. An optional external claim must commit before callback invocation. | 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 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. For rollback defense on spent grants, the host can provide the authority an independently held atomic `claimOnce` hook, as described in [the file authority contract](README.md#anchor-a-one-use-claim-outside-the-local-authority-store). An asynchronous claim keeps the local lock busy and rechecks expiry before the callback; an uncertain claim consumes the local grant and never sends. The hook does not preserve issuer revocations for unspent grants after a store rollback. The included tests use an in-memory sender and a second Node process for revocation, 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..56c3556b 100644 --- a/pilots/action-boundary/file-revocation-handoff.mjs +++ b/pilots/action-boundary/file-revocation-handoff.mjs @@ -109,10 +109,11 @@ function freezeDeep(value) { return value; } -export function createFileRevocationHandoff({ directory, now = () => Date.now() } = {}) { +export function createFileRevocationHandoff({ directory, now = () => Date.now(), claimOnce = null } = {}) { if (typeof directory !== "string" || !path.isAbsolute(directory) || - directory !== path.resolve(directory) || typeof now !== "function") { - throw new TypeError("A trusted absolute directory and host clock are required"); + directory !== path.resolve(directory) || typeof now !== "function" || + (claimOnce !== null && typeof claimOnce !== "function")) { + throw new TypeError("A trusted absolute directory, host clock, and optional atomic claim are required"); } privateDirectory(directory, true); const epochs = path.join(directory, "epochs"); @@ -264,10 +265,38 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now() createExclusive(claimFile, { protocol: "aml-file-authority-claim/1", grant_id, binding_sha256: bound.sha256, idempotency_key_sha256: idempotencyKey }, claimed); - try { - invoked = true; - response = execute(freezeDeep(JSON.parse(bound.request.bytes)), { idempotencyKey }); - } catch { callbackThrew = true; } + if (claimOnce) { + // Keep the local lock across an optional asynchronous CAS. The + // host anchor must commit before returning exactly true; a + // restored local snapshot cannot erase that separate claim. + try { + const result = await claimOnce(Object.freeze({ + protocol: "aml-file-authority-external-claim/1", + claim_key_sha256: idempotencyKey, + binding_sha256: bound.sha256 + })); + if (result === false) decision = denied("blocked", "external_claim_spent"); + else if (result !== true) decision = denied("indeterminate", "external_claim_unavailable"); + } catch { decision = denied("indeterminate", "external_claim_unavailable"); } + if (!decision) { + // An awaited host claim can outlive the grant or allow an + // authority path replacement. Recheck immediately before the + // callback, still under the lock and with the claim spent. + checkDirectories(); + const postClaimTime = hostTime(); + if (postClaimTime < record.issued_at_ms || postClaimTime >= record.expires_at_ms) { + decision = denied("blocked", "grant_expired_or_clock_invalid"); + } else if (currentEpoch(record.issuer_sha256) !== record.epoch) { + decision = denied("blocked", "issuer_revoked"); + } + } + } + if (!decision) { + try { + invoked = true; + response = execute(freezeDeep(JSON.parse(bound.request.bytes)), { idempotencyKey }); + } catch { callbackThrew = true; } + } } } } diff --git a/pilots/action-boundary/file-revocation-worker.mjs b/pilots/action-boundary/file-revocation-worker.mjs index ff41a0af..0196cd18 100644 --- a/pilots/action-boundary/file-revocation-worker.mjs +++ b/pilots/action-boundary/file-revocation-worker.mjs @@ -1,14 +1,16 @@ import fs from "node:fs"; import path from "node:path"; import { createFileRevocationHandoff } from "./file-revocation-handoff.mjs"; +import { createTestingClaimOnce } from "./testing-claim-anchor.mjs"; -const [directory, grant_id, mode = "normal"] = process.argv.slice(2); +const [directory, grant_id, mode = "normal", anchorDirectory] = process.argv.slice(2); const input = { tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", audience: "mail-service", tool: "send_mail", policy: { allowed: ["send_mail"], revision: 1 }, preparedRequest: { to: ["team@example.test"], bcc: [], subject: "Update", body: "Approved" } }; -const host = createFileRevocationHandoff({ directory }); +const host = createFileRevocationHandoff({ directory, + ...(mode === "anchored" ? { claimOnce: createTestingClaimOnce(anchorDirectory) } : {}) }); if (mode === "revoke") { console.log(JSON.stringify(host.revokeIssuer({ tenant: input.tenant, issuer: input.issuer }))); process.exit(0); diff --git a/pilots/action-boundary/revocable-prepared-mail-tool.mjs b/pilots/action-boundary/revocable-prepared-mail-tool.mjs index f647f22f..4cc7feb7 100644 --- a/pilots/action-boundary/revocable-prepared-mail-tool.mjs +++ b/pilots/action-boundary/revocable-prepared-mail-tool.mjs @@ -76,9 +76,9 @@ export function createRevocablePreparedMailToolExecute({ authority, tenant, issu "authority_issue_unavailable"); } - // There is no await between the final policy read, conditional issuance, - // and the authority's synchronous locked claim + callback invocation. - // The callback must begin the provider handoff synchronously. + // Conditional issuance and dispatch start without another await. An + // optional external claim may await while the authority lock is held; + // the callback must begin the provider handoff as soon as that claim wins. let outcome; try { outcome = await authority.dispatch({ ...binding, grant_id: grant.grant_id, diff --git a/pilots/action-boundary/testing-claim-anchor.mjs b/pilots/action-boundary/testing-claim-anchor.mjs new file mode 100644 index 00000000..468e27e9 --- /dev/null +++ b/pilots/action-boundary/testing-claim-anchor.mjs @@ -0,0 +1,29 @@ +// Test fixture only. A separate local directory demonstrates an atomic claim +// surviving an authority-store rollback; it is not a production trust service. +import fs from "node:fs"; +import path from "node:path"; + +const DIGEST = /^[0-9a-f]{64}$/; + +export function createTestingClaimOnce(directory) { + return ({ protocol, claim_key_sha256, binding_sha256 }) => { + if (protocol !== "aml-file-authority-external-claim/1" || + !DIGEST.test(claim_key_sha256) || !DIGEST.test(binding_sha256)) { + throw Error("invalid_external_claim"); + } + const file = path.join(directory, `${claim_key_sha256}.json`); + let fd; + try { fd = fs.openSync(file, "wx", 0o600); } + catch (error) { + if (error.code === "EEXIST") return false; + throw error; + } + try { + fs.writeFileSync(fd, JSON.stringify({ claim_key_sha256, binding_sha256 }) + "\n"); + fs.fsyncSync(fd); + } finally { fs.closeSync(fd); } + const directoryFd = fs.openSync(directory, "r"); + try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); } + return true; + }; +} diff --git a/test/action-boundary-file-revocation-handoff.test.js b/test/action-boundary-file-revocation-handoff.test.js index ae1660a2..1ffba7d0 100644 --- a/test/action-boundary-file-revocation-handoff.test.js +++ b/test/action-boundary-file-revocation-handoff.test.js @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createFileRevocationHandoff } from "../pilots/action-boundary/file-revocation-handoff.mjs"; +import { createTestingClaimOnce } from "../pilots/action-boundary/testing-claim-anchor.mjs"; const exec = promisify(execFile); const worker = new URL("../pilots/action-boundary/file-revocation-worker.mjs", import.meta.url).pathname; @@ -244,3 +245,132 @@ test("a world-writable nonsticky parent cannot host the authority", () => }), /trusted_authority_path_required/); } finally { fs.chmodSync(parent, 0o700); } })); + +test("a separately held atomic claim blocks replay after store rollback and process restart", () => + withAsyncDirectory(async parent => { + const directory = path.join(parent, "authority"); + const anchor = path.join(parent, "held-claims"); + fs.mkdirSync(anchor, { mode: 0o700 }); + const host = createFileRevocationHandoff({ directory, + claimOnce: createTestingClaimOnce(anchor) }); + const grant = host.issue(context()); + const snapshot = path.join(parent, "snapshot"); + fs.cpSync(directory, snapshot, { recursive: true }); + fs.chmodSync(snapshot, 0o700); + for (const child of ["epochs", "grants", "claimed", "outcomes"]) { + fs.chmodSync(path.join(snapshot, child), 0o700); + } + fs.chmodSync(path.join(snapshot, "clock.json"), 0o600); + fs.chmodSync(path.join(snapshot, "grants", `${grant.grant_id}.json`), 0o600); + let calls = 0; + const first = await host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; return "sent"; } }); + assert.equal(first.status, "returned"); + assert.equal(fs.readdirSync(anchor).length, 1); + fs.renameSync(directory, path.join(parent, "spent")); + fs.renameSync(snapshot, directory); + + const restarted = await exec(process.execPath, + [worker, directory, grant.grant_id, "anchored", anchor]); + const result = JSON.parse(restarted.stdout); + assert.deepEqual([result.status, result.reason, result.dispatch_count], + ["blocked", "external_claim_spent", 0]); + assert.equal(calls, 1); + assert.equal(fs.existsSync(path.join(directory, "effects.log")), false); + fs.rmSync(path.join(parent, "spent"), { recursive: true, force: true }); + })); + +test("an uncertain or rejected external claim spends locally and never calls the provider", () => + withAsyncDirectory(async parent => { + let calls = 0; + const outcomes = [ + [() => false, "blocked", "external_claim_spent"], + [() => { throw Error("anchor offline"); }, "indeterminate", "external_claim_unavailable"], + [async () => { throw Error("anchor offline"); }, "indeterminate", "external_claim_unavailable"], + [() => ({ accepted: true }), "indeterminate", "external_claim_unavailable"] + ]; + for (const [claimOnce, status, reason] of outcomes) { + const directory = fs.mkdtempSync(path.join(parent, "authority-")); + const host = createFileRevocationHandoff({ directory, claimOnce }); + const grant = host.issue(context()); + const result = await host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([result.status, result.reason, result.dispatch_count], [status, reason, 0]); + assert.equal(fs.existsSync(path.join(directory, "claimed", `${grant.grant_id}.json`)), true); + const retry = await host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.equal(retry.reason, "grant_spent_or_uncertain"); + } + assert.equal(calls, 0); + })); + +test("async external claim holds the local lock until the provider handoff", () => + withAsyncDirectory(async directory => { + let allowClaim; + const pending = new Promise(resolve => { allowClaim = resolve; }); + let entered; + const waiting = new Promise(resolve => { entered = resolve; }); + let calls = 0; + const host = createFileRevocationHandoff({ directory, claimOnce: async record => { + assert.equal(Object.isFrozen(record), true); + assert.match(record.claim_key_sha256, /^[0-9a-f]{64}$/); + entered(); + return pending; + } }); + const grant = host.issue(context()); + const action = host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; return "sent"; } }); + await waiting; + assert.equal(calls, 0); + assert.throws(() => createFileRevocationHandoff({ directory }).revokeIssuer({ + tenant: "tenant-a", issuer: "host-a" + }), /authority_busy_or_crashed/); + allowClaim(true); + const result = await action; + assert.deepEqual([result.status, result.dispatch_count, calls], ["returned", 1, 1]); + assert.equal(createFileRevocationHandoff({ directory }).revokeIssuer({ + tenant: "tenant-a", issuer: "host-a" + }).epoch, 1); + })); + +test("grant expiry during an awaited external claim blocks the provider callback", () => + withAsyncDirectory(async directory => { + let time = 1_700_000_000_000; + let resolveClaim; + let entered; + const waiting = new Promise(resolve => { entered = resolve; }); + const host = createFileRevocationHandoff({ directory, now: () => time, + claimOnce: () => { entered(); return new Promise(resolve => { resolveClaim = resolve; }); } }); + const grant = host.issue(context(), { ttlMs: 10 }); + let calls = 0; + const action = host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + await waiting; + time += 10; + resolveClaim(true); + const result = await action; + assert.deepEqual([result.status, result.reason, result.dispatch_count, calls], + ["blocked", "grant_expired_or_clock_invalid", 0, 0]); + assert.equal(fs.existsSync(path.join(directory, "claimed", `${grant.grant_id}.json`)), true); + })); + +test("an authority path replaced during an awaited claim cannot invoke the callback", () => + withAsyncDirectory(async parent => { + const directory = path.join(parent, "authority"); + let resolveClaim; + let entered; + const waiting = new Promise(resolve => { entered = resolve; }); + const host = createFileRevocationHandoff({ directory, + claimOnce: () => { entered(); return new Promise(resolve => { resolveClaim = resolve; }); } }); + const grant = host.issue(context()); + let calls = 0; + const action = host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + await waiting; + fs.renameSync(directory, path.join(parent, "moved-authority")); + fs.mkdirSync(directory, { mode: 0o700 }); + resolveClaim(true); + const result = await action; + assert.deepEqual([result.status, result.dispatch_count, calls], ["indeterminate", 0, 0]); + assert.equal(fs.existsSync(path.join(parent, "moved-authority", "authority.lock")), true); + })); From d536c70413877a53763c28f2e2afd411af35d0f0 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:59:52 -0700 Subject: [PATCH 2/2] Accept fail-closed contention in mail worker race test --- ...n-boundary-revocable-prepared-mail.test.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/action-boundary-revocable-prepared-mail.test.js b/test/action-boundary-revocable-prepared-mail.test.js index 88ad2254..6383a787 100644 --- a/test/action-boundary-revocable-prepared-mail.test.js +++ b/test/action-boundary-revocable-prepared-mail.test.js @@ -72,12 +72,24 @@ test("two real workers competing for one intent produce at most one send", () => 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"); + assert.equal(rows.every(row => row.dispatch_count === 0 || row.dispatch_count === 1), true); + const firstSends = rows.filter(row => row.dispatch_count === 1).length; + assert.ok(firstSends <= 1); + // Both workers may fail closed at the short-lived authority lock before + // either issues a grant. A later attempt can send only if no operation + // marker was written; otherwise the same intent remains spent. + const hadMarker = fs.readdirSync(path.join(directory, "operations")).length > 0; 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]); + assert.ok(again.dispatch_count === 0 || again.dispatch_count === 1); + if (hadMarker) assert.deepEqual([again.reason, again.dispatch_count], + ["operation_already_claimed", 0]); + else assert.equal(again.dispatch_count, 1); + const totalSends = firstSends + again.dispatch_count; + assert.ok(totalSends <= 1); + const effects = fs.existsSync(path.join(directory, "effects.log")) ? + fs.readFileSync(path.join(directory, "effects.log"), "utf8") : ""; + assert.equal(effects, "send\n".repeat(totalSends)); })); test("host-added BCC without an exact rule cannot be approved or sent", () =>