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
4 changes: 2 additions & 2 deletions pilots/action-boundary/AUTHORITY_CHECKPOINT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ The file anchor is a **one-machine example**. It uses an owner-only directory, a

Protocol 2 sorts the four decision-file namespaces and their filenames, canonicalizes each parsed JSON record, hashes the individual records, and commits the ordered list's digest to both checkpoints. An unexpected filename, partial temporary record, missing record, or changed record value stops the authority. Validation scans the decision files on startup and at the authority boundary; its cost grows with retained records. The host clock and post-callback outcome records are outside this commitment and retain their prior handling.

Existing protocol-1 checkpoints remain readable and continue using their original **whole-directory rollback** detection. They do not silently gain selective-record protection or change protocol during a write. Starting a new protocol-2 authority requires a fresh empty local directory and a fresh independent anchor after the host has reconciled any earlier operations; copying an old store into one is not a migration procedure.
Protocol-1 checkpoints with an established authority mode record remain readable and continue using their original **whole-directory rollback** detection. They do not silently gain selective-record protection or change protocol during a write. A pre-mode store without that record fails closed and requires operator reconciliation and migration; it cannot silently adopt a mode. Starting a new protocol-2 authority requires a fresh empty local directory and a fresh independent anchor after the host has reconciled any earlier operations; copying an old store into one is not a migration procedure.

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 separately supported `claimOnce` hook can be configured at the same time. Choose that combined mode when initializing the authority; adding or removing the hook on an existing store fails its mode check and needs an explicit reconciled migration. 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. The epoch-aware claim checks the held issuer epoch at dispatch. Protocol-2 checkpoints detect selective changes to the local epoch and other committed decision records before issuing or dispatching, provided the outside checkpoint remains current.

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 @@ -69,6 +69,8 @@ const revoked = await authority.revokeIssuerAnchored({ tenant, issuer });

The host must keep that store independent of the local authority directory and retain both its epochs and claims through local rollback. The local lock spans the external epoch update and local epoch write; a rejected or uncertain advance leaves the lock stranded for operator reconciliation. A restored old directory can still issue a stale grant, but the epoch-aware external claim rejects it before the callback. [`testing-epoch-anchor.mjs`](testing-epoch-anchor.mjs) demonstrates the protocol with separate local files and a cross-process rollback test; it is not a production database, multi-machine authority, or tamper-resistant store. All participating local workers and revocation routes must use the **same** local authority lock; independent local stores can race an external advance after a claim and before provider handoff. An anchor outage blocks callbacks, and a returned callback remains no proof of provider acceptance.

The first operation on a fresh, empty authority store writes a durable `authority-mode.json` record for `local`, `claim-anchored`, or `epoch-anchored` mode; a checkpoint authority initializes its mode with its checkpoint and separately distinguishes checkpoint plus claim. Every later operation checks that record under the local lock, including after an awaited external claim or epoch advance. A worker with the wrong mode gets `authority_mode_mismatch` before it can issue, revoke, or dispatch; a checkpoint worker that omits the anchor fails at construction as `authority_anchor_required`. A mode mismatch releases the lock so a correctly configured worker can continue. An existing store with a missing mode record fails closed as `authority_mode_unavailable` on issuance and `authority_store_unavailable` on dispatch (or at construction for a checkpoint authority). New grants using an external claim separately record their required claim protocol; if a valid weaker mode record is substituted, such a grant still gets `required_anchor_unavailable` before a claim or callback. Do not change modes in place. An existing pre-mode store needs operator reconciliation and migration to a new authority store with the intended external anchor; its old records cannot silently adopt a mode. These local records do not bind a worker to a particular external backend, protect grants from older versions that lack the requirement field, prevent unanchored issuance from a snapshot predating mode adoption, resist modification by a process with the same OS identity, or stop a tool path that bypasses this authority.

**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 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.
Expand Down
2 changes: 1 addition & 1 deletion pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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.
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. The local store pins its authority mode, and grants using an external claim pin their required claim protocol; a restarted worker with the wrong mode cannot issue or dispatch against that 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 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
54 changes: 47 additions & 7 deletions pilots/action-boundary/file-revocation-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { hashHandoffBytes as hash, validHandoffIdentity as identity,

const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DIGEST = /^[0-9a-f]{64}$/;
const CLAIM_V1 = "aml-file-authority-external-claim/1";
const CLAIM_V2 = "aml-file-authority-external-claim/2";

// A private leaf is insufficient if a parent can be switched to an older
// authority snapshot. /tmp-style sticky directories protect an owned child;
Expand Down Expand Up @@ -173,6 +175,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
}
const externalClaim = epochAnchor ? epochAnchor.claimOnce.bind(epochAnchor) : claimOnce;
const externalAdvance = epochAnchor?.advanceEpoch.bind(epochAnchor);
const claimProtocol = epochAnchor ? CLAIM_V2 : claimOnce ? CLAIM_V1 : null;
privateDirectory(directory, true);
const epochs = path.join(directory, "epochs");
const grants = path.join(directory, "grants");
Expand All @@ -181,7 +184,11 @@ 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 modeFile = path.join(directory, "authority-mode.json");
const checkpointFile = path.join(directory, "checkpoint.json");
const authorityMode = epochAnchor ? "epoch-anchored" : anchor ?
claimOnce ? "checkpoint-claim-anchored" : "checkpoint-anchored" :
claimOnce ? "claim-anchored" : "local";
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]) {
Expand Down Expand Up @@ -225,6 +232,28 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
}
}

function checkMode({ initialize = true } = {}) {
const record = readRecord(modeFile);
if (!record) {
// A missing mode in an existing store may be a rollback or lost mode
// record. Never infer a weaker mode from the current worker's config.
if (!initialize || fs.existsSync(clockFile) || fs.existsSync(checkpointFile) ||
[epochs, grants, operations, claimed, outcomes]
.some(child => fs.readdirSync(child).length !== 0)) {
throw Error("authority_mode_unavailable");
}
createExclusive(modeFile, {
protocol: "aml-file-authority-mode/1", mode: authorityMode
}, directory);
} else if (record.protocol !== "aml-file-authority-mode/1" ||
!["local", "claim-anchored", "epoch-anchored", "checkpoint-anchored",
"checkpoint-claim-anchored"].includes(record.mode)) {
throw Error("invalid_authority_mode");
} else if (record.mode !== authorityMode) {
throw Error("authority_mode_mismatch");
}
}

function checkpointRecord(value) {
const v1 = value?.protocol === "aml-file-authority-checkpoint/1";
const v2 = value?.protocol === "aml-file-authority-checkpoint/2";
Expand Down Expand Up @@ -269,6 +298,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
throw error;
}
try {
checkMode();
if (readRecord(checkpointFile)) checkCheckpoint();
else {
if (externalState() !== null || fs.existsSync(clockFile) ||
Expand Down Expand Up @@ -320,10 +350,13 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
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; }
try { checkCheckpoint(); checkMode(); }
catch (error) {
// A mismatched worker must not strand the legitimate workers' lock.
// A failed release still fails closed for everyone.
release();
throw error;
}
}

function release() {
Expand Down Expand Up @@ -408,6 +441,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
if (result === false) throw Error("external_epoch_mismatch");
if (result !== true) throw Error("external_epoch_unavailable");
checkDirectories();
checkMode({ initialize: false });
if (currentEpoch(key) !== expected) throw Error("authority_epoch_changed");
replaceAtomic(path.join(epochs, `${key}.json`), {
protocol: "aml-file-authority-epoch/1", issuer_sha256: key, epoch: next
Expand Down Expand Up @@ -468,6 +502,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
issuer_sha256: issuer,
binding_sha256: bound.sha256, epoch, issued_at_ms: issuedAt,
expires_at_ms: issuedAt + ttlMs,
...(claimProtocol ? { required_claim_protocol: claimProtocol } : {}),
...(operationHash ? { operation_sha256: operationHash } : {})
};
anchoredWrite("issue", grantId, { operation: operationRecord, grant: grantRecord }, () => {
Expand Down Expand Up @@ -503,13 +538,18 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
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)) ||
(record.required_claim_protocol !== undefined &&
record.required_claim_protocol !== CLAIM_V1 &&
record.required_claim_protocol !== CLAIM_V2) ||
!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.required_claim_protocol && record.required_claim_protocol !== claimProtocol) {
decision = denied("indeterminate", "required_anchor_unavailable");
} else if (record.operation_sha256 && !operationMatches(record)) {
decision = denied("indeterminate", "operation_record_unavailable");
} else {
Expand All @@ -536,8 +576,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
// restored local snapshot cannot erase that separate claim.
try {
const result = await externalClaim(Object.freeze({
protocol: epochAnchor ? "aml-file-authority-external-claim/2" :
"aml-file-authority-external-claim/1",
protocol: claimProtocol,
// 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,
Expand All @@ -554,6 +593,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();
checkMode({ initialize: false });
checkCheckpoint();
if (record.operation_sha256 && !operationMatches(record)) {
decision = denied("indeterminate", "operation_record_unavailable");
Expand All @@ -578,7 +618,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(),
}
} catch (error) {
decision = invoked ? { status: "outcome_unknown", reason: "authority_or_callback_error", dispatch_count: 1 } :
denied("indeterminate", error.message === "authority_busy_or_crashed" ? error.message :
denied("indeterminate", ["authority_busy_or_crashed", "authority_mode_mismatch"].includes(error.message) ? error.message :
"authority_store_unavailable");
} finally {
if (held) {
Expand Down
Loading
Loading