@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.
@@ -0,0 +1,192 @@
1
+ import { derivedSourceEventId, majorToMinor, parseCsvRows, parseJsonlRows } from "./importing.js";
2
+ export const IMPORT_PRESETS = ["litellm", "openrouter", "stripe"];
3
+ /** Metadata key on Stripe PaymentIntents and transfers that holds the ATCN job reference. */
4
+ export const STRIPE_JOB_REF_KEY = "atcn_job_ref";
5
+ /** Rows of a JSON export: an array of objects, or an object wrapping one in `data` (LiteLLM) or `data.data` (OpenRouter). */
6
+ export function exportRowsFromJson(value) {
7
+ let items = value;
8
+ if (isObject(items) && "data" in items)
9
+ items = items.data;
10
+ if (isObject(items) && "data" in items)
11
+ items = items.data;
12
+ if (isObject(items))
13
+ items = [items];
14
+ if (!Array.isArray(items))
15
+ throw new Error("JSON export must be an array of rows or an object with a data array");
16
+ return items.map((item, index) => {
17
+ if (!isObject(item))
18
+ throw new Error(`row ${index + 1} is not a JSON object`);
19
+ return item;
20
+ });
21
+ }
22
+ /** Reads an export saved as JSON, JSONL or CSV. */
23
+ export function parseExportText(text) {
24
+ const trimmed = text.trim();
25
+ if (!trimmed.startsWith("[") && !trimmed.startsWith("{"))
26
+ return parseCsvRows(text);
27
+ let value;
28
+ try {
29
+ value = JSON.parse(trimmed);
30
+ }
31
+ catch {
32
+ return parseJsonlRows(text);
33
+ }
34
+ return exportRowsFromJson(value);
35
+ }
36
+ /** Converts rows of a known export into financial event bodies. */
37
+ export function importPresetRows(preset, rows, options = {}) {
38
+ const source = options.source ?? preset;
39
+ if (preset === "litellm")
40
+ return importDailySpend(rows, { ref: "end_user", date: "startTime", amount: "spend" }, source);
41
+ if (preset === "openrouter")
42
+ return importDailySpend(rows, { ref: "external_user", date: "date__day", amount: "total_usage" }, source);
43
+ return importStripeBalanceRows(rows, source);
44
+ }
45
+ /** Fixed-point scale for summing sub-cent USD spend: 18 decimal places. */
46
+ const SPEND_SCALE = 18;
47
+ function importDailySpend(rows, columns, source) {
48
+ const result = { events: [], errors: [], skipped: [] };
49
+ const groups = new Map();
50
+ rows.forEach((row, index) => {
51
+ const number = index + 1;
52
+ const ref = text(row[columns.ref]);
53
+ if (ref === null) {
54
+ result.skipped.push({ row: number, reason: `no ${columns.ref}: send the ATCN job reference as the request's user field` });
55
+ return;
56
+ }
57
+ try {
58
+ const day = utcIso(row[columns.date], columns.date).slice(0, 10);
59
+ const amount = spendToScaled(row[columns.amount], columns.amount);
60
+ const key = JSON.stringify([ref, day]);
61
+ const group = groups.get(key) ?? { row: number, ref, day, total: 0n };
62
+ group.total += amount;
63
+ groups.set(key, group);
64
+ }
65
+ catch (error) {
66
+ result.errors.push({ row: number, error: error.message });
67
+ }
68
+ });
69
+ const cent = 10n ** BigInt(SPEND_SCALE - 2);
70
+ for (const group of groups.values()) {
71
+ const amountMinor = Number((group.total + cent / 2n) / cent);
72
+ if (amountMinor === 0) {
73
+ result.skipped.push({ row: group.row, reason: `spend for ${group.ref} on ${group.day} rounds to 0 cents` });
74
+ continue;
75
+ }
76
+ result.events.push({
77
+ row: group.row,
78
+ event: {
79
+ type: "charge",
80
+ source,
81
+ source_event_id: derivedSourceEventId({ provider_job_ref: group.ref, day: group.day }),
82
+ provider_reference: null,
83
+ provider_status: null,
84
+ amount_minor: amountMinor,
85
+ currency: "USD",
86
+ event_date: `${group.day}T00:00:00.000Z`,
87
+ match: { provider_job_ref: group.ref, task_external_ref: null, delegation_external_ref: null },
88
+ },
89
+ });
90
+ }
91
+ result.events.sort((a, b) => a.row - b.row);
92
+ result.skipped.sort((a, b) => a.row - b.row);
93
+ return result;
94
+ }
95
+ /** A non-negative decimal (a JSON number or a string, exponent allowed) as an integer at SPEND_SCALE. */
96
+ function spendToScaled(value, column) {
97
+ const decimal = typeof value === "number" ? String(value) : typeof value === "string" ? value.trim() : "";
98
+ const match = /^(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(decimal);
99
+ if (!match || (match[1] === "" && !match[2]))
100
+ throw new Error(`${column} ${JSON.stringify(value)} is not a non-negative decimal number`);
101
+ const [, whole, fraction = "", exponent = "0"] = match;
102
+ const digits = BigInt(whole + fraction || "0");
103
+ const shift = SPEND_SCALE - fraction.length + Number(exponent);
104
+ if (shift >= 0)
105
+ return digits * 10n ** BigInt(shift);
106
+ const divisor = 10n ** BigInt(-shift);
107
+ return (digits + divisor / 2n) / divisor;
108
+ }
109
+ /** Stripe reporting categories that are job cost, the event type each becomes, and the column holding the job reference. */
110
+ const STRIPE_CATEGORIES = {
111
+ charge: { type: "charge", refColumn: `payment_metadata[${STRIPE_JOB_REF_KEY}]` },
112
+ refund: { type: "refund", refColumn: `payment_metadata[${STRIPE_JOB_REF_KEY}]` },
113
+ transfer: { type: "payment_reported", refColumn: `transfer_metadata[${STRIPE_JOB_REF_KEY}]` },
114
+ // A reversal event needs the ATCN id of the event it reverses, which the export cannot know: money a provider
115
+ // returns from a transfer is recorded as a refund.
116
+ transfer_reversal: { type: "refund", refColumn: `transfer_metadata[${STRIPE_JOB_REF_KEY}]` },
117
+ };
118
+ /** ISO 4217 currencies Stripe reports without minor units, and those with three decimal places. */
119
+ const ZERO_DECIMAL_CURRENCIES = ["BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"];
120
+ const THREE_DECIMAL_CURRENCIES = ["BHD", "JOD", "KWD", "OMR", "TND"];
121
+ function importStripeBalanceRows(rows, source) {
122
+ const result = { events: [], errors: [], skipped: [] };
123
+ rows.forEach((row, index) => {
124
+ const number = index + 1;
125
+ const category = text(row.reporting_category) ?? "";
126
+ const mapping = STRIPE_CATEGORIES[category];
127
+ if (!mapping) {
128
+ result.skipped.push({ row: number, reason: `reporting_category ${category || "(empty)"} is not job cost` });
129
+ return;
130
+ }
131
+ const ref = text(row[mapping.refColumn]);
132
+ if (ref === null) {
133
+ result.skipped.push({ row: number, reason: `no ${mapping.refColumn}` });
134
+ return;
135
+ }
136
+ try {
137
+ const sourceEventId = text(row.balance_transaction_id);
138
+ if (sourceEventId === null)
139
+ throw new Error("missing balance_transaction_id");
140
+ const currency = (text(row.currency) ?? "").toUpperCase();
141
+ if (!/^[A-Z]{3}$/.test(currency))
142
+ throw new Error(`currency ${JSON.stringify(row.currency ?? null)} is not an ISO 4217 code`);
143
+ const gross = text(row.gross);
144
+ if (gross === null)
145
+ throw new Error("missing gross");
146
+ const created = row.created_utc ?? row.created;
147
+ result.events.push({
148
+ row: number,
149
+ event: {
150
+ type: mapping.type,
151
+ source,
152
+ source_event_id: sourceEventId,
153
+ provider_reference: text(row.source_id),
154
+ provider_status: null,
155
+ amount_minor: Math.abs(stripeMajorToMinor(gross, currency)),
156
+ currency,
157
+ event_date: utcIso(created, row.created_utc !== undefined ? "created_utc" : "created"),
158
+ match: { provider_job_ref: ref, task_external_ref: null, delegation_external_ref: null },
159
+ },
160
+ });
161
+ }
162
+ catch (error) {
163
+ result.errors.push({ row: number, error: error.message });
164
+ }
165
+ });
166
+ return result;
167
+ }
168
+ /** Stripe reports amounts in major units; zeros past the currency's decimal places (JPY "500.00") are dropped. */
169
+ function stripeMajorToMinor(value, currency) {
170
+ const digits = ZERO_DECIMAL_CURRENCIES.includes(currency) ? 0 : THREE_DECIMAL_CURRENCIES.includes(currency) ? 3 : 2;
171
+ const [whole, fraction = ""] = value.split(".");
172
+ const trimmedFraction = fraction.length > digits ? fraction.slice(0, digits) + fraction.slice(digits).replace(/0+$/, "") : fraction;
173
+ return majorToMinor(trimmedFraction ? `${whole}.${trimmedFraction}` : whole, digits);
174
+ }
175
+ /** An ISO timestamp; a date or date-time without a zone is read as UTC, as LiteLLM and Stripe write them. */
176
+ function utcIso(value, column) {
177
+ const raw = typeof value === "string" ? value.trim() : "";
178
+ const zoneless = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$/.test(raw);
179
+ const time = Date.parse(zoneless ? `${raw.replace(" ", "T")}${raw.length > 10 ? "Z" : ""}` : raw);
180
+ if (raw === "" || Number.isNaN(time))
181
+ throw new Error(`${column} ${JSON.stringify(value ?? null)} is not a date`);
182
+ return new Date(time).toISOString();
183
+ }
184
+ function text(value) {
185
+ if (value === undefined || value === null)
186
+ return null;
187
+ const result = String(value).trim();
188
+ return result === "" ? null : result;
189
+ }
190
+ function isObject(value) {
191
+ return value !== null && typeof value === "object" && !Array.isArray(value);
192
+ }
@@ -1,6 +1,6 @@
1
- import { type AttestationItem } from "@atcn/schema";
1
+ import { type AttestationConflict, type AttestationItem } from "@atcn/schema";
2
2
  import { type Rollup } from "./rollup.js";
3
- import { type AllocationRecord, type CaptureGap, type ClosureDelegation, type ClosurePayload, type DeliveryClaim, type Disclosure, type ExceptionRecord, type FieldState, type Issuer, type KeyBindingRecord, type ObligationLink, type ReceiptPayload, type ReceiptTotals, type ResponseRecord } from "./documents.js";
3
+ import { type AllocationRecord, type CaptureGap, type ClosureDelegation, type ClosurePayload, type DeliveryClaim, type Disclosure, type ExceptionRecord, type FieldState, type Issuer, type KeyBindingRecord, type ObligationLink, type ReceiptPayload, type ReceiptTotals, type ResolvedException, type ResponseRecord } from "./documents.js";
4
4
  import { type AttestableField, type FinancialEventRecord } from "./types.js";
5
5
  /**
6
6
  * Pure builders for the two projections of one root task (PRD §16): the private closure snapshot
@@ -17,6 +17,8 @@ export interface TaskRecord {
17
17
  scope_ref: string | null;
18
18
  retrospective: boolean;
19
19
  created_at: string;
20
+ /** Schema 1.5, present only when set. */
21
+ estimate_tolerance_bps?: number;
20
22
  }
21
23
  export type DelegationRecord = ClosureDelegation & {
22
24
  root_task_id: string;
@@ -26,6 +28,12 @@ export type DelegationRecord = ClosureDelegation & {
26
28
  export declare function labelClaims(claims: DeliveryClaim[]): DeliveryClaim[];
27
29
  /** A response as an attestation. Only a key-signed statement has a signer, so only it can revoke or be revoked. */
28
30
  export declare function responseAttestation(response: ResponseRecord): AttestationItem;
31
+ /**
32
+ * Conflicts among key-signed statements (provider or witness) in effect at `at`. A signed_attestation confirms its
33
+ * fields and a propose_correction contests the corrected ones, per receipt revision: one key doing both is
34
+ * equivocation, two keys doing different things is disagreement, and a `disputes` ref is a dispute.
35
+ */
36
+ export declare function statementConflicts(responses: ResponseRecord[], at: string): AttestationConflict[];
29
37
  /** Recomputes the "expired" and "revoked" labels as of `at`. Revoked and expired responses stay visible. */
30
38
  export declare function labelResponses(responses: ResponseRecord[], at: string): ResponseRecord[];
31
39
  /** Delivery status is the latest non-superseded status claim. It is a recorded claim, not an adjudicated truth. */
@@ -58,7 +66,16 @@ export interface ReceiptInput {
58
66
  /** Responses to earlier revisions of this receipt chain. */
59
67
  prior_responses: ResponseRecord[];
60
68
  capture_gaps: CaptureGap[];
69
+ /** Every key binding known to the issuer; the receipt lists only those its signed claims and estimates name. */
70
+ key_bindings: KeyBindingRecord[];
61
71
  }
72
+ /** The key bindings named by the signers of these claims and events, each once, in binding_id order. */
73
+ export declare function signerKeyBindings(claims: {
74
+ signer?: {
75
+ binding_id: string;
76
+ key_id: string;
77
+ };
78
+ }[], events: Pick<FinancialEventRecord, "expectation">[], keyBindings: KeyBindingRecord[]): KeyBindingRecord[];
62
79
  /**
63
80
  * Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
64
81
  * allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
@@ -98,7 +115,15 @@ export interface ClosureInput {
98
115
  key_bindings: KeyBindingRecord[];
99
116
  capture_gaps: CaptureGap[];
100
117
  obligation_links?: ObligationLink[];
118
+ /** Derived exceptions a person resolved while their condition still holds (resolutionsInForce). */
119
+ resolved_exceptions?: ResolvedException[];
101
120
  }
102
- export declare function closureDisclosure(input: Pick<ClosureInput, "task" | "delegations" | "claims" | "events" | "responses" | "receipts">): Disclosure;
121
+ /**
122
+ * What the closure discloses as missing, unverified, contested, provider-reported and retrospective. With
123
+ * `generated_at` (schema 1.5), fields with conflicting signed statements at that time are contested too.
124
+ */
125
+ export declare function closureDisclosure(input: Pick<ClosureInput, "task" | "delegations" | "claims" | "events" | "responses" | "receipts"> & {
126
+ generated_at?: string;
127
+ }): Disclosure;
103
128
  /** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
104
129
  export declare function buildClosurePayload(input: ClosureInput): ClosurePayload;
@@ -1,7 +1,10 @@
1
- import { digestOf, resolveAttestations } from "@atcn/schema";
1
+ import { digestOf, disputesInEffect, findConflicts, inEffect, resolveAttestations, } from "@atcn/schema";
2
2
  import { computeRollup, costSign } from "./rollup.js";
3
3
  import { CLOSURE_DOCUMENT_TYPE, RECEIPT_DOCUMENT_TYPE, SUBLEDGER_SCHEMA_VERSION, } from "./documents.js";
4
+ import { buildExpectationReport } from "./expectations.js";
5
+ import { buildRailAttestationReport } from "./rails.js";
4
6
  import { ATTESTABLE_FIELDS } from "./types.js";
7
+ import { usageChecksFor } from "./usage.js";
5
8
  /** Claims superseded by a later correction stay visible and gain the "superseded" label. */
6
9
  export function labelClaims(claims) {
7
10
  const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
@@ -18,6 +21,34 @@ export function responseAttestation(response) {
18
21
  refs: s.refs,
19
22
  };
20
23
  }
24
+ /**
25
+ * Conflicts among key-signed statements (provider or witness) in effect at `at`. A signed_attestation confirms its
26
+ * fields and a propose_correction contests the corrected ones, per receipt revision: one key doing both is
27
+ * equivocation, two keys doing different things is disagreement, and a `disputes` ref is a dispute.
28
+ */
29
+ export function statementConflicts(responses, at) {
30
+ const signed = responses.filter((r) => r.assurance.includes("provider_key_signed"));
31
+ const items = signed.map(responseAttestation);
32
+ const resolution = resolveAttestations(items, at);
33
+ const claims = signed
34
+ .filter((r) => inEffect(resolution, r.statement_digest))
35
+ .flatMap((r) => {
36
+ const s = r.statement;
37
+ const claim = (field, status) => ({
38
+ digest: r.statement_digest,
39
+ signer: r.provider_id,
40
+ subject: `receipt:${r.receipt_id}@${r.receipt_revision}.${field}`,
41
+ status,
42
+ ...(s.execution ? { execution_digest: s.execution.execution_digest } : {}),
43
+ });
44
+ if (s.response_type === "signed_attestation")
45
+ return s.fields.map((f) => claim(f, "confirmed"));
46
+ if (s.response_type === "propose_correction")
47
+ return s.corrections.map((c) => claim(c.field, "corrected"));
48
+ return [];
49
+ });
50
+ return findConflicts(claims, disputesInEffect(items, resolution));
51
+ }
21
52
  const TIME_LABELS = ["expired", "revoked"];
22
53
  /** Recomputes the "expired" and "revoked" labels as of `at`. Revoked and expired responses stay visible. */
23
54
  export function labelResponses(responses, at) {
@@ -92,11 +123,19 @@ export function receiptFieldStatus(delegation, claims, events, priorResponses) {
92
123
  "scope.terms_digest": delegation.terms_digest ? "buyer_asserted" : "missing",
93
124
  "financial.amounts": events.some((e) => costSign(e.type) !== 0 || e.type === "quote") ? "imported" : "missing",
94
125
  "financial.status": events.some((e) => e.normalized_status !== "unknown") ? "imported" : "missing",
126
+ "delivery.usage": stateOf(active.filter((c) => c.usage !== undefined).at(-1)),
95
127
  };
96
128
  for (const field of openCorrectionFields(priorResponses))
97
129
  status[field] = "contested";
98
130
  return status;
99
131
  }
132
+ /** The key bindings named by the signers of these claims and events, each once, in binding_id order. */
133
+ export function signerKeyBindings(claims, events, keyBindings) {
134
+ const signers = [...claims.map((c) => c.signer), ...events.map((e) => e.expectation?.signer)].filter((s) => s !== undefined);
135
+ return keyBindings
136
+ .filter((b) => signers.some((s) => s.binding_id === b.binding_id && s.key_id === b.key_id))
137
+ .sort((a, b) => (a.binding_id < b.binding_id ? -1 : a.binding_id > b.binding_id ? 1 : 0));
138
+ }
100
139
  /**
101
140
  * Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
102
141
  * allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
@@ -105,6 +144,7 @@ export function buildReceiptPayload(input) {
105
144
  const d = input.delegation;
106
145
  const claims = labelClaims(input.claims);
107
146
  const fieldStatus = receiptFieldStatus(d, claims, input.events, input.prior_responses);
147
+ const keyBindings = signerKeyBindings(input.claims, input.events, input.key_bindings);
108
148
  return {
109
149
  document_type: RECEIPT_DOCUMENT_TYPE,
110
150
  schema_version: SUBLEDGER_SCHEMA_VERSION,
@@ -130,6 +170,9 @@ export function buildReceiptPayload(input) {
130
170
  retrospective: d.retrospective,
131
171
  downstream_visibility: d.downstream_visibility,
132
172
  ...(d.execution ? { execution: d.execution } : {}),
173
+ ...(d.pricing ? { pricing: d.pricing } : {}),
174
+ ...(d.refund_terms ? { refund_terms: d.refund_terms } : {}),
175
+ ...(d.witness_policy ? { witness_policy: d.witness_policy } : {}),
133
176
  },
134
177
  provider: {
135
178
  provider_id: input.provider?.provider_id ?? null,
@@ -149,6 +192,7 @@ export function buildReceiptPayload(input) {
149
192
  .filter((r) => r.statement.response_type === "propose_correction")
150
193
  .map((r) => ({ response_id: r.response_id, receipt_revision: r.receipt_revision, fields: r.statement.fields, decision: r.decision?.status ?? "open" })),
151
194
  lineage: { complete: input.capture_gaps.length === 0, capture_gaps: input.capture_gaps.map(({ delegation_id: _delegationId, ...gap }) => gap) },
195
+ ...(keyBindings.length > 0 ? { key_bindings: keyBindings } : {}),
152
196
  };
153
197
  }
154
198
  /** Latest allocation lines per financial event, the input the roll-up uses. */
@@ -169,6 +213,10 @@ export function rollupFor(task, delegations, events, allocations) {
169
213
  allocations: Object.fromEntries(Object.entries(latest).map(([id, a]) => [id, a.lines])),
170
214
  });
171
215
  }
216
+ /**
217
+ * What the closure discloses as missing, unverified, contested, provider-reported and retrospective. With
218
+ * `generated_at` (schema 1.5), fields with conflicting signed statements at that time are contested too.
219
+ */
172
220
  export function closureDisclosure(input) {
173
221
  const missing = [];
174
222
  const unverified = [];
@@ -211,10 +259,23 @@ export function closureDisclosure(input) {
211
259
  contested.push(`receipt:${r.receipt_id}.${c.field}`);
212
260
  }
213
261
  }
262
+ if (input.generated_at !== undefined) {
263
+ for (const conflict of statementConflicts(input.responses, input.generated_at)) {
264
+ const field = conflict.subject.replace(/@\d+\./, ".");
265
+ if (!contested.includes(field))
266
+ contested.push(field);
267
+ }
268
+ }
214
269
  return { missing, unverified, contested, provider_reported: providerReported, retrospective };
215
270
  }
216
271
  /** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
217
272
  export function buildClosurePayload(input) {
273
+ const claims = labelClaims(input.claims);
274
+ const rollup = rollupFor(input.task, input.delegations, input.events, input.allocations);
275
+ const responses = labelResponses(input.responses, input.generated_at);
276
+ const usageChecks = usageChecksFor(input.delegations, claims, rollup, responses, input.receipts);
277
+ const expectationReport = buildExpectationReport({ task: input.task, delegations: input.delegations, claims, events: input.events, rollup, key_bindings: input.key_bindings });
278
+ const railAttestations = buildRailAttestationReport(input.events);
218
279
  return {
219
280
  document_type: CLOSURE_DOCUMENT_TYPE,
220
281
  schema_version: SUBLEDGER_SCHEMA_VERSION,
@@ -226,13 +287,13 @@ export function buildClosurePayload(input) {
226
287
  issuer: input.issuer,
227
288
  task: input.task,
228
289
  delegations: input.delegations,
229
- delivery_claims: labelClaims(input.claims),
290
+ delivery_claims: claims,
230
291
  financial_events: input.events.map((e) => ({ record: e.record, event_digest: digestOf(e.record), attributed_to: e.attributed_to })),
231
292
  allocations: input.allocations,
232
- rollup: rollupFor(input.task, input.delegations, input.events, input.allocations),
293
+ rollup,
233
294
  open_exceptions: input.open_exceptions,
234
295
  receipts: input.receipts,
235
- responses: labelResponses(input.responses, input.generated_at),
296
+ responses,
236
297
  key_bindings: input.key_bindings,
237
298
  lineage: {
238
299
  complete: input.capture_gaps.length === 0,
@@ -241,5 +302,9 @@ export function buildClosurePayload(input) {
241
302
  },
242
303
  disclosure: closureDisclosure(input),
243
304
  ...(input.obligation_links && input.obligation_links.length > 0 ? { obligation_links: input.obligation_links } : {}),
305
+ ...(usageChecks.length > 0 ? { usage_checks: usageChecks } : {}),
306
+ ...(expectationReport ? { expectation_report: expectationReport } : {}),
307
+ ...(railAttestations ? { rail_attestations: railAttestations } : {}),
308
+ ...(input.resolved_exceptions && input.resolved_exceptions.length > 0 ? { resolved_exceptions: input.resolved_exceptions } : {}),
244
309
  };
245
310
  }
@@ -0,0 +1,108 @@
1
+ import type { FinancialEventRecord, RailAttestation } from "./types.js";
2
+ /**
3
+ * Rail attestations (schema 1.5): a payment rail's own record that a payment or refund happened, embedded in the
4
+ * financial event so the closure re-verifies it offline, without contacting the rail. ATCN never moves money; it checks
5
+ * what the rail recorded and labels the event rail_attested, separately from operator-reported payment status.
6
+ */
7
+ /** What a verified rail record proves. */
8
+ export interface RailFacts {
9
+ type: "payment_reported" | "refund";
10
+ amount_minor: number;
11
+ currency: string;
12
+ /** The rail's reference for the payment: an escrow id or a transaction hash. Recorded as the event's provider_reference. */
13
+ rail_ref: string;
14
+ /** The buyer's job reference the rail recorded (an A2A task id, an authorization nonce), when it records one. */
15
+ job_ref: string | null;
16
+ occurred_at: string | null;
17
+ }
18
+ export type RailVerification = {
19
+ ok: true;
20
+ rail: string;
21
+ facts: RailFacts;
22
+ anchor: string;
23
+ } | {
24
+ ok: false;
25
+ code: RailRefusalCode;
26
+ detail: string;
27
+ };
28
+ export type RailRefusalCode = "unsupported_scheme" | "malformed" | "data_hash_mismatch" | "merkle_proof_invalid" | "signature_invalid" | "settlement_not_successful" | "unsupported_asset";
29
+ /** Verifies one rail's records offline. Add a rail by implementing this and listing it in RAIL_IMPORTERS. */
30
+ export interface RailAttestationImporter {
31
+ rail: string;
32
+ schemes: readonly string[];
33
+ verify(attestation: RailAttestation): RailVerification;
34
+ }
35
+ export declare const A2A_SE_RELEASE_SCHEME = "urn:a2a-se:escrow-release-attestation:v1";
36
+ export declare const A2A_SE_REFUND_SCHEME = "urn:a2a-se:escrow-refund-attestation:v1";
37
+ /** Python's json.dumps(value, sort_keys=True, separators=(",", ":")), which A2A-SE hashes: keys by code point, non-ASCII escaped. */
38
+ export declare function pythonCanonicalJson(value: unknown): string;
39
+ /**
40
+ * A record from GET /v1/exchange/escrow/{escrow_id}/attestations: { leaf_index, data_hash, merkle_root, proof, schema_id,
41
+ * payload }. The payload's leaf hash must be data_hash, and folding the proof must give merkle_root. A2A-SE attestations
42
+ * are not signed; the anchor is the Merkle root of the exchange's append-only log, which the reader should compare with
43
+ * the root the exchange publishes.
44
+ */
45
+ export declare const a2aSeImporter: RailAttestationImporter;
46
+ export declare const X402_EXACT_EVM_SCHEME = "x402:exact-evm:v2";
47
+ /** USD stablecoins, by CAIP-2 network and token address (lowercase). ATCN records them as USD cents at 1:1. */
48
+ export declare const X402_USD_ASSETS: Record<string, {
49
+ name: string;
50
+ decimals: number;
51
+ }>;
52
+ interface X402Authorization {
53
+ from: string;
54
+ to: string;
55
+ value: string;
56
+ validAfter: string;
57
+ validBefore: string;
58
+ nonce: string;
59
+ }
60
+ /** The EIP-712 digest a payer signs for an EIP-3009 transferWithAuthorization (the x402 "exact" EVM scheme). */
61
+ export declare function eip3009Digest(authorization: X402Authorization, domain: {
62
+ name: string;
63
+ version: string;
64
+ chainId: bigint;
65
+ verifyingContract: string;
66
+ }): Uint8Array;
67
+ /** The address that made a 65-byte (r, s, v) secp256k1 signature over a digest. */
68
+ export declare function recoverAddress(digest: Uint8Array, signature: string): string;
69
+ /**
70
+ * A settled x402 payment: { requirements, payload, settlement } as the client and facilitator exchanged them. The payer's
71
+ * EIP-3009 authorization signature must recover its `from` address and authorize exactly the required amount to payTo.
72
+ * The facilitator's settlement response supplies success and the transaction hash; on-chain inclusion is not checked
73
+ * offline.
74
+ */
75
+ export declare const x402Importer: RailAttestationImporter;
76
+ export declare const RAIL_IMPORTERS: readonly RailAttestationImporter[];
77
+ export declare function verifyRailAttestation(attestation: RailAttestation, importers?: readonly RailAttestationImporter[]): RailVerification;
78
+ /** Why a financial event's rail attestation does not support it, or null when it verifies and agrees with the event. */
79
+ export declare function railAttestationProblem(event: Pick<FinancialEventRecord, "type" | "amount_minor" | "currency" | "provider_reference" | "rail_attestation">): {
80
+ code: string;
81
+ detail: string;
82
+ } | null;
83
+ export interface RailAttestationEntry {
84
+ financial_event_id: string;
85
+ scheme: string;
86
+ rail: string;
87
+ rail_ref: string;
88
+ anchor: string;
89
+ assurance: ["rail_attested"];
90
+ }
91
+ /** The closure's rail_attestations: one entry per event whose embedded attestation verifies and agrees with it; null when none carry one. */
92
+ export declare function buildRailAttestationReport(events: {
93
+ record: FinancialEventRecord;
94
+ }[]): RailAttestationEntry[] | null;
95
+ /**
96
+ * Buyer side: the financial event body for a rail record, after verifying it. Throws RailAttestationError with the
97
+ * refusal code when it does not verify. Matching uses the job reference the rail recorded unless `match` is given.
98
+ */
99
+ export declare function financialEventFromRailAttestation(attestation: RailAttestation, options: {
100
+ source: string;
101
+ match?: Record<string, string>;
102
+ eventDate?: string;
103
+ }): Record<string, unknown>;
104
+ export declare class RailAttestationError extends Error {
105
+ readonly code: string;
106
+ constructor(code: string, detail: string);
107
+ }
108
+ export {};