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
14 changes: 13 additions & 1 deletion pilots/action-boundary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,23 @@ 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. When a grant has a host `operationId`, the claim key is the stable SHA-256 operation hash for tenant, issuer, and that ID, so an older local snapshot cannot send the same host intent with a newly minted grant. For a direct grant without an operation ID, the key is the SHA-256 idempotency key for tenant and grant ID. The binding digest identifies the exact prepared 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 operation marker, 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.

**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.
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 or host operation* only while its independently held claims remain durable and unchanged and the host retains its operation ID. 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.

Expand Down
4 changes: 2 additions & 2 deletions pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,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 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. |
| 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. 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. 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.
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 depends on preserving that store unless the host supplies 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). That hook uses the stable host operation key to reject a new grant for a spent intent after local rollback. 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 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
49 changes: 42 additions & 7 deletions pilots/action-boundary/file-revocation-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -298,10 +299,44 @@ 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",
// An operation-backed grant keeps the same host intent key
// even if a restored local snapshot mints a different grant.
claim_key_sha256: record.operation_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();
if (record.operation_sha256 && !operationMatches(record)) {
decision = denied("indeterminate", "operation_record_unavailable");
} else {
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; }
}
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions pilots/action-boundary/file-revocation-worker.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
6 changes: 3 additions & 3 deletions pilots/action-boundary/revocable-prepared-mail-tool.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ export function createRevocablePreparedMailToolExecute({ authority, tenant, issu
return blocked(reason);
}

// 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,
Expand Down
29 changes: 29 additions & 0 deletions pilots/action-boundary/testing-claim-anchor.mjs
Original file line number Diff line number Diff line change
@@ -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;
};
}
Loading
Loading