Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions pilots/action-boundary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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
Expand Down
56 changes: 49 additions & 7 deletions pilots/action-boundary/file-revocation-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 };
Expand All @@ -242,13 +273,16 @@ 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) {
decision = denied("indeterminate", "invalid_grant_record");
} 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");
Expand Down Expand Up @@ -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 };
}
26 changes: 26 additions & 0 deletions pilots/action-boundary/revocable-mail-worker.mjs
Original file line number Diff line number Diff line change
@@ -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");
Loading
Loading