@atcn/subledger 1.4.0 → 1.5.0

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/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 { labelResponses, receiptTotals, responseAttestation, rollupFor } from "./projection.js";
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 { ATTESTABLE_FIELDS } from "./types.js";
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 expectedUnverified = ATTESTABLE_FIELDS.filter((f) => r.field_status[f] !== "missing");
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 ATTESTABLE_FIELDS)
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.0",
4
- "description": "Agent Work Subledger: deterministic attribution, roll-up, exceptions, signed receipts and closures, and offline verification",
3
+ "version": "1.5.0",
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.4.0",
37
- "@atcn/schema": "1.4.0",
36
+ "@atcn/core": "1.5.0",
37
+ "@atcn/schema": "1.5.0",
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
+ }