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: 1 addition & 1 deletion pilots/action-boundary/AUTHORITY_CHECKPOINT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ node pilots/action-boundary/inspect-file-authority.mjs \

The optional second argument is the file anchor directory. The command opens it with `create: false`; it never creates a missing anchor or authority directory. It exits zero for `observed_consistent` and 2 for a lock, mismatch, unreadable state, or other `needs_reconciliation` result. A present authority or anchor lock might belong to a live worker or a crashed one. `checkpoints.comparison` distinguishes a local/external mismatch; `committed_state.comparison` distinguishes a protocol-2 or protocol-3 state mismatch while the checkpoints still agree. `committed_state.includes_authority_mode` is true only for protocol 3. Protocol 1 reports `not_committed`, not a successful state verification. Omit the anchor only to inspect an unanchored store; an anchored local checkpoint without its anchor reports `authority_anchor_not_supplied`.

`reconciliation` lists claimed grants with no local outcome or an `outcome_unknown` record, even if the lock is gone and the checkpoints agree. Each candidate has only a grant ID and opaque operation/idempotency hashes for looking up the host's external claim and provider history; it does not include the prepared request or recipient. It flags missing or mismatched claim, grant, operation, and outcome links, including an operation whose issuer or epoch differs from its grant. An outcome must use one of the writer's valid status/reason pairs: `returned`/`callback_returned` or `outcome_unknown`/`callback_error`. An invalid pair requires reconciliation rather than counting a local return. `returned` counts callbacks that returned locally; it is not a delivery count. A missing outcome does not prove the callback ran: the external claim could have rejected or the worker could have stopped before handoff. These candidates require human review, never automatic retry.
`reconciliation` lists claimed grants with no local outcome or an `outcome_unknown` record, even if the lock is gone and the checkpoints agree. Each candidate has only a grant ID and opaque operation/idempotency hashes for looking up the host's external claim and provider history; it does not include the prepared request or recipient. It also scans all grants and operation markers, including unspent ones: an orphan operation marker can block reuse of a host intent, and a missing or mismatched marker can make an unspent grant unusable. These links, malformed grant and operation records, and missing or mismatched claim and outcome links produce `invalid_records` and require reconciliation. An outcome must use one of the writer's valid status/reason pairs: `returned`/`callback_returned` or `outcome_unknown`/`callback_error`. An invalid pair requires reconciliation rather than counting a local return. `returned` counts callbacks that returned locally; it is not a delivery count. A missing outcome does not prove the callback ran: the external claim could have rejected or the worker could have stopped before handoff. These candidates require human review, never automatic retry.

The command performs no unlock, mode initialization, checkpoint advance, grant issue, clock update, provider call, or recovery. A missing mode reports `authority_mode_missing`, including in an empty directory: a never initialized store cannot be distinguished from one whose mode was deleted. Its scan is not an atomic snapshot of live workers, and `observed_consistent` cannot establish provider delivery, an independently protected anchor or external claim backend, or permission to resume an uncertain operation. Preserve the records and reconcile the provider's operation and idempotency history before any operator-controlled recovery. `inspectFileRevocationHandoff({ directory, anchor })` exposes the same observation to a host without invoking the CLI.

Expand Down
86 changes: 70 additions & 16 deletions pilots/action-boundary/file-revocation-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,22 +238,6 @@ function claimReconciliation(directory) {
(grant.operation_sha256 !== undefined && !DIGEST.test(grant.operation_sha256)))) {
issues.push({ grant_id: grantId, problem: "grant_missing_or_mismatch" });
}
if (grant?.operation_sha256 && DIGEST.test(grant.operation_sha256)) {
let operation, operationUnreadable = false;
try { operation = readRecord(path.join(directory, "operations",
`${grant.operation_sha256}.json`)); }
catch { issues.push({ grant_id: grantId, problem: "operation_unreadable" });
operationUnreadable = true; }
if (!operationUnreadable && (!operation ||
operation.protocol !== "aml-file-authority-operation/1" ||
operation.operation_sha256 !== grant.operation_sha256 ||
operation.grant_id !== grantId ||
operation.binding_sha256 !== claim.binding_sha256 ||
operation.issuer_sha256 !== grant.issuer_sha256 ||
operation.epoch !== grant.epoch)) {
issues.push({ grant_id: grantId, problem: "operation_missing_or_mismatch" });
}
}
if (outcomeNamesSet.has(name)) {
try { outcome = readRecord(path.join(outcomes, name)); }
catch { issues.push({ grant_id: grantId, problem: "outcome_unreadable" }); continue; }
Expand Down Expand Up @@ -284,6 +268,76 @@ function claimReconciliation(directory) {
issues.push({ grant_id: name.slice(0, -5), problem: "outcome_without_claim" });
}
}
// Claims cover only spent grants. Inspect both sides of every operation link:
// an orphan marker can block a host intent, while an unspent grant whose
// marker disappeared cannot be dispatched safely. Neither has a claim yet.
const grants = new Map(), operations = new Map();
for (const name of fs.readdirSync(path.join(directory, "grants")).sort()) {
if (!pattern.test(name)) {
issues.push({ collection: "grants", problem: "unexpected_filename" });
continue;
}
const grantId = name.slice(0, -5);
let grant;
try { grant = readRecord(path.join(directory, "grants", name)); }
catch { issues.push({ grant_id: grantId, problem: "grant_unreadable" }); continue; }
if (!grant || grant.protocol !== "aml-file-authority-grant/1" ||
grant.grant_id !== grantId || !DIGEST.test(grant.issuer_sha256) ||
!DIGEST.test(grant.binding_sha256) ||
!Number.isSafeInteger(grant.epoch) || grant.epoch < 0 ||
!Number.isSafeInteger(grant.issued_at_ms) ||
!Number.isSafeInteger(grant.expires_at_ms) ||
grant.expires_at_ms <= grant.issued_at_ms ||
(grant.operation_sha256 !== undefined &&
!DIGEST.test(grant.operation_sha256)) ||
(grant.required_claim_protocol !== undefined &&
grant.required_claim_protocol !== CLAIM_V1 &&
grant.required_claim_protocol !== CLAIM_V2)) {
issues.push({ grant_id: grantId, problem: "grant_invalid" });
continue;
}
grants.set(grantId, grant);
}
const operationPattern = /^[0-9a-f]{64}\.json$/;
for (const name of fs.readdirSync(path.join(directory, "operations")).sort()) {
if (!operationPattern.test(name)) {
issues.push({ collection: "operations", problem: "unexpected_filename" });
continue;
}
const operationHash = name.slice(0, -5);
let operation;
try { operation = readRecord(path.join(directory, "operations", name)); }
catch { issues.push({ operation_sha256: operationHash,
problem: "operation_unreadable" }); continue; }
if (!operation || operation.protocol !== "aml-file-authority-operation/1" ||
operation.operation_sha256 !== operationHash ||
typeof operation.grant_id !== "string" || !ID.test(operation.grant_id) ||
!DIGEST.test(operation.issuer_sha256) ||
!DIGEST.test(operation.binding_sha256) ||
!Number.isSafeInteger(operation.epoch) || operation.epoch < 0) {
issues.push({ operation_sha256: operationHash, problem: "operation_invalid" });
continue;
}
operations.set(operationHash, operation);
}
const matchingLink = (grant, operation) => grant && operation &&
grant.grant_id === operation.grant_id &&
grant.operation_sha256 === operation.operation_sha256 &&
grant.binding_sha256 === operation.binding_sha256 &&
grant.issuer_sha256 === operation.issuer_sha256 && grant.epoch === operation.epoch;
for (const [grantId, grant] of grants) {
if (grant.operation_sha256 &&
!matchingLink(grant, operations.get(grant.operation_sha256))) {
issues.push({ grant_id: grantId, problem: "operation_missing_or_mismatch" });
}
}
for (const operation of operations.values()) {
if (!matchingLink(grants.get(operation.grant_id), operation)) {
issues.push({ grant_id: operation.grant_id,
operation_sha256: operation.operation_sha256,
problem: "operation_without_matching_grant" });
}
}
return { status: issues.length ? "invalid_records" : candidates.length ?
"provider_review_needed" : "no_unresolved_local_claims",
counts, candidates, issues };
Expand Down
57 changes: 57 additions & 0 deletions test/action-boundary-file-authority-inspection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,63 @@ test("unanchored inspection checks the spent operation epoch and issuer link", (
})();
});

test("unanchored inspection reports an orphan operation marker before any claim", () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-inspection-orphan-"));
try {
const directory = path.join(parent, "authority");
const host = createFileRevocationHandoff({ directory });
const grant = host.issue(input(), { operationId: "host-intent-inspect-orphan" });
fs.rmSync(path.join(directory, "grants", `${grant.grant_id}.json`));
const before = snapshot(parent);
const report = inspectFileRevocationHandoff({ directory });
assert.equal(report.committed_state.comparison, "not_committed");
assert.equal(report.status, "needs_reconciliation");
assert.ok(report.reasons.includes("reconciliation_records_invalid"));
assert.ok(report.reconciliation.issues.some(item =>
item.grant_id === grant.grant_id &&
item.problem === "operation_without_matching_grant"));
assert.equal(JSON.stringify(report).includes("team@example.test"), false);
assert.deepEqual(snapshot(parent), before);
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
});

test("unanchored inspection reports an unspent grant with a missing operation", () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-inspection-unspent-"));
try {
const directory = path.join(parent, "authority");
const host = createFileRevocationHandoff({ directory });
const grant = host.issue(input(), { operationId: "host-intent-inspect-unspent" });
const operationName = fs.readdirSync(path.join(directory, "operations"))[0];
fs.rmSync(path.join(directory, "operations", operationName));
const before = snapshot(parent);
const report = inspectFileRevocationHandoff({ directory });
assert.equal(report.reconciliation.counts.claims, 0);
assert.equal(report.status, "needs_reconciliation");
assert.ok(report.reconciliation.issues.some(item =>
item.grant_id === grant.grant_id &&
item.problem === "operation_missing_or_mismatch"));
assert.deepEqual(snapshot(parent), before);
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
});

test("unanchored inspection requires the grant to point back to its operation", () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-inspection-backlink-"));
try {
const directory = path.join(parent, "authority");
const host = createFileRevocationHandoff({ directory });
const grant = host.issue(input(), { operationId: "host-intent-inspect-backlink" });
const grantFile = path.join(directory, "grants", `${grant.grant_id}.json`);
const record = JSON.parse(fs.readFileSync(grantFile, "utf8"));
delete record.operation_sha256;
fs.writeFileSync(grantFile, JSON.stringify(record) + "\n");
const report = inspectFileRevocationHandoff({ directory });
assert.equal(report.status, "needs_reconciliation");
assert.ok(report.reconciliation.issues.some(item =>
item.grant_id === grant.grant_id &&
item.problem === "operation_without_matching_grant"));
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
});

test("an outcome with no spent claim is reported as an invalid local link", () =>
fixture(async ({ directory, anchor, host }) => {
const grant = host.issue(input());
Expand Down
Loading