@atcn/subledger 1.4.1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/dist/documents.d.ts +1067 -26
- package/dist/documents.js +121 -7
- package/dist/exceptions.d.ts +47 -2
- package/dist/exceptions.js +173 -3
- package/dist/expectations.d.ts +96 -0
- package/dist/expectations.js +236 -0
- package/dist/importing.d.ts +53 -0
- package/dist/importing.js +192 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/outcome.d.ts +36 -0
- package/dist/outcome.js +64 -0
- package/dist/presets.d.ts +36 -0
- package/dist/presets.js +192 -0
- package/dist/projection.d.ts +28 -3
- package/dist/projection.js +69 -4
- package/dist/rails.d.ts +108 -0
- package/dist/rails.js +242 -0
- package/dist/response.d.ts +2 -0
- package/dist/response.js +1 -0
- package/dist/rollup.d.ts +1 -1
- package/dist/rollup.js +10 -4
- package/dist/types.d.ts +279 -7
- package/dist/types.js +179 -6
- package/dist/usage.d.ts +41 -0
- package/dist/usage.js +86 -0
- package/dist/verify.d.ts +8 -1
- package/dist/verify.js +385 -7
- package/package.json +6 -4
- package/test-vectors/reconciliation-results.json +151 -0
- package/test-vectors/reconciliation.json +107 -0
- package/test-vectors/vectors.json +25666 -1
package/dist/verify.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { verifyClosurePackage } from "@atcn/core";
|
|
2
|
-
import { ClosurePackageSchema, digestOf, executionBinding, resolveAttestations, verifyPayload } from "@atcn/schema";
|
|
2
|
+
import { AgentTraceSchema, ClosurePackageSchema, canonicalize, digestOf, executionBinding, resolveAttestations, summarizeTrace, traceDigest, traceProblems, utf8Decode, verifyPayload, } from "@atcn/schema";
|
|
3
3
|
import { CLEARING_SOURCE, clearingFacts, settlementEvidence, undoneBatch } from "./bridge.js";
|
|
4
4
|
import { CLOSURE_DOCUMENT_TYPE, RECEIPT_DOCUMENT_TYPE, SignedClosureSchema, SignedReceiptSchema, SIGNED_BY_HOSTED_SERVICE, SUBLEDGER_VERIFIER_VERSION, SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS, } from "./documents.js";
|
|
5
|
-
import {
|
|
5
|
+
import { DERIVED_EXCEPTION_KINDS, SERVICE_ONLY_EXCEPTION_KINDS, deriveTaskExceptions } from "./exceptions.js";
|
|
6
|
+
import { buildExpectationReport, expectationSignatureProblem } from "./expectations.js";
|
|
7
|
+
import { closureDisclosure, deliveryStatus, labelResponses, receiptTotals, responseAttestation, rollupFor, signerKeyBindings } from "./projection.js";
|
|
6
8
|
import { verifyCountersignature, verifyStatementSignature } from "./response.js";
|
|
7
|
-
import {
|
|
9
|
+
import { outcomeSignatureProblem } from "./outcome.js";
|
|
10
|
+
import { buildRailAttestationReport, railAttestationProblem } from "./rails.js";
|
|
11
|
+
import { attestableFieldsFor } from "./types.js";
|
|
12
|
+
import { usageChecksFor } from "./usage.js";
|
|
8
13
|
const SERVICE_ACTOR = "svc_atcn";
|
|
9
14
|
function check(name, problems) {
|
|
10
15
|
return { name, ok: problems.length === 0, details: problems };
|
|
@@ -53,6 +58,17 @@ export function verifySubledgerDocument(input, options) {
|
|
|
53
58
|
function finish(documentType, checks) {
|
|
54
59
|
return { valid: checks.every((c) => c.ok), document_type: documentType, checks };
|
|
55
60
|
}
|
|
61
|
+
const NOT_CANONICAL = "payload is not canonical JSON: numbers must be safe integers";
|
|
62
|
+
/** Free-form parts of a payload, such as an embedded rail record, can hold numbers that no signature could cover. */
|
|
63
|
+
function isCanonical(payload) {
|
|
64
|
+
try {
|
|
65
|
+
canonicalize(payload);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
56
72
|
function schemaFailure(documentType, issues) {
|
|
57
73
|
return finish(documentType, [check("schema", issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`))]);
|
|
58
74
|
}
|
|
@@ -103,6 +119,111 @@ function schema14Problems(version, delegations, responses, claims) {
|
|
|
103
119
|
}
|
|
104
120
|
return problems;
|
|
105
121
|
}
|
|
122
|
+
/** A document that declares 1.2, 1.3 or 1.4 must not carry 1.5 fields, which those verifiers would drop before checking signatures. */
|
|
123
|
+
function schema15Problems(version, parts) {
|
|
124
|
+
if (!["1.2", "1.3", "1.4"].includes(version))
|
|
125
|
+
return [];
|
|
126
|
+
const problems = [];
|
|
127
|
+
for (const d of parts.delegations) {
|
|
128
|
+
if (d.pricing !== undefined)
|
|
129
|
+
problems.push(`schema ${version} does not allow pricing on delegation ${d.delegation_id}`);
|
|
130
|
+
if (d.refund_terms !== undefined)
|
|
131
|
+
problems.push(`schema ${version} does not allow refund_terms on delegation ${d.delegation_id}`);
|
|
132
|
+
if (d.witness_policy !== undefined)
|
|
133
|
+
problems.push(`schema ${version} does not allow witness_policy on delegation ${d.delegation_id}`);
|
|
134
|
+
if (d.execution?.agent.additional_models !== undefined)
|
|
135
|
+
problems.push(`schema ${version} does not allow additional_models on delegation ${d.delegation_id}`);
|
|
136
|
+
}
|
|
137
|
+
for (const c of parts.claims) {
|
|
138
|
+
if (c.usage !== undefined)
|
|
139
|
+
problems.push(`schema ${version} does not allow usage on delivery claim ${c.event_id}`);
|
|
140
|
+
if (c.signer !== undefined)
|
|
141
|
+
problems.push(`schema ${version} does not allow signer on delivery claim ${c.event_id}`);
|
|
142
|
+
}
|
|
143
|
+
for (const e of parts.events) {
|
|
144
|
+
if (e.skill !== undefined)
|
|
145
|
+
problems.push(`schema ${version} does not allow skill on financial event ${e.financial_event_id}`);
|
|
146
|
+
if (e.normalized_status === "pending_finality")
|
|
147
|
+
problems.push(`schema ${version} does not allow status pending_finality on financial event ${e.financial_event_id}`);
|
|
148
|
+
if (e.type === "estimate" || e.type === "hold")
|
|
149
|
+
problems.push(`schema ${version} does not allow ${e.type} events (financial event ${e.financial_event_id})`);
|
|
150
|
+
if (e.expectation !== undefined)
|
|
151
|
+
problems.push(`schema ${version} does not allow expectation on financial event ${e.financial_event_id}`);
|
|
152
|
+
if (e.rail_attestation !== undefined)
|
|
153
|
+
problems.push(`schema ${version} does not allow rail_attestation on financial event ${e.financial_event_id}`);
|
|
154
|
+
}
|
|
155
|
+
if (parts.hasUsageChecks)
|
|
156
|
+
problems.push(`schema ${version} does not allow usage_checks`);
|
|
157
|
+
if (parts.hasExpectationReport)
|
|
158
|
+
problems.push(`schema ${version} does not allow expectation_report`);
|
|
159
|
+
if (parts.hasEstimateTolerance)
|
|
160
|
+
problems.push(`schema ${version} does not allow task estimate_tolerance_bps`);
|
|
161
|
+
if (parts.hasRailAttestations)
|
|
162
|
+
problems.push(`schema ${version} does not allow rail_attestations`);
|
|
163
|
+
if (parts.hasReceiptKeyBindings)
|
|
164
|
+
problems.push(`schema ${version} does not allow key_bindings on a receipt`);
|
|
165
|
+
if (parts.hasResolvedExceptions)
|
|
166
|
+
problems.push(`schema ${version} does not allow resolved_exceptions`);
|
|
167
|
+
for (const r of parts.responses)
|
|
168
|
+
if (r.statement.role !== undefined)
|
|
169
|
+
problems.push(`schema ${version} does not allow statement role (response ${r.response_id})`);
|
|
170
|
+
const usageFieldUsed = parts.fieldLists.some((list) => list.includes("delivery.usage")) || parts.responses.some((r) => r.statement.fields.includes("delivery.usage") || r.statement.corrections.some((c) => c.field === "delivery.usage"));
|
|
171
|
+
if (usageFieldUsed)
|
|
172
|
+
problems.push(`schema ${version} does not allow the delivery.usage field`);
|
|
173
|
+
return problems;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Recomputes each recorded usage summary from its trace file. Usage whose trace was not supplied is reported as not
|
|
177
|
+
* inspected; it neither passes nor fails. A supplied file that is not a well-formed trace fails.
|
|
178
|
+
*/
|
|
179
|
+
function traceSummaryCheck(claims, traceFiles) {
|
|
180
|
+
const name = "trace_summary";
|
|
181
|
+
const withUsage = claims.filter((c) => c.usage !== undefined);
|
|
182
|
+
if (withUsage.length === 0)
|
|
183
|
+
return { name, ok: true, details: ["no usage recorded"] };
|
|
184
|
+
const problems = [];
|
|
185
|
+
const traces = new Map();
|
|
186
|
+
(traceFiles ?? []).forEach((bytes, index) => {
|
|
187
|
+
const trace = parseTraceFile(bytes);
|
|
188
|
+
if (typeof trace === "string")
|
|
189
|
+
problems.push(`trace file ${index + 1}: ${trace}`);
|
|
190
|
+
else
|
|
191
|
+
traces.set(traceDigest(trace), trace);
|
|
192
|
+
});
|
|
193
|
+
const notInspected = [];
|
|
194
|
+
const used = new Set();
|
|
195
|
+
for (const claim of withUsage) {
|
|
196
|
+
const usage = claim.usage;
|
|
197
|
+
const trace = traces.get(usage.trace_digest);
|
|
198
|
+
if (!trace) {
|
|
199
|
+
notInspected.push(`not inspected: delivery claim ${claim.event_id} (trace ${usage.trace_digest} not supplied)`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
used.add(usage.trace_digest);
|
|
203
|
+
if (digestOf(summarizeTrace(trace)) !== digestOf(usage.summary))
|
|
204
|
+
problems.push(`delivery claim ${claim.event_id}: recorded usage does not match its trace ${usage.trace_digest}`);
|
|
205
|
+
}
|
|
206
|
+
if (problems.length > 0)
|
|
207
|
+
return check(name, problems);
|
|
208
|
+
const unused = [...traces.keys()].filter((d) => !used.has(d)).map((d) => `supplied trace ${d} matches no recorded usage`);
|
|
209
|
+
const inspected = withUsage.length - notInspected.length;
|
|
210
|
+
const details = [...(inspected > 0 ? [`${inspected} recorded usage summary(ies) match their traces`] : []), ...notInspected, ...unused];
|
|
211
|
+
return inspected === 0 ? { name, ok: true, details, state: "not_inspected" } : { name, ok: true, details };
|
|
212
|
+
}
|
|
213
|
+
function parseTraceFile(bytes) {
|
|
214
|
+
let raw;
|
|
215
|
+
try {
|
|
216
|
+
raw = JSON.parse(utf8Decode(bytes));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return "not JSON";
|
|
220
|
+
}
|
|
221
|
+
const parsed = AgentTraceSchema.safeParse(raw);
|
|
222
|
+
if (!parsed.success)
|
|
223
|
+
return `not a trace: ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`;
|
|
224
|
+
const problems = traceProblems(parsed.data);
|
|
225
|
+
return problems.length > 0 ? `malformed trace: ${problems.join("; ")}` : parsed.data;
|
|
226
|
+
}
|
|
106
227
|
function reversalProblems(events) {
|
|
107
228
|
const byId = new Map(events.map((e) => [e.financial_event_id, e]));
|
|
108
229
|
const problems = [];
|
|
@@ -122,11 +243,22 @@ export function verifyReceipt(input, options) {
|
|
|
122
243
|
const parsed = SignedReceiptSchema.safeParse(input);
|
|
123
244
|
if (!parsed.success)
|
|
124
245
|
return schemaFailure(RECEIPT_DOCUMENT_TYPE, parsed.error.issues);
|
|
246
|
+
if (!isCanonical(parsed.data.payload))
|
|
247
|
+
return finish(RECEIPT_DOCUMENT_TYPE, [check("schema", [NOT_CANONICAL])]);
|
|
125
248
|
const doc = parsed.data;
|
|
126
249
|
const r = doc.payload;
|
|
127
250
|
const schemaProblems = [
|
|
128
251
|
...versionFeatureProblems(r.schema_version, r.issuer.signed_by, r.delivery_claims, false),
|
|
129
252
|
...schema14Problems(r.schema_version, [r.delegation], [], r.delivery_claims),
|
|
253
|
+
...schema15Problems(r.schema_version, {
|
|
254
|
+
delegations: [r.delegation],
|
|
255
|
+
claims: r.delivery_claims,
|
|
256
|
+
events: r.financial_events,
|
|
257
|
+
responses: [],
|
|
258
|
+
fieldLists: [Object.keys(r.field_status), r.unverified_fields, ...r.corrections.map((c) => c.fields)],
|
|
259
|
+
hasUsageChecks: false,
|
|
260
|
+
hasReceiptKeyBindings: r.key_bindings !== undefined,
|
|
261
|
+
}),
|
|
130
262
|
];
|
|
131
263
|
const checks = [check("schema", schemaProblems), signatureCheck(doc, r.issued_at, options.trustedKeys), operatorSignatureCheck(r, r.issuer.operator_id, doc.operator_signatures, options)];
|
|
132
264
|
const events = r.financial_events.map(({ allocation_version: _version, ...e }) => ({ ...e, liability_owner: null, economic_event_id: null, fx: null }));
|
|
@@ -134,17 +266,59 @@ export function verifyReceipt(input, options) {
|
|
|
134
266
|
const recomputed = receiptTotals(r.delegation, events);
|
|
135
267
|
checks.push(check("totals", digestOf(recomputed) === digestOf(r.totals) ? [] : ["totals do not match the listed financial events"]));
|
|
136
268
|
const fieldProblems = [];
|
|
137
|
-
const
|
|
269
|
+
const fields = attestableFieldsFor(r.schema_version);
|
|
270
|
+
const expectedUnverified = fields.filter((f) => r.field_status[f] !== "missing");
|
|
138
271
|
if (digestOf(expectedUnverified) !== digestOf(r.unverified_fields))
|
|
139
272
|
fieldProblems.push("unverified_fields must list every non-missing field");
|
|
140
|
-
for (const f of
|
|
273
|
+
for (const f of fields)
|
|
141
274
|
if (!r.field_status[f])
|
|
142
275
|
fieldProblems.push(`field_status is missing ${f}`);
|
|
143
276
|
checks.push(check("field_disclosure", fieldProblems));
|
|
277
|
+
checks.push(receiptSignedRecordsCheck(r, events));
|
|
144
278
|
checks.push(receiptChainCheck(r, options));
|
|
145
279
|
checks.push(receiptExpiryCheck(r, options.at ?? new Date().toISOString()));
|
|
280
|
+
checks.push(traceSummaryCheck(r.delivery_claims, options.traces));
|
|
146
281
|
return finish(RECEIPT_DOCUMENT_TYPE, checks);
|
|
147
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Provider-signed outcome claims and signed estimates and holds on a receipt must verify against the key bindings it
|
|
285
|
+
* lists, which must be exactly the bindings their signers name; only verified claims may be labelled provider_key_signed.
|
|
286
|
+
*/
|
|
287
|
+
function receiptSignedRecordsCheck(r, events) {
|
|
288
|
+
const name = "signed_records";
|
|
289
|
+
const listed = r.key_bindings ?? [];
|
|
290
|
+
const problems = [];
|
|
291
|
+
if (digestOf(signerKeyBindings(r.delivery_claims, events, listed)) !== digestOf(listed))
|
|
292
|
+
problems.push("key_bindings must list exactly the bindings the receipt's signers name, in binding_id order");
|
|
293
|
+
const delegation = { provider_id: r.provider.provider_id, provider_job_ref: r.delegation.provider_job_ref };
|
|
294
|
+
for (const claim of r.delivery_claims) {
|
|
295
|
+
const labelled = claim.assurance.includes("provider_key_signed");
|
|
296
|
+
if (!claim.signer) {
|
|
297
|
+
if (labelled)
|
|
298
|
+
problems.push(`${claim.type} claim ${claim.event_id} is labelled provider_key_signed without a signature`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const problem = outcomeSignatureProblem({ ...claim, delegation_id: r.delegation.delegation_id }, delegation, listed);
|
|
302
|
+
if (problem)
|
|
303
|
+
problems.push(problem);
|
|
304
|
+
else if (!labelled)
|
|
305
|
+
problems.push(`${claim.type} claim ${claim.event_id} is signed but not labelled provider_key_signed`);
|
|
306
|
+
}
|
|
307
|
+
for (const e of events) {
|
|
308
|
+
if (!e.expectation)
|
|
309
|
+
continue;
|
|
310
|
+
const problem = expectationSignatureProblem(e, r.provider.provider_id, listed);
|
|
311
|
+
if (problem)
|
|
312
|
+
problems.push(problem);
|
|
313
|
+
}
|
|
314
|
+
if (problems.length > 0)
|
|
315
|
+
return check(name, problems);
|
|
316
|
+
const claims = r.delivery_claims.filter((c) => c.signer).length;
|
|
317
|
+
const estimates = events.filter((e) => e.expectation?.signer).length;
|
|
318
|
+
if (claims + estimates === 0)
|
|
319
|
+
return { name, ok: true, details: ["no signed claims, estimates or holds"] };
|
|
320
|
+
return { name, ok: true, details: [`${claims} provider-signed outcome claim(s) and ${estimates} signed estimate/hold record(s) verify against the listed key bindings`] };
|
|
321
|
+
}
|
|
148
322
|
/** The issuer signed expires_at, so a receipt past it no longer stands, even though its signature still verifies. */
|
|
149
323
|
function receiptExpiryCheck(r, at) {
|
|
150
324
|
if (r.expires_at === null)
|
|
@@ -184,11 +358,25 @@ export function verifyClosure(input, options) {
|
|
|
184
358
|
const parsed = SignedClosureSchema.safeParse(input);
|
|
185
359
|
if (!parsed.success)
|
|
186
360
|
return schemaFailure(CLOSURE_DOCUMENT_TYPE, parsed.error.issues);
|
|
361
|
+
if (!isCanonical(parsed.data.payload))
|
|
362
|
+
return finish(CLOSURE_DOCUMENT_TYPE, [check("schema", [NOT_CANONICAL])]);
|
|
187
363
|
const doc = parsed.data;
|
|
188
364
|
const c = doc.payload;
|
|
189
365
|
const schemaProblems = [
|
|
190
366
|
...versionFeatureProblems(c.schema_version, c.issuer.signed_by, c.delivery_claims, c.obligation_links !== undefined),
|
|
191
367
|
...schema14Problems(c.schema_version, c.delegations, c.responses, c.delivery_claims),
|
|
368
|
+
...schema15Problems(c.schema_version, {
|
|
369
|
+
delegations: c.delegations,
|
|
370
|
+
claims: c.delivery_claims,
|
|
371
|
+
events: c.financial_events.map((e) => e.record),
|
|
372
|
+
responses: c.responses,
|
|
373
|
+
fieldLists: [],
|
|
374
|
+
hasUsageChecks: c.usage_checks !== undefined,
|
|
375
|
+
hasExpectationReport: c.expectation_report !== undefined,
|
|
376
|
+
hasEstimateTolerance: c.task.estimate_tolerance_bps !== undefined,
|
|
377
|
+
hasRailAttestations: c.rail_attestations !== undefined,
|
|
378
|
+
hasResolvedExceptions: c.resolved_exceptions !== undefined,
|
|
379
|
+
}),
|
|
192
380
|
];
|
|
193
381
|
const checks = [check("schema", schemaProblems), signatureCheck(doc, c.generated_at, options.trustedKeys), operatorSignatureCheck(c, c.issuer.operator_id, doc.operator_signatures, options)];
|
|
194
382
|
const digestProblems = c.financial_events.filter((e) => digestOf(e.record) !== e.event_digest).map((e) => `event ${e.record.financial_event_id} digest mismatch`);
|
|
@@ -201,11 +389,177 @@ export function verifyClosure(input, options) {
|
|
|
201
389
|
checks.push(check("allocations", allocationProblems(c)));
|
|
202
390
|
const recomputed = rollupFor(c.task, c.delegations, c.financial_events, c.allocations);
|
|
203
391
|
checks.push(check("totals", digestOf(recomputed) === digestOf(c.rollup) ? [] : ["roll-up does not match events, attribution, and allocations"]));
|
|
392
|
+
checks.push(derivedFieldsCheck(c));
|
|
204
393
|
checks.push(providerResponsesCheck(c));
|
|
205
394
|
checks.push(closureChainCheck(c, options));
|
|
206
395
|
checks.push(obligationLinkCheck(c, options));
|
|
396
|
+
checks.push(usageChecksCheck(c, recomputed));
|
|
397
|
+
checks.push(expectationsCheck(c, recomputed));
|
|
398
|
+
checks.push(openExceptionsCheck(c));
|
|
399
|
+
checks.push(signedClaimsCheck(c));
|
|
400
|
+
checks.push(railAttestationsCheck(c));
|
|
401
|
+
checks.push(traceSummaryCheck(c.delivery_claims, options.traces));
|
|
207
402
|
return finish(CLOSURE_DOCUMENT_TYPE, checks);
|
|
208
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Each embedded rail attestation must verify offline (Merkle inclusion for A2A-SE, the payer's signature for x402) and
|
|
406
|
+
* agree with its event, and rail_attestations must list exactly those events.
|
|
407
|
+
*/
|
|
408
|
+
function railAttestationsCheck(c) {
|
|
409
|
+
const name = "rail_attestations";
|
|
410
|
+
const problems = [];
|
|
411
|
+
for (const { record } of c.financial_events) {
|
|
412
|
+
const problem = railAttestationProblem(record);
|
|
413
|
+
if (problem)
|
|
414
|
+
problems.push(`${record.type} ${record.financial_event_id}: rail attestation refused (${problem.code}): ${problem.detail}`);
|
|
415
|
+
}
|
|
416
|
+
const expected = buildRailAttestationReport(c.financial_events);
|
|
417
|
+
if (digestOf(expected ?? null) !== digestOf(c.rail_attestations ?? null))
|
|
418
|
+
problems.push("rail_attestations do not match the closure's payment and refund records");
|
|
419
|
+
if (problems.length > 0)
|
|
420
|
+
return check(name, problems);
|
|
421
|
+
if (!expected)
|
|
422
|
+
return { name, ok: true, details: ["no rail attestations"] };
|
|
423
|
+
return { name, ok: true, details: [`${expected.length} payment/refund record(s) rail_attested, re-verified offline without contacting the rail`, ...expected.map((e) => e.anchor)] };
|
|
424
|
+
}
|
|
425
|
+
const isRecomputableException = (kind) => DERIVED_EXCEPTION_KINDS.includes(kind) && !SERVICE_ONLY_EXCEPTION_KINDS.includes(kind);
|
|
426
|
+
/** The derived exceptions a closure's own records imply at close, as of generated_at; the service must list each as open or resolved. */
|
|
427
|
+
export function closureDerivedExceptions(c) {
|
|
428
|
+
return deriveTaskExceptions({
|
|
429
|
+
task: c.task,
|
|
430
|
+
delegations: c.delegations,
|
|
431
|
+
claims: c.delivery_claims,
|
|
432
|
+
events: c.financial_events,
|
|
433
|
+
rollup: rollupFor(c.task, c.delegations, c.financial_events, c.allocations),
|
|
434
|
+
now: c.generated_at,
|
|
435
|
+
responses: c.responses,
|
|
436
|
+
receipts: c.receipts,
|
|
437
|
+
key_bindings: c.key_bindings,
|
|
438
|
+
closing: true,
|
|
439
|
+
}).filter((d) => isRecomputableException(d.kind));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Schema 1.5: the derived exceptions recomputed from the closure's own records, as of generated_at and at close, must
|
|
443
|
+
* each be open or listed as resolved by a person, and no open derived exception may lack its condition. Witness quorum
|
|
444
|
+
* (it needs the service's verified domains) and exceptions raised at intake are not recomputed.
|
|
445
|
+
*/
|
|
446
|
+
function openExceptionsCheck(c) {
|
|
447
|
+
const name = "open_exceptions";
|
|
448
|
+
if (["1.2", "1.3", "1.4"].includes(c.schema_version))
|
|
449
|
+
return { name, ok: true, details: [`schema ${c.schema_version}: open exceptions are not recomputed`] };
|
|
450
|
+
const derived = closureDerivedExceptions(c);
|
|
451
|
+
const sameCondition = (x, d) => x.kind === d.kind && x.delegation_id === d.delegation_id && x.detail === d.detail;
|
|
452
|
+
const open = c.open_exceptions.filter((x) => isRecomputableException(x.kind));
|
|
453
|
+
const resolved = c.resolved_exceptions ?? [];
|
|
454
|
+
const problems = c.open_exceptions.filter((x) => x.status !== "open").map((x) => `open_exceptions lists ${x.kind} exception ${x.exception_id} with status ${x.status}`);
|
|
455
|
+
for (const d of derived) {
|
|
456
|
+
if (!open.some((x) => sameCondition(x, d)) && !resolved.some((x) => sameCondition(x, d)))
|
|
457
|
+
problems.push(`${d.kind} on ${d.delegation_id ?? c.task.task_id} holds but is neither open nor resolved: ${d.detail}`);
|
|
458
|
+
}
|
|
459
|
+
for (const x of open)
|
|
460
|
+
if (!derived.some((d) => sameCondition(x, d)))
|
|
461
|
+
problems.push(`${x.kind} exception ${x.exception_id} is open but its condition does not hold at generated_at`);
|
|
462
|
+
for (const x of resolved) {
|
|
463
|
+
if (x.status === "open" || x.resolved_by === "system")
|
|
464
|
+
problems.push(`resolved exception ${x.exception_id} must be resolved or dismissed by a person`);
|
|
465
|
+
if (!derived.some((d) => sameCondition(x, d)))
|
|
466
|
+
problems.push(`resolved exception ${x.exception_id} (${x.kind}) does not match a condition that holds at generated_at`);
|
|
467
|
+
}
|
|
468
|
+
if (problems.length > 0)
|
|
469
|
+
return check(name, problems);
|
|
470
|
+
return {
|
|
471
|
+
name,
|
|
472
|
+
ok: true,
|
|
473
|
+
details: [
|
|
474
|
+
`${derived.length} derived exception(s) recomputed as of generated_at: ${derived.length - resolved.length} open, ${resolved.length} resolved by a person`,
|
|
475
|
+
"not recomputed: witness_quorum_not_met (needs the service's verified domains) and exceptions raised at intake",
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Provider-signed outcome claims must verify against the listed key bindings, and only they may be labelled provider_key_signed. */
|
|
480
|
+
function signedClaimsCheck(c) {
|
|
481
|
+
const name = "signed_claims";
|
|
482
|
+
const delegationOf = new Map(c.delegations.map((d) => [d.delegation_id, d]));
|
|
483
|
+
const problems = [];
|
|
484
|
+
for (const claim of c.delivery_claims) {
|
|
485
|
+
const labelled = claim.assurance.includes("provider_key_signed");
|
|
486
|
+
if (!claim.signer) {
|
|
487
|
+
if (labelled)
|
|
488
|
+
problems.push(`${claim.type} claim ${claim.event_id} is labelled provider_key_signed without a signature`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const delegation = delegationOf.get(claim.delegation_id);
|
|
492
|
+
const problem = delegation ? outcomeSignatureProblem(claim, delegation, c.key_bindings) : `${claim.type} claim ${claim.event_id} names a delegation not in the closure`;
|
|
493
|
+
if (problem)
|
|
494
|
+
problems.push(problem);
|
|
495
|
+
else if (!labelled)
|
|
496
|
+
problems.push(`${claim.type} claim ${claim.event_id} is signed but not labelled provider_key_signed`);
|
|
497
|
+
}
|
|
498
|
+
if (problems.length > 0)
|
|
499
|
+
return check(name, problems);
|
|
500
|
+
const signed = c.delivery_claims.filter((claim) => claim.signer).length;
|
|
501
|
+
return { name, ok: true, details: [signed > 0 ? `${signed} provider-signed outcome claim(s) verify` : "no provider-signed outcome claims"] };
|
|
502
|
+
}
|
|
503
|
+
/** Fields the closure derives from its own records must be exactly what those records produce: each delegation's delivery status, the lineage summary and the disclosure lists. */
|
|
504
|
+
function derivedFieldsCheck(c) {
|
|
505
|
+
const problems = [];
|
|
506
|
+
for (const d of c.delegations) {
|
|
507
|
+
const expected = deliveryStatus(c.delivery_claims.filter((claim) => claim.delegation_id === d.delegation_id));
|
|
508
|
+
if (d.delivery_status !== expected)
|
|
509
|
+
problems.push(`delegation ${d.delegation_id} delivery status ${d.delivery_status} does not follow from its claims (${expected})`);
|
|
510
|
+
}
|
|
511
|
+
if (c.lineage.complete !== (c.lineage.capture_gaps.length === 0))
|
|
512
|
+
problems.push("lineage.complete does not match the capture gaps");
|
|
513
|
+
const unknownDownstream = c.delegations.filter((d) => d.downstream_visibility === "unknown").map((d) => d.delegation_id);
|
|
514
|
+
if (digestOf(unknownDownstream) !== digestOf(c.lineage.unknown_downstream))
|
|
515
|
+
problems.push("lineage.unknown_downstream does not match the delegations");
|
|
516
|
+
const disclosure = closureDisclosure({
|
|
517
|
+
task: c.task,
|
|
518
|
+
delegations: c.delegations,
|
|
519
|
+
claims: c.delivery_claims,
|
|
520
|
+
events: c.financial_events.map((e) => ({ record: e.record, attributed_to: e.attributed_to })),
|
|
521
|
+
responses: c.responses,
|
|
522
|
+
receipts: c.receipts,
|
|
523
|
+
...(["1.2", "1.3", "1.4"].includes(c.schema_version) ? {} : { generated_at: c.generated_at }),
|
|
524
|
+
});
|
|
525
|
+
if (digestOf(disclosure) !== digestOf(c.disclosure))
|
|
526
|
+
problems.push("disclosure lists do not match the closure's records");
|
|
527
|
+
return check("derived_fields", problems);
|
|
528
|
+
}
|
|
529
|
+
/** The closure's usage checks must be exactly what its pricing, usage claims, roll-up and responses produce. */
|
|
530
|
+
function usageChecksCheck(c, rollup) {
|
|
531
|
+
const name = "usage_checks";
|
|
532
|
+
const expected = usageChecksFor(c.delegations, c.delivery_claims, rollup, c.responses, c.receipts);
|
|
533
|
+
const recorded = c.usage_checks ?? [];
|
|
534
|
+
if (digestOf(expected) !== digestOf(recorded))
|
|
535
|
+
return check(name, ["usage_checks do not match the delegations' pricing, recorded usage and billed amounts"]);
|
|
536
|
+
if (recorded.length === 0)
|
|
537
|
+
return { name, ok: true, details: ["no delegation has both pricing and recorded usage"] };
|
|
538
|
+
const outside = recorded.filter((u) => u.within_tolerance === false || u.expected_minor === null).map((u) => u.delegation_id);
|
|
539
|
+
return { name, ok: true, details: [`${recorded.length} usage check(s) recomputed`, ...(outside.length > 0 ? [`outside tolerance or unpriced: ${outside.join(", ")}`] : [])] };
|
|
540
|
+
}
|
|
541
|
+
/** Signed estimates and holds must verify, and the expectation report must be exactly what the closure's records produce. */
|
|
542
|
+
function expectationsCheck(c, rollup) {
|
|
543
|
+
const name = "expectations";
|
|
544
|
+
const providerOf = new Map(c.delegations.map((d) => [d.delegation_id, d.provider_id]));
|
|
545
|
+
const problems = [];
|
|
546
|
+
for (const e of c.financial_events) {
|
|
547
|
+
if (!e.record.expectation)
|
|
548
|
+
continue;
|
|
549
|
+
const problem = expectationSignatureProblem(e.record, providerOf.get(e.attributed_to) ?? null, c.key_bindings);
|
|
550
|
+
if (problem)
|
|
551
|
+
problems.push(problem);
|
|
552
|
+
}
|
|
553
|
+
const expected = buildExpectationReport({ task: c.task, delegations: c.delegations, claims: c.delivery_claims, events: c.financial_events, rollup, key_bindings: c.key_bindings });
|
|
554
|
+
if (digestOf(expected ?? null) !== digestOf(c.expectation_report ?? null))
|
|
555
|
+
problems.push("expectation_report does not match the closure's estimates, holds and costs");
|
|
556
|
+
if (problems.length > 0)
|
|
557
|
+
return check(name, problems);
|
|
558
|
+
if (!expected)
|
|
559
|
+
return { name, ok: true, details: ["no estimates or holds"] };
|
|
560
|
+
const signed = expected.records.filter((r) => !r.assurance.includes("buyer_recorded")).length;
|
|
561
|
+
return { name, ok: true, details: [`${expected.records.length} estimate/hold record(s), ${signed} signed by the agent or a gateway; report recomputed`, "recorded only: nothing was enforced, blocked or reserved"] };
|
|
562
|
+
}
|
|
209
563
|
/**
|
|
210
564
|
* Delegations backed by clearing-network obligations. Every event recorded from the clearing journal must sit on a
|
|
211
565
|
* linked delegation. With the obligations' closure packages, each package must verify, contain the linked decision,
|
|
@@ -403,10 +757,12 @@ function responseProblems(c) {
|
|
|
403
757
|
problems.push(`response ${response.response_id} cites run ${s.execution.execution_id}, which is not the run recorded on delegation ${receipt.delegation_id}`);
|
|
404
758
|
}
|
|
405
759
|
const claimsKeySigned = response.assurance.includes("provider_key_signed");
|
|
406
|
-
if (!claimsKeySigned)
|
|
407
|
-
continue;
|
|
408
760
|
const sig = response.provider_signature;
|
|
409
761
|
const binding = sig ? c.key_bindings.find((b) => b.binding_id === sig.binding_id && b.key_id === sig.key_id) : undefined;
|
|
762
|
+
if (s.role === "witness")
|
|
763
|
+
problems.push(...witnessStatementProblems(response, delegations.get(receipt.delegation_id)?.provider_id ?? null, claimsKeySigned ? binding : undefined));
|
|
764
|
+
if (!claimsKeySigned)
|
|
765
|
+
continue;
|
|
410
766
|
if (!sig || !binding)
|
|
411
767
|
problems.push(`response ${response.response_id} claims provider_key_signed without a listed key binding`);
|
|
412
768
|
else if (binding.provider_id !== response.provider_id)
|
|
@@ -418,6 +774,28 @@ function responseProblems(c) {
|
|
|
418
774
|
}
|
|
419
775
|
return problems;
|
|
420
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* A witness statement is a signed_attestation that cites the run and at least one evidence item, signed with a
|
|
779
|
+
* domain-challenged key of a provider other than the delegation's own.
|
|
780
|
+
*/
|
|
781
|
+
function witnessStatementProblems(response, delegationProviderId, binding) {
|
|
782
|
+
const s = response.statement;
|
|
783
|
+
const problems = [];
|
|
784
|
+
const label = `witness response ${response.response_id}`;
|
|
785
|
+
if (s.response_type !== "signed_attestation")
|
|
786
|
+
problems.push(`${label} must be a signed_attestation`);
|
|
787
|
+
if (!s.execution)
|
|
788
|
+
problems.push(`${label} must cite the run it observed`);
|
|
789
|
+
if (s.evidence.length === 0)
|
|
790
|
+
problems.push(`${label} must cite the evidence it saw`);
|
|
791
|
+
if (!binding)
|
|
792
|
+
problems.push(`${label} must be signed with a listed key binding`);
|
|
793
|
+
else if (binding.method !== "domain_challenge")
|
|
794
|
+
problems.push(`${label} must be signed with a domain-challenged key`);
|
|
795
|
+
if (response.provider_id !== null && response.provider_id === delegationProviderId)
|
|
796
|
+
problems.push(`${label} is from the delegation's own provider`);
|
|
797
|
+
return problems;
|
|
798
|
+
}
|
|
421
799
|
function closureChainCheck(c, options) {
|
|
422
800
|
if (c.version === 1)
|
|
423
801
|
return check("version_chain", c.previous_closure_digest === null && c.previous_closure_id === null ? [] : ["version 1 must not reference a previous closure"]);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atcn/subledger",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.1",
|
|
4
|
+
"description": "The cost record for an AI agent job: matches charges to delegated work, flags what doesn't add up, and signs closures anyone can verify offline",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
"build": "tsc -p tsconfig.build.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@atcn/core": "1.
|
|
37
|
-
"@atcn/schema": "1.
|
|
36
|
+
"@atcn/core": "1.5.1",
|
|
37
|
+
"@atcn/schema": "1.5.1",
|
|
38
|
+
"@noble/curves": "^1.9.0",
|
|
39
|
+
"@noble/hashes": "^1.8.0",
|
|
38
40
|
"zod": "^4.1.0"
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rail_id": "atcn",
|
|
3
|
+
"rail_url": "https://github.com/fadnisnikhil/atcn",
|
|
4
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
5
|
+
"schema_version": "1.1",
|
|
6
|
+
"atcn_version": "1.5.0",
|
|
7
|
+
"method": "in-process run against the local runner's subledger (same rules as the hosted API): examples/local-runner/scripts/reconciliation.ts",
|
|
8
|
+
"verdicts": {
|
|
9
|
+
"BLOCK": "the attack does not change the books: refused or absorbed at intake",
|
|
10
|
+
"REVIEW": "recorded, with an open exception flagging it for a person"
|
|
11
|
+
},
|
|
12
|
+
"vectors": [
|
|
13
|
+
{
|
|
14
|
+
"vector_id": "atcn-recon-double-charge-replay-001",
|
|
15
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
16
|
+
"attack_class": "double_charge",
|
|
17
|
+
"expected_verdict": "BLOCK",
|
|
18
|
+
"result": "PASS",
|
|
19
|
+
"observed": {
|
|
20
|
+
"refused_steps": [],
|
|
21
|
+
"deduplicated_steps": [
|
|
22
|
+
4
|
|
23
|
+
],
|
|
24
|
+
"exception_kinds": [],
|
|
25
|
+
"net_cost_minor": {
|
|
26
|
+
"A": 1000
|
|
27
|
+
},
|
|
28
|
+
"closed_net_cost_minor": {
|
|
29
|
+
"A": 1000
|
|
30
|
+
},
|
|
31
|
+
"closures_verify_offline": true
|
|
32
|
+
},
|
|
33
|
+
"adapter_notes": ""
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"vector_id": "atcn-recon-double-charge-distinct-001",
|
|
37
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
38
|
+
"attack_class": "double_charge",
|
|
39
|
+
"expected_verdict": "REVIEW",
|
|
40
|
+
"result": "PASS",
|
|
41
|
+
"observed": {
|
|
42
|
+
"refused_steps": [],
|
|
43
|
+
"deduplicated_steps": [],
|
|
44
|
+
"exception_kinds": [
|
|
45
|
+
"amount_mismatch"
|
|
46
|
+
],
|
|
47
|
+
"net_cost_minor": {
|
|
48
|
+
"A": 2000
|
|
49
|
+
},
|
|
50
|
+
"closed_net_cost_minor": {
|
|
51
|
+
"A": 2000
|
|
52
|
+
},
|
|
53
|
+
"closures_verify_offline": true
|
|
54
|
+
},
|
|
55
|
+
"adapter_notes": ""
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"vector_id": "atcn-recon-cross-task-replay-001",
|
|
59
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
60
|
+
"attack_class": "cross_task_replay",
|
|
61
|
+
"expected_verdict": "BLOCK",
|
|
62
|
+
"result": "PASS",
|
|
63
|
+
"observed": {
|
|
64
|
+
"refused_steps": [
|
|
65
|
+
6
|
|
66
|
+
],
|
|
67
|
+
"deduplicated_steps": [],
|
|
68
|
+
"exception_kinds": [
|
|
69
|
+
"duplicate_event"
|
|
70
|
+
],
|
|
71
|
+
"net_cost_minor": {
|
|
72
|
+
"A": 1000,
|
|
73
|
+
"B": 0
|
|
74
|
+
},
|
|
75
|
+
"closed_net_cost_minor": {
|
|
76
|
+
"A": 1000,
|
|
77
|
+
"B": 0
|
|
78
|
+
},
|
|
79
|
+
"closures_verify_offline": true
|
|
80
|
+
},
|
|
81
|
+
"adapter_notes": ""
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"vector_id": "atcn-recon-refund-after-close-001",
|
|
85
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
86
|
+
"attack_class": "refund_after_close",
|
|
87
|
+
"expected_verdict": "REVIEW",
|
|
88
|
+
"result": "PASS",
|
|
89
|
+
"observed": {
|
|
90
|
+
"refused_steps": [],
|
|
91
|
+
"deduplicated_steps": [],
|
|
92
|
+
"exception_kinds": [
|
|
93
|
+
"refund_after_close"
|
|
94
|
+
],
|
|
95
|
+
"net_cost_minor": {
|
|
96
|
+
"A": 0
|
|
97
|
+
},
|
|
98
|
+
"closed_net_cost_minor": {
|
|
99
|
+
"A": 1000
|
|
100
|
+
},
|
|
101
|
+
"closures_verify_offline": true
|
|
102
|
+
},
|
|
103
|
+
"adapter_notes": ""
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"vector_id": "atcn-recon-charge-after-cancel-001",
|
|
107
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
108
|
+
"attack_class": "charge_after_cancel",
|
|
109
|
+
"expected_verdict": "REVIEW",
|
|
110
|
+
"result": "PASS",
|
|
111
|
+
"observed": {
|
|
112
|
+
"refused_steps": [],
|
|
113
|
+
"deduplicated_steps": [],
|
|
114
|
+
"exception_kinds": [
|
|
115
|
+
"charge_after_cancellation",
|
|
116
|
+
"missing_receipt"
|
|
117
|
+
],
|
|
118
|
+
"net_cost_minor": {
|
|
119
|
+
"A": 1000
|
|
120
|
+
},
|
|
121
|
+
"closed_net_cost_minor": {
|
|
122
|
+
"A": 1000
|
|
123
|
+
},
|
|
124
|
+
"closures_verify_offline": true
|
|
125
|
+
},
|
|
126
|
+
"adapter_notes": ""
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"vector_id": "atcn-recon-backdated-estimate-001",
|
|
130
|
+
"artefact_id": "atcn-reconciliation-v1",
|
|
131
|
+
"attack_class": "backdated_estimate",
|
|
132
|
+
"expected_verdict": "REVIEW",
|
|
133
|
+
"result": "PASS",
|
|
134
|
+
"observed": {
|
|
135
|
+
"refused_steps": [],
|
|
136
|
+
"deduplicated_steps": [],
|
|
137
|
+
"exception_kinds": [
|
|
138
|
+
"estimate_after_charge"
|
|
139
|
+
],
|
|
140
|
+
"net_cost_minor": {
|
|
141
|
+
"A": 1500
|
|
142
|
+
},
|
|
143
|
+
"closed_net_cost_minor": {
|
|
144
|
+
"A": 1500
|
|
145
|
+
},
|
|
146
|
+
"closures_verify_offline": true
|
|
147
|
+
},
|
|
148
|
+
"adapter_notes": ""
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|