diff --git a/README.md b/README.md index a0ea92fe..c0b69921 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ **Revocation after a local store rollback:** The file authority can optionally use a separate host-owned epoch anchor. Its atomic claim checks that an issuer's held epoch still matches the grant, so an old unspent grant from a restored local snapshot stops before the simulated provider callback. The cross-process test restores the old directory, starts a new worker, and records zero callbacks. [Read the host contract](pilots/action-boundary/README.md#preserve-issuer-revocation-across-local-rollback); the included file anchor is a test fixture, not a production service or multi-machine guarantee. +**Detect an older restored authority:** An [opt-in external checkpoint](pilots/action-boundary/AUTHORITY_CHECKPOINT.md) advances before grant, revocation, and claim records become usable. A fresh child process refuses a restored older authority directory when the separate anchor stayed current. The included file anchor is still one-machine and must be preserved independently; restoring both copies together defeats this check. + **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/AUTHORITY_CHECKPOINT.md b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md new file mode 100644 index 00000000..d67e7013 --- /dev/null +++ b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md @@ -0,0 +1,36 @@ +# Detect an older local authority snapshot + +The file authority can now use an **opt-in checkpoint outside its own directory**. A host-owned synchronous compare-and-swap anchor records a store ID, sequence, and hash-chain head before each grant issuance, issuer revocation, or one-use claim. The authority writes its local checkpoint only after the corresponding local record is durable. A restored older authority directory therefore has a checkpoint that disagrees with the external anchor and fails closed on a fresh instance. + +```js +import { createFileAuthorityAnchor, createFileRevocationHandoff } from + "./file-revocation-handoff.mjs"; + +// Separate from the authority directory and its backup/restore domain. +const anchor = createFileAuthorityAnchor({ directory: trustedIndependentAnchorPath }); +const authority = createFileRevocationHandoff({ + directory: trustedAuthorityPath, anchor +}); +``` + +The file anchor is a **one-machine example**. It uses an owner-only directory, an exclusive local lock, and synced atomic file replacement. A host can supply its own `{ read, compareAndSwap }` object with the same synchronous, durable, strongly consistent behavior. Every participating worker must use the same anchor and authority directory. Anchored mode starts only with an empty authority; it will not invent a trustworthy history for an existing unanchored store. An anchored store rejects callers that omit the anchor. + +The separately supported `claimOnce` hook can be configured at the same time. It guards the spent host operation with an atomic external insert, while this checkpoint also records issuance and revocation transitions. Both configured mechanisms must permit dispatch before its callback runs. A checkpoint mismatch after an awaited external claim stops the callback and requires reconciliation. + +The separate `epochAnchor` option uses an epoch-aware external claim at handoff and an asynchronous revocation protocol. It cannot be configured alongside this checkpoint; choose one revocation protection for an authority directory. Unlike the checkpoint, `epochAnchor` can reject a selectively restored local issuer epoch at claim time. The checkpoint instead checks whether a whole authority directory is older than its retained outside state before issuing or dispatching. + +| Local operation | Ordering before an external effect | +| --- | --- | +| Issue a grant | Advance anchor, write operation marker and grant, then write local checkpoint. | +| Revoke an issuer | Advance anchor, write new epoch, then write local checkpoint. | +| Claim a grant | Advance anchor, write spent marker, write local checkpoint, then invoke callback synchronously. | + +If the anchor refuses an advance, the local mutation and callback do not run. If the anchor advances but a later local write fails or the process crashes, the two checkpoints disagree and subsequent work stops for operator reconciliation. This favors a missed action over replay. Never automatically copy the outside value into the local checkpoint, remove a crashed lock, or grant a new operation ID to resolve a mismatch. Check the provider's records, local grants and claims, and backup history first. + +The bounded check detects restoration of the **whole authority directory** when its independent anchor remains newer. It does not help if both directories are restored together, if the anchor can be rolled back by the same actor, if a process with the same OS identity selectively changes records, or if a tool bypasses this authority. The local file anchor is not a multi-machine consensus system or a trusted external witness. Its hash chain authenticates neither the provider nor the approving person. A successful callback result still does not prove delivery. Production needs an anchor with an independently controlled retention and restore boundary plus a reviewed recovery procedure. + +Run the project-authored child-process and failure tests: + +```sh +node --test test/action-boundary-file-authority-anchor.test.js +``` diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index 56c5e3ec..9d9298b7 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -71,9 +71,13 @@ The host must keep that store independent of the local authority directory and r **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. +**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 local operation marker alone assumes the store is preserved; a fresh instance needs an independently retained external claim or checkpoint to withstand an older restored directory. -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 state, 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 spent-only `claimOnce` hook protects a spent grant or host operation while its independently held claims remain durable and the host retains its operation ID; only the stronger `epochAnchor` also rejects an old unspent grant after local epoch rollback. Neither option protects a replaced or rolled-back external store or makes 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. +**Detect a restored local snapshot:** [The opt-in checkpoint](AUTHORITY_CHECKPOINT.md) records issue, revoke, and claim transitions in a separate host-controlled anchor before their local records become usable. A child process with an older restored authority directory and a newer outside anchor blocks before its simulated provider callback. The bundled `createFileAuthorityAnchor` is a separate local file example, not an independently operated service; restoring both directories together still defeats this check. Anchored and unanchored workers cannot safely share one authority. + +The checkpoint and `epochAnchor` are alternative revocation protections and cannot be configured together. The checkpoint detects a restored authority directory before issue or dispatch if its separately retained state is newer. The epoch-aware claim checks issuer revocation at dispatch, including when an old local epoch file survives or is selectively restored. The checkpoint can be combined with spent-only `claimOnce`. + +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, any external state, 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 spent-only `claimOnce` hook protects a spent grant or host operation while its independently held claims remain durable and the host retains its operation ID. The epoch-aware claim additionally rejects an old unspent grant after local epoch rollback; the checkpoint detects a restored older authority directory while its external state stays current. No option protects a replaced or rolled-back external store or makes 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 e4239482..8fca0169 100644 --- a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md +++ b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md @@ -37,7 +37,7 @@ The policy must have one exact `send_message` / `send` rule for the **prepared** 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 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. To also preserve issuer revocation against old *unspent* grants, use the stronger [`epochAnchor` contract](README.md#preserve-issuer-revocation-across-local-rollback) and route every revocation through `await authority.revokeIssuerAnchored(...)`. 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 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), or enables the [independent checkpoint](AUTHORITY_CHECKPOINT.md). The hook uses the stable host operation key to reject a new grant for a spent intent after local rollback. To preserve issuer revocation against old *unspent* grants, use either the [`epochAnchor` contract](README.md#preserve-issuer-revocation-across-local-rollback) and route every revocation through `await authority.revokeIssuerAnchored(...)`, or the checkpoint with its separately retained state; the two options cannot be configured together. 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 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-anchor-worker.mjs b/pilots/action-boundary/file-anchor-worker.mjs new file mode 100644 index 00000000..1dce3ec0 --- /dev/null +++ b/pilots/action-boundary/file-anchor-worker.mjs @@ -0,0 +1,22 @@ +// Test-only child process for a separately stored authority checkpoint. +import fs from "node:fs"; +import path from "node:path"; +import { createFileAuthorityAnchor, createFileRevocationHandoff } from + "./file-revocation-handoff.mjs"; + +const [directory, anchorDirectory, grant_id] = process.argv.slice(2); +const input = { tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", + audience: "mail-service", tool: "send_message", policy: { revision: 1 }, + preparedRequest: { to: "team@example.test", body: "Approved" } }; +try { + const anchor = createFileAuthorityAnchor({ directory: anchorDirectory }); + const host = createFileRevocationHandoff({ directory, anchor }); + const result = await host.dispatch({ ...input, grant_id, execute: () => { + fs.appendFileSync(path.join(anchorDirectory, "effects.log"), "callback\n"); + return true; + } }); + process.stdout.write(JSON.stringify(result) + "\n"); +} catch (error) { + process.stdout.write(JSON.stringify({ status: "blocked", reason: error.message, + dispatch_count: 0 }) + "\n"); +} diff --git a/pilots/action-boundary/file-revocation-handoff.mjs b/pilots/action-boundary/file-revocation-handoff.mjs index 37637c5f..0d599e8e 100644 --- a/pilots/action-boundary/file-revocation-handoff.mjs +++ b/pilots/action-boundary/file-revocation-handoff.mjs @@ -109,15 +109,63 @@ function freezeDeep(value) { return value; } -export function createFileRevocationHandoff({ directory, now = () => Date.now(), +// One-machine example of the host's independent monotonic checkpoint store. +// Keep this directory under a separate backup/retention domain. Restoring +// both directories together can still replay an older authority snapshot. +export function createFileAuthorityAnchor({ directory } = {}) { + if (typeof directory !== "string" || !path.isAbsolute(directory) || + directory !== path.resolve(directory)) throw new TypeError("trusted_anchor_path_required"); + privateDirectory(directory, true); + const file = path.join(directory, "checkpoint.json"); + const lock = path.join(directory, "anchor.lock"); + const pinned = new Map(); + verifyAuthorityPath(directory, { record: pinned }); + const check = () => privateDirectory(directory, false, pinned); + const read = () => { check(); + if (hasUncertainTemp(directory, "checkpoint.json")) throw Error("uncertain_anchor_update"); + return readRecord(file); + }; + function compareAndSwap(expected, next) { + check(); + try { fs.mkdirSync(lock, { mode: 0o700 }); } + catch (error) { + if (error.code === "EEXIST") throw Error("anchor_busy_or_crashed"); + throw error; + } + syncDirectory(directory); + try { + const prior = read(); + if (canonicalJSONStringify(prior) !== canonicalJSONStringify(expected)) return false; + if (!next || next.protocol !== "aml-file-authority-checkpoint/1" || + !ID.test(next.store_id) || !Number.isSafeInteger(next.sequence) || + !DIGEST.test(next.head_sha256) || + next.sequence !== (prior ? prior.sequence + 1 : 0) || + (prior && next.store_id !== prior.store_id)) throw Error("invalid_anchor_transition"); + if (prior) replaceAtomic(file, next, directory); + else createExclusive(file, next, directory); + return true; + } finally { check(); fs.rmdirSync(lock); syncDirectory(directory); } + } + return { directory, read, compareAndSwap }; +} + +export function createFileRevocationHandoff({ directory, now = () => Date.now(), anchor, claimOnce = null, epochAnchor = null } = {}) { if (typeof directory !== "string" || !path.isAbsolute(directory) || directory !== path.resolve(directory) || typeof now !== "function" || + (anchor !== undefined && (!anchor || typeof anchor.read !== "function" || + typeof anchor.compareAndSwap !== "function")) || (claimOnce !== null && typeof claimOnce !== "function") || (epochAnchor !== null && (typeof epochAnchor !== "object" || typeof epochAnchor.claimOnce !== "function" || - typeof epochAnchor.advanceEpoch !== "function" || claimOnce !== null))) { - throw new TypeError("A trusted directory, clock, and optional atomic host anchor are required"); + typeof epochAnchor.advanceEpoch !== "function" || claimOnce !== null || + anchor !== undefined))) { + throw new TypeError("A trusted directory, clock, and optional atomic host anchor are required; incompatible anchor options"); + } + if (anchor?.directory && (anchor.directory === directory || + !path.relative(directory, anchor.directory).startsWith("..") && + !path.isAbsolute(path.relative(directory, anchor.directory)))) { + throw new TypeError("independent_anchor_directory_required"); } const externalClaim = epochAnchor ? epochAnchor.claimOnce.bind(epochAnchor) : claimOnce; const externalAdvance = epochAnchor?.advanceEpoch.bind(epochAnchor); @@ -129,6 +177,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), const outcomes = path.join(directory, "outcomes"); const lock = path.join(directory, "authority.lock"); const clockFile = path.join(directory, "clock.json"); + const checkpointFile = path.join(directory, "checkpoint.json"); for (const child of [epochs, grants, operations, claimed, outcomes]) privateDirectory(child, true); const pinned = new Map(); for (const child of [directory, epochs, grants, operations, claimed, outcomes]) { @@ -141,14 +190,92 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } } + function checkpointRecord(value) { + return value && value.protocol === "aml-file-authority-checkpoint/1" && + ID.test(value.store_id) && Number.isSafeInteger(value.sequence) && value.sequence >= 0 && + DIGEST.test(value.head_sha256) && + Object.keys(value).sort().join("|") === "head_sha256|protocol|sequence|store_id"; + } + + function externalState() { + const value = anchor.read(); + if (value?.then) throw Error("synchronous_authority_anchor_required"); + return value; + } + + function checkCheckpoint() { + if (hasUncertainTemp(directory, "checkpoint.json")) throw Error("uncertain_authority_checkpoint"); + const local = readRecord(checkpointFile); + if (!anchor) { + if (local) throw Error("authority_anchor_required"); + return null; + } + if (!checkpointRecord(local) || + canonicalJSONStringify(externalState()) !== canonicalJSONStringify(local)) { + throw Error("authority_anchor_mismatch"); + } + return local; + } + + // Initialize only on an empty authority. An existing unanchored history + // cannot acquire a trustworthy external past by merely adding an anchor. + if (anchor) { + checkDirectories(); + try { fs.mkdirSync(lock, { mode: 0o700 }); } + catch (error) { + if (error.code === "EEXIST") throw Error("authority_busy_or_crashed"); + throw error; + } + try { + if (readRecord(checkpointFile)) checkCheckpoint(); + else { + if (externalState() !== null || fs.existsSync(clockFile) || + [epochs, grants, operations, claimed, outcomes].some(child => + fs.readdirSync(child).length !== 0)) throw Error("anchor_requires_empty_authority"); + const store_id = randomUUID(); + const initial = { protocol: "aml-file-authority-checkpoint/1", store_id, + sequence: 0, head_sha256: hash(canonicalJSONStringify(["genesis", store_id])) }; + if (anchor.compareAndSwap(null, Object.freeze(initial)) !== true || + canonicalJSONStringify(externalState()) !== canonicalJSONStringify(initial)) { + throw Error("authority_anchor_unavailable"); + } + createExclusive(checkpointFile, initial, directory); + } + } finally { fs.rmdirSync(lock); syncDirectory(directory); } + } else if (readRecord(checkpointFile)) { + throw Error("authority_anchor_required"); + } + + function anchoredWrite(kind, key, record, write) { + if (!anchor) return write(); + const previous = checkCheckpoint(); + if (!Number.isSafeInteger(previous.sequence + 1)) throw Error("anchor_sequence_exhausted"); + const next = { protocol: previous.protocol, store_id: previous.store_id, + sequence: previous.sequence + 1, + head_sha256: hash(canonicalJSONStringify([previous.head_sha256, kind, key, + hash(canonicalJSONStringify(record))])) }; + if (anchor.compareAndSwap(Object.freeze({ ...previous }), Object.freeze({ ...next })) !== true || + canonicalJSONStringify(externalState()) !== canonicalJSONStringify(next)) { + throw Error("authority_anchor_unavailable"); + } + const result = write(); + replaceAtomic(checkpointFile, next, directory); + return result; + } + function acquire() { checkDirectories(); + checkCheckpoint(); try { fs.mkdirSync(lock, { mode: 0o700 }); } catch (error) { if (error.code === "EEXIST") throw Error("authority_busy_or_crashed"); throw error; } syncDirectory(directory); // If this fails, leave the lock to fail closed. + // A different worker might have committed between the earlier read and + // lock acquisition. A mismatch now leaves no usable authority lock. + try { checkCheckpoint(); } + catch (error) { fs.rmdirSync(lock); syncDirectory(directory); throw error; } } function release() { @@ -198,9 +325,11 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), const key = issuerHash(tenant, issuer); const next = currentEpoch(key) + 1; if (!Number.isSafeInteger(next)) throw Error("revocation_epoch_exhausted"); - replaceAtomic(path.join(epochs, `${key}.json`), { + const record = { protocol: "aml-file-authority-epoch/1", issuer_sha256: key, epoch: next - }, epochs); + }; + anchoredWrite("revoke", key, record, () => + replaceAtomic(path.join(epochs, `${key}.json`), record, epochs)); return { tenant, issuer, epoch: next }; }); } @@ -280,22 +409,27 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } } 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`), { + const operationRecord = operationHash ? { 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`), { + } : null; + const grantRecord = { protocol: "aml-file-authority-grant/1", grant_id: grantId, issuer_sha256: issuer, binding_sha256: bound.sha256, epoch, issued_at_ms: issuedAt, expires_at_ms: issuedAt + ttlMs, ...(operationHash ? { operation_sha256: operationHash } : {}) - }, grants); + }; + anchoredWrite("issue", grantId, { operation: operationRecord, grant: grantRecord }, () => { + if (operationHash) { + // Spend the host intent before the grant. A crash here can lose an + // action, but it cannot send it twice on this preserved store. + createExclusive(path.join(operations, `${operationHash}.json`), + operationRecord, operations); + } + createExclusive(path.join(grants, `${grantId}.json`), grantRecord, grants); + }); return { grant_id: grantId, binding_sha256: bound.sha256, prepared_request_sha256: bound.request.sha256, policy_sha256: bound.policy.sha256 }; }); @@ -339,9 +473,11 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } else { // This marker is durable before callback invocation. A crash after // the marker leaves the grant spent and the global lock stranded. - createExclusive(claimFile, { protocol: "aml-file-authority-claim/1", + const claimRecord = { protocol: "aml-file-authority-claim/1", grant_id, binding_sha256: bound.sha256, - idempotency_key_sha256: idempotencyKey }, claimed); + idempotency_key_sha256: idempotencyKey }; + anchoredWrite("claim", grant_id, claimRecord, () => + createExclusive(claimFile, claimRecord, claimed)); if (externalClaim) { // Keep the local lock across an optional asynchronous CAS. The // host anchor must commit before returning exactly true; a @@ -366,6 +502,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), // authority path replacement. Recheck immediately before the // callback, still under the lock and with the claim spent. checkDirectories(); + checkCheckpoint(); if (record.operation_sha256 && !operationMatches(record)) { decision = denied("indeterminate", "operation_record_unavailable"); } else { diff --git a/test/action-boundary-file-authority-anchor.test.js b/test/action-boundary-file-authority-anchor.test.js new file mode 100644 index 00000000..f1e2f569 --- /dev/null +++ b/test/action-boundary-file-authority-anchor.test.js @@ -0,0 +1,220 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createFileAuthorityAnchor, createFileRevocationHandoff } from + "../pilots/action-boundary/file-revocation-handoff.mjs"; +import { createTestingClaimOnce } from + "../pilots/action-boundary/testing-claim-anchor.mjs"; + +const input = () => ({ tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", + audience: "mail-service", tool: "send_message", policy: { revision: 1 }, + preparedRequest: { to: "team@example.test", body: "Approved" } }); +const worker = new URL("../pilots/action-boundary/file-anchor-worker.mjs", import.meta.url).pathname; +const exec = promisify(execFile); +function copyAuthority(directory, snapshot) { + fs.cpSync(directory, snapshot, { recursive: true }); + fs.chmodSync(snapshot, 0o700); + for (const child of ["epochs", "grants", "operations", "claimed", "outcomes"]) { + fs.chmodSync(path.join(snapshot, child), 0o700); + } + for (const file of ["checkpoint.json", "clock.json"]) { + fs.chmodSync(path.join(snapshot, file), 0o600); + } + for (const child of ["grants", "operations"]) { + for (const file of fs.readdirSync(path.join(snapshot, child))) { + fs.chmodSync(path.join(snapshot, child, file), 0o600); + } + } +} +async function fixture(run) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-anchor-test-")); + const directory = path.join(parent, "authority"); + const anchorDirectory = path.join(parent, "independent-anchor"); + try { + const anchor = createFileAuthorityAnchor({ directory: anchorDirectory }); + const host = createFileRevocationHandoff({ directory, anchor }); + return await run({ parent, directory, anchorDirectory, anchor, host }); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +} + +test("an independent checkpoint advances for issue, claim, and revocation", () => + fixture(async ({ directory, anchor, host }) => { + assert.equal(anchor.read().sequence, 0); + const grant = host.issue(input(), { operationId: "host-intent-anchored-001" }); + assert.equal(anchor.read().sequence, 1); + let callbacks = 0; + const first = await host.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; return "simulated"; } }); + assert.deepEqual([first.status, callbacks, anchor.read().sequence], ["returned", 1, 2]); + assert.equal(host.revokeIssuer(input()).epoch, 1); + assert.equal(anchor.read().sequence, 3); + const restarted = createFileRevocationHandoff({ directory, anchor: + createFileAuthorityAnchor({ directory: anchor.directory }) }); + const replay = await restarted.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } }); + assert.equal(replay.dispatch_count, 0); + assert.equal(callbacks, 1); + assert.throws(() => createFileRevocationHandoff({ directory }), /authority_anchor_required/); + })); + +test("a separate anchored worker dispatches once and its replay is blocked", () => + fixture(async ({ directory, anchorDirectory, anchor, host }) => { + const grant = host.issue(input(), { operationId: "host-intent-anchored-worker" }); + const first = JSON.parse((await exec(process.execPath, + [worker, directory, anchorDirectory, grant.grant_id])).stdout); + const second = JSON.parse((await exec(process.execPath, + [worker, directory, anchorDirectory, grant.grant_id])).stdout); + assert.deepEqual([first.status, first.dispatch_count, second.dispatch_count], + ["returned", 1, 0]); + assert.equal(fs.readFileSync(path.join(anchorDirectory, "effects.log"), "utf8"), "callback\n"); + assert.equal(anchor.read().sequence, 2); + })); + +test("a restored older authority snapshot fails before any callback in a fresh process", () => + fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { + const grant = host.issue(input(), { operationId: "host-intent-anchored-002" }); + const snapshot = path.join(parent, "old-snapshot"); + copyAuthority(directory, snapshot); + let callbacks = 0; + assert.equal((await host.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } })).status, "returned"); + assert.equal(anchor.read().sequence, 2); + fs.renameSync(directory, path.join(parent, "newer")); + fs.renameSync(snapshot, directory); + assert.throws(() => createFileRevocationHandoff({ directory, anchor: + createFileAuthorityAnchor({ directory: anchor.directory }) }), /authority_anchor_mismatch/); + const child = JSON.parse((await exec(process.execPath, + [worker, directory, anchorDirectory, grant.grant_id])).stdout); + assert.deepEqual([child.reason, child.dispatch_count], ["authority_anchor_mismatch", 0]); + assert.equal(fs.existsSync(path.join(anchorDirectory, "effects.log")), false); + assert.equal(callbacks, 1); + })); + +test("a restored unspent grant cannot bypass a revocation absent a spent-claim anchor", () => + fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { + const grant = host.issue(input(), { operationId: "host-intent-pre-revocation" }); + const snapshot = path.join(parent, "pre-revocation"); + copyAuthority(directory, snapshot); + assert.equal(host.revokeIssuer(input()).epoch, 1); + assert.equal(anchor.read().sequence, 2); + fs.renameSync(directory, path.join(parent, "revoked-authority")); + fs.renameSync(snapshot, directory); + const result = JSON.parse((await exec(process.execPath, + [worker, directory, anchorDirectory, grant.grant_id])).stdout); + assert.deepEqual([result.reason, result.dispatch_count], ["authority_anchor_mismatch", 0]); + assert.equal(fs.existsSync(path.join(anchorDirectory, "effects.log")), false); + })); + +test("checkpoint and external one-use claim can gate the same callback", () => + fixture(async ({ parent, directory, anchor, host }) => { + const claimDirectory = path.join(parent, "claims-independent"); + fs.mkdirSync(claimDirectory, { mode: 0o700 }); + const claim = createTestingClaimOnce(claimDirectory); + const grant = host.issue(input(), { operationId: "host-intent-two-anchors" }); + const combined = createFileRevocationHandoff({ directory, anchor, claimOnce: claim }); + let calls = 0; + const first = await combined.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { calls++; return true; } }); + const retry = await combined.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([first.status, retry.dispatch_count, calls, anchor.read().sequence], + ["returned", 0, 1, 2]); + assert.equal(fs.readdirSync(claimDirectory).length, 1); + })); + +test("a checkpoint mismatch during an awaited external claim stops the callback", () => + fixture(async ({ directory, anchor, host }) => { + const grant = host.issue(input()); + const previous = fs.readFileSync(path.join(directory, "checkpoint.json")); + const combined = createFileRevocationHandoff({ directory, anchor, + claimOnce: async () => { + await Promise.resolve(); + fs.writeFileSync(path.join(directory, "checkpoint.json"), previous); + return true; + } }); + let calls = 0; + const result = await combined.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([result.status, result.dispatch_count, calls], ["indeterminate", 0, 0]); + assert.equal(anchor.read().sequence, 2); + })); + +test("restoring both the authority and its file anchor permits replay after restart", () => + fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { + const grant = host.issue(input(), { operationId: "host-intent-both-restored" }); + const oldAuthority = path.join(parent, "old-authority"); + const oldAnchor = path.join(parent, "old-anchor"); + copyAuthority(directory, oldAuthority); + fs.cpSync(anchorDirectory, oldAnchor, { recursive: true }); + fs.chmodSync(oldAnchor, 0o700); + fs.chmodSync(path.join(oldAnchor, "checkpoint.json"), 0o600); + let callbacks = 0; + assert.equal((await host.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } })).status, "returned"); + assert.equal(anchor.read().sequence, 2); + fs.renameSync(directory, path.join(parent, "new-authority")); + fs.renameSync(anchorDirectory, path.join(parent, "new-anchor")); + fs.renameSync(oldAuthority, directory); + fs.renameSync(oldAnchor, anchorDirectory); + const restarted = createFileRevocationHandoff({ directory, + anchor: createFileAuthorityAnchor({ directory: anchorDirectory }) }); + assert.equal((await restarted.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } })).status, "returned"); + assert.equal(callbacks, 2); // A shared rollback domain defeats the checkpoint. + })); + +test("an anchor refusing a claim blocks callback and leaves grant unspent", () => + fixture(async ({ directory, anchor, host }) => { + const grant = host.issue(input()); + const unavailable = { read: () => anchor.read(), + compareAndSwap: (expected, next) => next.sequence === 2 ? false : + anchor.compareAndSwap(expected, next) }; + const another = createFileRevocationHandoff({ directory, anchor: unavailable }); + let callbacks = 0; + const blocked = await another.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } }); + assert.deepEqual([blocked.status, blocked.dispatch_count, callbacks, anchor.read().sequence], + ["indeterminate", 0, 0, 1]); + assert.equal(fs.readdirSync(path.join(directory, "claimed")).length, 0); + })); + +test("a committed external advance with failed local write strands the old checkpoint", () => + fixture(async ({ directory, anchor, host }) => { + const grant = host.issue(input()); + const interrupted = { read: () => anchor.read(), + compareAndSwap(expected, next) { + anchor.compareAndSwap(expected, next); + if (next.sequence === 2) throw Error("lost local write"); + return true; + } }; + const another = createFileRevocationHandoff({ directory, anchor: interrupted }); + let callbacks = 0; + const result = await another.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { callbacks++; } }); + assert.equal(result.status, "indeterminate"); + assert.equal(callbacks, 0); + assert.equal(anchor.read().sequence, 2); + assert.equal(JSON.parse(fs.readFileSync(path.join(directory, "checkpoint.json"))).sequence, 1); + assert.throws(() => createFileRevocationHandoff({ directory, anchor }), + /authority_anchor_mismatch/); + })); + +test("an existing unanchored store cannot acquire a synthetic past, or host its anchor inside", () => + fixture(async ({ directory, anchor, host }) => { + assert.throws(() => createFileRevocationHandoff({ directory, anchor, + epochAnchor: { claimOnce() {}, advanceEpoch() {} } }), + /incompatible anchor options/); + assert.throws(() => createFileRevocationHandoff({ directory, anchor: + createFileAuthorityAnchor({ directory: path.join(directory, "nested-anchor") }) }), + /independent_anchor_directory_required/); + const other = path.join(path.dirname(directory), "legacy"); + const legacy = createFileRevocationHandoff({ directory: other }); + legacy.issue(input()); + assert.throws(() => createFileRevocationHandoff({ directory: other, anchor }), + /anchor_requires_empty_authority/); + assert.equal(host.observeIssuer(input()).epoch, 0); + }));