@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/documents.js CHANGED
@@ -1,17 +1,20 @@
1
- import { AttestationRefSchema, ExecutionBindingSchema, ExecutionDescriptorSchema } from "@atcn/schema";
1
+ import { AttestationRefSchema, ExecutionBindingSchema, ExecutionDescriptorSchema, PricingSchema, RefundTermsSchema, SkillRefSchema, USAGE_METERS } from "@atcn/schema";
2
2
  import { z } from "zod";
3
- import { ASSURANCE_LABELS, ATTESTABLE_FIELDS, CLAIM_ASSERTERS, Currency, DELIVERY_EVENT_TYPES, EvidenceRefSchema, FINANCIAL_EVENT_TYPES, FxSchema, Minor, NORMALIZED_STATUSES, PAYERS, RESPONSE_TYPES } from "./types.js";
3
+ import { SubledgerWitnessPolicySchema, ExpectationSchema, KeySignerSchema, RailAttestationSchema, EXPECTATION_ISSUERS, HOLD_STATUSES, ASSURANCE_LABELS, ATTESTABLE_FIELDS, CLAIM_ASSERTERS, Currency, DELIVERY_EVENT_TYPES, EvidenceRefSchema, FINANCIAL_EVENT_TYPES, FxSchema, Minor, NORMALIZED_STATUSES, PAYERS, RESPONSE_TYPES, UsageRecordSchema, } from "./types.js";
4
4
  /**
5
5
  * Signed documents of the Agent Work Subledger (PRD v1.2 §6, §16).
6
6
  * Schema 1.3 adds closure `obligation_links`, the clearing-network claim asserters, and the `network_recorded` assurance label;
7
7
  * 1.2 documents must use neither. Schema 1.4 adds delegation `execution`, the response statement fields `execution`,
8
8
  * `issued_at`, `expires_at` and `refs`, and the `expired` and `revoked` labels; 1.2 and 1.3 documents must use none
9
- * of them (see packages/schema/COMPATIBILITY.md).
9
+ * of them. Schema 1.5 adds delegation `pricing`, `refund_terms` and `witness_policy`, claim `usage`, the `delivery.usage`
10
+ * field, closure `usage_checks`, `additional_models` in runs, financial event `skill`, the `pending_finality` status,
11
+ * the statement `role`, the `estimate` and `hold` event types with `expectation`, task `estimate_tolerance_bps` and
12
+ * closure `expectation_report`; earlier documents must use none of them (see packages/schema/COMPATIBILITY.md).
10
13
  */
11
- export const SUBLEDGER_SCHEMA_VERSION = "1.4";
12
- export const SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS = ["1.2", "1.3", "1.4"];
14
+ export const SUBLEDGER_SCHEMA_VERSION = "1.5";
15
+ export const SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS = ["1.2", "1.3", "1.4", "1.5"];
13
16
  /** Must equal this package's version in package.json (checked by a test). */
14
- export const SUBLEDGER_VERIFIER_VERSION = "1.4.0";
17
+ export const SUBLEDGER_VERIFIER_VERSION = "1.5.0";
15
18
  export const RECEIPT_DOCUMENT_TYPE = "atcn.subledger.receipt";
16
19
  export const CLOSURE_DOCUMENT_TYPE = "atcn.subledger.closure";
17
20
  export const RESPONSE_STATEMENT_TYPE = "atcn.subledger.receipt_response";
@@ -93,6 +96,12 @@ export const FinancialEventRecordSchema = z.object({
93
96
  settles_event_id: z.string().nullable(),
94
97
  fx: FxSchema.nullable(),
95
98
  reason: z.string().nullable(),
99
+ /** Schema 1.5, present only when stated. */
100
+ skill: SkillRefSchema.optional(),
101
+ /** Schema 1.5, estimates and holds only. */
102
+ expectation: ExpectationSchema.optional(),
103
+ /** Schema 1.5, payments and refunds only. */
104
+ rail_attestation: RailAttestationSchema.optional(),
96
105
  });
97
106
  export const DeliveryClaimSchema = z.object({
98
107
  event_id: z.string(),
@@ -107,9 +116,14 @@ export const DeliveryClaimSchema = z.object({
107
116
  retrospective: z.boolean(),
108
117
  occurred_at: Iso,
109
118
  recorded_at: Iso,
119
+ /** Schema 1.5, completion and partial_completion only, present only when recorded. */
120
+ usage: UsageRecordSchema.optional(),
121
+ /** Schema 1.5, a provider-signed outcome claim only. */
122
+ signer: KeySignerSchema.optional(),
110
123
  });
111
124
  export const FIELD_STATES = ["missing", "imported", "buyer_asserted", "provider_reported", "contested"];
112
- export const FieldStatusSchema = z.record(z.enum(ATTESTABLE_FIELDS), z.enum(FIELD_STATES));
125
+ /** Not exhaustive, because schema 1.5 added a field; the verifier checks the field set for the document's version. */
126
+ export const FieldStatusSchema = z.partialRecord(z.enum(ATTESTABLE_FIELDS), z.enum(FIELD_STATES));
113
127
  export const CorrectionSchema = z.object({ field: z.enum(ATTESTABLE_FIELDS), proposed_value: z.string().max(1000), reason: z.string().max(2000) });
114
128
  /** The exact statement a provider signs (or a link holder submits). Binds receipt, digest, revision, issuer tenant, type, and fields. */
115
129
  export const ResponseStatementSchema = z.object({
@@ -130,6 +144,11 @@ export const ResponseStatementSchema = z.object({
130
144
  expires_at: Iso.optional(),
131
145
  /** Schema 1.4: earlier statements this one revokes (same provider only) or disputes, by statement digest. */
132
146
  refs: z.array(AttestationRefSchema).max(20).optional(),
147
+ /**
148
+ * Schema 1.5: "witness" when an independent witness, not the provider, signs that it observed the run. A witness
149
+ * statement is a signed_attestation that cites the run and at least one evidence item.
150
+ */
151
+ role: z.literal("witness").optional(),
133
152
  });
134
153
  export const ResponseRecordSchema = z.object({
135
154
  response_id: z.string(),
@@ -192,6 +211,12 @@ export const ReceiptPayloadSchema = z.object({
192
211
  downstream_visibility: z.enum(["unknown", "disclosed", "none"]),
193
212
  /** Schema 1.4, present only when recorded: the run a provider statement can cite. */
194
213
  execution: ExecutionDescriptorSchema.optional(),
214
+ /** Schema 1.5, present only when agreed: the usage prices. */
215
+ pricing: PricingSchema.optional(),
216
+ /** Schema 1.5, present only when agreed: what happens on failure or timeout, and the refund budget. */
217
+ refund_terms: RefundTermsSchema.optional(),
218
+ /** Schema 1.5, present only when required: the independent witnesses the delegation needs. */
219
+ witness_policy: SubledgerWitnessPolicySchema.optional(),
195
220
  }),
196
221
  provider: z.object({
197
222
  provider_id: z.string().nullable(),
@@ -207,6 +232,8 @@ export const ReceiptPayloadSchema = z.object({
207
232
  unverified_fields: z.array(z.enum(ATTESTABLE_FIELDS)),
208
233
  corrections: z.array(z.object({ response_id: z.string(), receipt_revision: z.number().int().positive(), fields: z.array(z.enum(ATTESTABLE_FIELDS)), decision: z.enum(["accepted", "rejected", "open"]) })),
209
234
  lineage: z.object({ complete: z.boolean(), capture_gaps: z.array(CaptureGapSchema.omit({ delegation_id: true })) }),
235
+ /** Schema 1.5, present only when a claim or estimate on the receipt is signed: exactly the key bindings its signers name. */
236
+ key_bindings: z.array(KeyBindingRecordSchema).optional(),
210
237
  });
211
238
  export const SignedReceiptSchema = z.object({ payload: ReceiptPayloadSchema, signature: SignatureSchema, operator_signatures: z.array(OperatorSignatureSchema).optional() });
212
239
  // ---------- Private closure snapshot (full root task; PRD §4 step 6, §6) ----------
@@ -233,6 +260,81 @@ export const ClosureDelegationSchema = z.object({
233
260
  created_at: Iso,
234
261
  /** Schema 1.4, present only when recorded, so older closures keep their bytes. */
235
262
  execution: ExecutionDescriptorSchema.optional(),
263
+ /** Schema 1.5, present only when agreed. */
264
+ pricing: PricingSchema.optional(),
265
+ /** Schema 1.5, present only when agreed. */
266
+ refund_terms: RefundTermsSchema.optional(),
267
+ /** Schema 1.5, present only when required. */
268
+ witness_policy: SubledgerWitnessPolicySchema.optional(),
269
+ });
270
+ /** One delegation's usage priced at its agreed rates and compared with what was billed (schema 1.5). */
271
+ export const UsageCheckSchema = z.object({
272
+ delegation_id: z.string(),
273
+ currency: Currency,
274
+ /** Null when some usage has no rate. */
275
+ expected_minor: Minor.nullable(),
276
+ lines: z.array(z.object({
277
+ meter: z.enum(USAGE_METERS),
278
+ model: z.object({ provider: z.string(), name: z.string() }).optional(),
279
+ tool_name: z.string().optional(),
280
+ units: Minor,
281
+ cost_minor: Minor,
282
+ })),
283
+ billed_minor: Minor,
284
+ /** billed − expected; positive means billed above usage cost. */
285
+ difference_minor: Minor.nullable(),
286
+ allowed_difference_minor: Minor.nullable(),
287
+ within_tolerance: z.boolean().nullable(),
288
+ unpriced: z.array(z.string()),
289
+ trace_digests: z.array(Digest),
290
+ /** Labels of the usage claims, plus provider_key_signed when the provider signed an attestation of delivery.usage. */
291
+ assurance: Assurance,
292
+ });
293
+ /** Estimate and hold against actual cost for one node (the task itself or a delegation), in the node's currency. */
294
+ export const RailAttestationEntrySchema = z.object({
295
+ financial_event_id: z.string(),
296
+ scheme: z.string(),
297
+ rail: z.string(),
298
+ rail_ref: z.string(),
299
+ anchor: z.string(),
300
+ assurance: z.tuple([z.literal("rail_attested")]),
301
+ });
302
+ export const ExpectationVarianceSchema = z.object({
303
+ /** Latest estimate issued before the node's first charge that no later estimate replaced; null when none. */
304
+ estimated_minor: Minor.nullable(),
305
+ /** Open plus captured holds. */
306
+ held_minor: Minor,
307
+ /** Net cost (task: the whole tree's net cost). */
308
+ actual_minor: Minor,
309
+ /** actual − estimated, and the same in basis points of the estimate (null without an estimate, or when it is 0). */
310
+ variance_vs_estimate_minor: Minor.nullable(),
311
+ variance_vs_estimate_bps: z.number().int().nullable(),
312
+ variance_vs_hold_minor: Minor.nullable(),
313
+ variance_vs_hold_bps: z.number().int().nullable(),
314
+ });
315
+ /** Estimates and holds compared with actual cost (schema 1.5). Recorded only; nothing was enforced, blocked or reserved. */
316
+ export const ExpectationReportSchema = z.object({
317
+ currency: Currency,
318
+ task: ExpectationVarianceSchema.extend({
319
+ /** Net cost on nodes that have no estimate. */
320
+ unestimated_minor: Minor,
321
+ }),
322
+ nodes: z.array(ExpectationVarianceSchema.extend({ node_id: z.string(), estimate_event_id: z.string().nullable() })),
323
+ records: z.array(z.object({
324
+ financial_event_id: z.string(),
325
+ node_id: z.string(),
326
+ type: z.enum(["estimate", "hold"]),
327
+ issued_by: z.enum(EXPECTATION_ISSUERS),
328
+ /**
329
+ * "current" counts toward the node's figures (for estimates: the one used); "not_latest" is a current estimate
330
+ * replaced by a later one from another issuer; "superseded" was explicitly replaced; "after_charge" estimates are
331
+ * kept but not used; "other_currency" is not in the task's currency.
332
+ */
333
+ status: z.enum(["current", "not_latest", "superseded", "after_charge", "other_currency"]),
334
+ /** Holds only: the latest status, released when the work failed or was cancelled while the hold was open. */
335
+ hold_status: z.enum(HOLD_STATUSES).nullable(),
336
+ assurance: Assurance,
337
+ })),
236
338
  });
237
339
  export const AllocationRecordSchema = z.object({
238
340
  allocation_id: z.string(),
@@ -258,6 +360,8 @@ export const ExceptionRecordSchema = z.object({
258
360
  detail: z.string(),
259
361
  created_at: Iso,
260
362
  });
363
+ /** A derived exception a person resolved or dismissed while its condition still holds; schema 1.5 closures list these. */
364
+ export const ResolvedExceptionSchema = ExceptionRecordSchema.extend({ resolved_by: z.string(), resolved_at: Iso, resolution: z.string().nullable() });
261
365
  export const NodeRollupSchema = z.object({
262
366
  node_id: z.string(),
263
367
  parent_id: z.string().nullable(),
@@ -306,6 +410,8 @@ export const ClosurePayloadSchema = z.object({
306
410
  scope_ref: z.string().nullable(),
307
411
  retrospective: z.boolean(),
308
412
  created_at: Iso,
413
+ /** Schema 1.5, present only when set. */
414
+ estimate_tolerance_bps: z.number().int().nonnegative().optional(),
309
415
  }),
310
416
  delegations: z.array(ClosureDelegationSchema),
311
417
  delivery_claims: z.array(DeliveryClaimSchema),
@@ -320,5 +426,13 @@ export const ClosurePayloadSchema = z.object({
320
426
  disclosure: DisclosureSchema,
321
427
  /** Present only when at least one delegation is backed by an obligation, so older closures keep their bytes. */
322
428
  obligation_links: z.array(ObligationLinkSchema).optional(),
429
+ /** Schema 1.5, present only when at least one delegation has pricing and recorded usage. */
430
+ usage_checks: z.array(UsageCheckSchema).optional(),
431
+ /** Schema 1.5, present only when the task has estimates or holds. */
432
+ expectation_report: ExpectationReportSchema.optional(),
433
+ /** Schema 1.5, present only when a payment or refund carries a rail attestation: those that verify, labelled rail_attested. */
434
+ rail_attestations: z.array(RailAttestationEntrySchema).optional(),
435
+ /** Schema 1.5, present only when a person resolved a derived exception whose condition still held at generated_at. */
436
+ resolved_exceptions: z.array(ResolvedExceptionSchema).optional(),
323
437
  });
324
438
  export const SignedClosureSchema = z.object({ payload: ClosurePayloadSchema, signature: SignatureSchema, operator_signatures: z.array(OperatorSignatureSchema).optional() });
@@ -1,6 +1,7 @@
1
+ import { type Pricing, type RefundTerms, type SkillRef } from "@atcn/schema";
1
2
  import type { Rollup } from "./rollup.js";
2
- import type { DeliveryClaim } from "./documents.js";
3
- import type { ExceptionKind } from "./types.js";
3
+ import type { DeliveryClaim, KeyBindingRecord, ResponseRecord } from "./documents.js";
4
+ import { type ExceptionKind, type FinancialEventRecord, type SubledgerWitnessPolicy } from "./types.js";
4
5
  /** Exception kinds recomputed from current state; they resolve automatically when the condition clears. */
5
6
  export declare const DERIVED_EXCEPTION_KINDS: ExceptionKind[];
6
7
  export interface DerivedException {
@@ -15,6 +16,7 @@ export interface DeriveInput {
15
16
  task_id: string;
16
17
  currency: string;
17
18
  budget_minor: number | null;
19
+ estimate_tolerance_bps?: number;
18
20
  };
19
21
  delegations: {
20
22
  delegation_id: string;
@@ -22,16 +24,52 @@ export interface DeriveInput {
22
24
  quoted_max_minor: number | null;
23
25
  accepted_amount_minor: number | null;
24
26
  quote_valid_until: string | null;
27
+ expected_delivery: string | null;
28
+ pricing?: Pricing | null;
29
+ refund_terms?: RefundTerms | null;
30
+ execution?: {
31
+ skill?: SkillRef;
32
+ };
33
+ provider_id?: string | null;
34
+ witness_policy?: SubledgerWitnessPolicy | null;
25
35
  }[];
26
36
  claims: DeliveryClaim[];
37
+ /** The task's financial events with their current attribution. */
38
+ events: {
39
+ record: FinancialEventRecord;
40
+ attributed_to: string;
41
+ }[];
27
42
  rollup: Rollup;
28
43
  now: string;
44
+ /** Witness statements in effect, each with the registrable domain the service verified for its signing key. */
45
+ witnesses?: {
46
+ delegation_id: string;
47
+ witness_id: string;
48
+ domain: string | null;
49
+ }[];
50
+ /** Verified registrable domains of the buyer operator and of each provider (by provider_id), for witness independence. */
51
+ party_domains?: {
52
+ operator: string | null;
53
+ providers: Record<string, string | null>;
54
+ };
55
+ /** Responses to the task's receipts and the delegation each receipt covers, for conflicting_statements. */
56
+ responses?: ResponseRecord[];
57
+ receipts?: {
58
+ receipt_id: string;
59
+ delegation_id: string;
60
+ }[];
61
+ /** Key bindings for signed estimates and holds (they affect labels only, not exceptions). */
62
+ key_bindings?: KeyBindingRecord[];
63
+ /** True when deriving as the task is closed: an open hold with nothing charged is then flagged. */
64
+ closing?: boolean;
29
65
  }
30
66
  /**
31
67
  * Condition-based exceptions recomputed from current state (PRD §6). They resolve automatically when the
32
68
  * condition clears. Flags only: the product records an overrun, it never claims to have prevented spend.
33
69
  */
34
70
  export declare function deriveTaskExceptions(input: DeriveInput): DerivedException[];
71
+ /** Kinds the offline verifier cannot recompute: witness independence needs the domains the service verified. */
72
+ export declare const SERVICE_ONLY_EXCEPTION_KINDS: ExceptionKind[];
35
73
  /** The latest exception recorded under a derived exception's dedupe key. */
36
74
  export interface LatestException {
37
75
  status: string;
@@ -44,3 +82,10 @@ export interface LatestException {
44
82
  * otherwise it is opened, which is a no-op when one is already open.
45
83
  */
46
84
  export declare function derivedExceptionAction(latest: LatestException | null, derived: DerivedException): "keep" | "update_detail" | "open";
85
+ /**
86
+ * The latest exceptions under derived dedupe keys that a person's resolution keeps closed while the condition holds.
87
+ * A closure lists them so the offline verifier can tell a resolved condition from an omitted one.
88
+ */
89
+ export declare function resolutionsInForce<T extends LatestException & {
90
+ dedupe_key: string;
91
+ }>(derived: DerivedException[], exceptions: T[]): T[];
@@ -1,5 +1,26 @@
1
+ import { countIndependentWitnesses } from "@atcn/schema";
2
+ import { buildExpectationReport, expectationExceptions } from "./expectations.js";
3
+ import { deliveryStatus, statementConflicts } from "./projection.js";
4
+ import { SKILL_BILLED_EVENT_TYPES } from "./types.js";
5
+ import { billedMinor, usageCheckFor } from "./usage.js";
1
6
  /** Exception kinds recomputed from current state; they resolve automatically when the condition clears. */
2
- export const DERIVED_EXCEPTION_KINDS = ["budget_overrun", "missing_receipt", "amount_mismatch", "stale_quote"];
7
+ export const DERIVED_EXCEPTION_KINDS = [
8
+ "budget_overrun",
9
+ "missing_receipt",
10
+ "amount_mismatch",
11
+ "stale_quote",
12
+ "usage_unpriced",
13
+ "usage_cost_mismatch",
14
+ "refund_terms_breach",
15
+ "skill_price_mismatch",
16
+ "witness_quorum_not_met",
17
+ "conflicting_statements",
18
+ "actual_exceeds_estimate",
19
+ "actual_exceeds_hold",
20
+ "hold_not_released",
21
+ "estimate_after_charge",
22
+ "charge_after_cancellation",
23
+ ];
3
24
  /**
4
25
  * Condition-based exceptions recomputed from current state (PRD §6). They resolve automatically when the
5
26
  * condition clears. Flags only: the product records an overrun, it never claims to have prevented spend.
@@ -17,8 +38,7 @@ export function deriveTaskExceptions(input) {
17
38
  }
18
39
  for (const d of input.delegations) {
19
40
  const node = input.rollup.nodes.find((n) => n.node_id === d.delegation_id);
20
- const own = node?.direct[d.currency];
21
- const billed = own ? own.invoiced + own.charged + own.fees + own.adjustments - own.credits - own.refunded : 0;
41
+ const billed = billedMinor(node?.direct[d.currency]);
22
42
  const claims = input.claims.filter((c) => c.delegation_id === d.delegation_id);
23
43
  const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
24
44
  const active = claims.filter((c) => !superseded.has(c.event_id));
@@ -26,6 +46,15 @@ export function deriveTaskExceptions(input) {
26
46
  if (billed > 0 && !hasDeliveryReceipt) {
27
47
  result.push({ kind: "missing_receipt", dedupe_key: `missing_receipt:${d.delegation_id}`, delegation_id: d.delegation_id, detail: `billed ${billed} ${d.currency} with no completion receipt recorded` });
28
48
  }
49
+ const status = deliveryStatus(claims);
50
+ if ((status === "cancelled" || status === "provider_failed") && billed > 0) {
51
+ result.push({
52
+ kind: "charge_after_cancellation",
53
+ dedupe_key: `charge_after_cancellation:${d.delegation_id}`,
54
+ delegation_id: d.delegation_id,
55
+ detail: `the delegation is ${status.replace("_", " ")} but ${billed} ${d.currency} is still billed; a refund, credit or reversal of it would net it to zero`,
56
+ });
57
+ }
29
58
  const agreed = d.accepted_amount_minor ?? d.quoted_max_minor;
30
59
  if (agreed !== null && billed > agreed) {
31
60
  const basis = d.accepted_amount_minor !== null ? "accepted amount" : "quoted maximum";
@@ -35,9 +64,140 @@ export function deriveTaskExceptions(input) {
35
64
  if (d.quote_valid_until !== null && d.quote_valid_until < input.now && !accepted) {
36
65
  result.push({ kind: "stale_quote", dedupe_key: `stale_quote:${d.delegation_id}`, delegation_id: d.delegation_id, detail: `quote expired at ${d.quote_valid_until} without a recorded acceptance` });
37
66
  }
67
+ const usage = usageCheckFor({ delegation_id: d.delegation_id, currency: d.currency, pricing: d.pricing, claims, billed_minor: billed, provider_attested: false });
68
+ if (usage && usage.expected_minor === null) {
69
+ result.push({ kind: "usage_unpriced", dedupe_key: `usage_unpriced:${d.delegation_id}`, delegation_id: d.delegation_id, detail: `usage has no agreed rate: ${usage.unpriced.join(", ")}` });
70
+ }
71
+ else if (usage && usage.within_tolerance === false) {
72
+ const direction = usage.difference_minor > 0 ? "above" : "below";
73
+ result.push({
74
+ kind: "usage_cost_mismatch",
75
+ dedupe_key: `usage_cost_mismatch:${d.delegation_id}`,
76
+ delegation_id: d.delegation_id,
77
+ detail: `billed ${billed} ${d.currency} is ${direction} usage cost ${usage.expected_minor} by ${Math.abs(usage.difference_minor)}, more than the allowed ${usage.allowed_difference_minor}; traces ${usage.trace_digests.join(", ")}`,
78
+ });
79
+ }
80
+ const events = activeEventsOf(input.events, d.delegation_id);
81
+ const refundProblems = d.refund_terms ? refundTermsProblems(d.refund_terms, d.expected_delivery, d.currency, events, claims, input.now) : [];
82
+ if (refundProblems.length > 0) {
83
+ result.push({ kind: "refund_terms_breach", dedupe_key: `refund_terms_breach:${d.delegation_id}`, delegation_id: d.delegation_id, detail: refundProblems.join("; ") });
84
+ }
85
+ const agreedSkill = d.execution?.skill;
86
+ const offSkill = agreedSkill ? events.filter((e) => SKILL_BILLED_EVENT_TYPES.includes(e.type) && e.skill && !sameSkill(e.skill, agreedSkill)) : [];
87
+ if (offSkill.length > 0) {
88
+ const agreedPrice = d.accepted_amount_minor ?? d.quoted_max_minor;
89
+ const billedLines = offSkill.map((e) => `${e.type} ${e.financial_event_id} bills ${skillName(e.skill)} for ${e.amount_minor} ${e.currency}`);
90
+ result.push({
91
+ kind: "skill_price_mismatch",
92
+ dedupe_key: `skill_price_mismatch:${d.delegation_id}`,
93
+ delegation_id: d.delegation_id,
94
+ detail: `${billedLines.join("; ")}; the delegation agreed ${skillName(agreedSkill)}${agreedPrice !== null ? ` at ${agreedPrice} ${d.currency}` : ""}`,
95
+ });
96
+ }
97
+ const witnessProblem = d.witness_policy ? witnessQuorumProblem(d.witness_policy, d.delegation_id, d.provider_id ?? null, input) : null;
98
+ if (witnessProblem) {
99
+ result.push({ kind: "witness_quorum_not_met", dedupe_key: `witness_quorum_not_met:${d.delegation_id}`, delegation_id: d.delegation_id, detail: witnessProblem });
100
+ }
38
101
  }
102
+ const conflicts = statementConflicts(input.responses ?? [], input.now);
103
+ for (const d of input.delegations) {
104
+ const receiptIds = new Set((input.receipts ?? []).filter((r) => r.delegation_id === d.delegation_id).map((r) => r.receipt_id));
105
+ const onDelegation = conflicts.filter((c) => receiptIds.has(c.subject.slice("receipt:".length, c.subject.indexOf("@"))));
106
+ if (onDelegation.length > 0) {
107
+ result.push({
108
+ kind: "conflicting_statements",
109
+ dedupe_key: `conflicting_statements:${d.delegation_id}`,
110
+ delegation_id: d.delegation_id,
111
+ detail: onDelegation.map((c) => `${c.kind} on ${c.subject} between ${c.signers.join(", ")}`).join("; "),
112
+ });
113
+ }
114
+ }
115
+ const report = buildExpectationReport({
116
+ task: input.task,
117
+ delegations: input.delegations.map((d) => ({ delegation_id: d.delegation_id, provider_id: d.provider_id ?? null })),
118
+ claims: input.claims,
119
+ events: input.events,
120
+ rollup: input.rollup,
121
+ key_bindings: input.key_bindings ?? [],
122
+ });
123
+ if (report)
124
+ result.push(...expectationExceptions(report, input));
39
125
  return result;
40
126
  }
127
+ /** Why a delegation lacks its independent witnesses, or null when enough counted. */
128
+ function witnessQuorumProblem(policy, delegationId, providerId, input) {
129
+ const operatorDomain = input.party_domains?.operator ?? null;
130
+ const providerDomain = providerId ? (input.party_domains?.providers[providerId] ?? null) : null;
131
+ const unverified = [operatorDomain === null ? "the buyer operator" : null, providerDomain === null ? "the provider" : null].filter((p) => p !== null);
132
+ if (unverified.length > 0)
133
+ return `witness independence cannot be checked: ${unverified.join(" and ")} has no verified domain`;
134
+ const candidates = (input.witnesses ?? [])
135
+ .filter((w) => w.delegation_id === delegationId && w.witness_id !== providerId && (policy.witness_provider_ids ?? [w.witness_id]).includes(w.witness_id))
136
+ .map((w) => ({ witness_id: w.witness_id, domain: w.domain }));
137
+ const count = countIndependentWitnesses(candidates, [operatorDomain, providerDomain]);
138
+ if (count.counted.length >= policy.min_independent_witnesses)
139
+ return null;
140
+ const refused = count.refused.map((r) => `${r.witness_id} ${r.reason}`);
141
+ return `${count.counted.length} of ${policy.min_independent_witnesses} required independent witnesses attested${refused.length > 0 ? `; not counted: ${refused.join("; ")}` : ""}`;
142
+ }
143
+ function sameSkill(a, b) {
144
+ return a.namespace === b.namespace && a.skill_id === b.skill_id;
145
+ }
146
+ function skillName(skill) {
147
+ return `${skill.namespace}/${skill.skill_id}`;
148
+ }
149
+ /** A delegation's events that count: attributed to it, not reversed, and not reversals themselves. */
150
+ function activeEventsOf(events, delegationId) {
151
+ const reversed = new Set(events.map((e) => e.record.reverses_event_id).filter((id) => id !== null));
152
+ return events
153
+ .filter((e) => e.attributed_to === delegationId && e.record.type !== "reversal" && !reversed.has(e.record.financial_event_id))
154
+ .map((e) => e.record);
155
+ }
156
+ const sumOf = (events) => events.reduce((total, e) => total + e.amount_minor, 0);
157
+ /**
158
+ * Where the recorded refunds break the agreed refund terms: more refunded than the post-settlement cap, a refund
159
+ * outside the window, or a refund the terms require on failure or timeout that was not made by the window's end.
160
+ */
161
+ function refundTermsProblems(terms, expectedDelivery, currency, events, claims, now) {
162
+ const problems = [];
163
+ const windowMs = terms.after_settlement.window_seconds * 1000;
164
+ const cap = terms.after_settlement.cap_minor;
165
+ const payments = events.filter((e) => e.type === "payment_reported").sort((a, b) => Date.parse(a.event_date) - Date.parse(b.event_date));
166
+ const refunds = events.filter((e) => e.type === "refund");
167
+ const paid = sumOf(payments);
168
+ const refunded = sumOf(refunds);
169
+ if (refunded > cap)
170
+ problems.push(`refunded ${refunded} ${currency}, more than the agreed cap of ${cap}`);
171
+ const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
172
+ const active = claims.filter((c) => !superseded.has(c.event_id));
173
+ const status = deliveryStatus(claims);
174
+ const delivered = active.some((c) => c.type === "completion" || c.type === "partial_completion");
175
+ let trigger = null;
176
+ if ((status === "provider_failed" || status === "cancelled") && terms.on_failure === "refund") {
177
+ const failure = active.filter((c) => c.type === "provider_failure" || c.type === "cancellation").at(-1);
178
+ trigger = { reason: "failure", at: failure.occurred_at };
179
+ }
180
+ else if (!delivered && expectedDelivery !== null && expectedDelivery < now && terms.on_timeout === "refund") {
181
+ trigger = { reason: "timeout", at: expectedDelivery };
182
+ }
183
+ if (payments.length > 0) {
184
+ const windowStart = Math.max(Date.parse(payments[0].event_date), trigger ? Date.parse(trigger.at) : 0);
185
+ const windowEnd = new Date(windowStart + windowMs).toISOString();
186
+ for (const r of refunds) {
187
+ if (Date.parse(r.event_date) > windowStart + windowMs)
188
+ problems.push(`refund ${r.financial_event_id} on ${r.event_date} is after the refund window ended at ${windowEnd}`);
189
+ }
190
+ }
191
+ if (trigger && paid > 0) {
192
+ const required = Math.min(paid, cap);
193
+ const deadline = new Date(Date.parse(trigger.at) + windowMs).toISOString();
194
+ if (now > deadline && refunded < required)
195
+ problems.push(`the terms require a refund on ${trigger.reason}: ${required} ${currency} was due by ${deadline}, ${refunded} was refunded`);
196
+ }
197
+ return problems;
198
+ }
199
+ /** Kinds the offline verifier cannot recompute: witness independence needs the domains the service verified. */
200
+ export const SERVICE_ONLY_EXCEPTION_KINDS = ["witness_quorum_not_met"];
41
201
  /**
42
202
  * What to do with a derived exception given the latest one under its dedupe key: a person's resolution of this exact
43
203
  * condition stands ("keep"); an open exception whose condition changed shows the current detail ("update_detail");
@@ -50,3 +210,13 @@ export function derivedExceptionAction(latest, derived) {
50
210
  return "update_detail";
51
211
  return "open";
52
212
  }
213
+ /**
214
+ * The latest exceptions under derived dedupe keys that a person's resolution keeps closed while the condition holds.
215
+ * A closure lists them so the offline verifier can tell a resolved condition from an omitted one.
216
+ */
217
+ export function resolutionsInForce(derived, exceptions) {
218
+ return derived.flatMap((d) => {
219
+ const latest = exceptions.filter((x) => x.dedupe_key === d.dedupe_key).at(-1) ?? null;
220
+ return latest && derivedExceptionAction(latest, d) === "keep" ? [latest] : [];
221
+ });
222
+ }
@@ -0,0 +1,96 @@
1
+ import type { DeliveryClaim, ExpectationReport, KeyBindingRecord } from "./documents.js";
2
+ import { type Rollup } from "./rollup.js";
3
+ import type { AssuranceLabel, Expectation, ExpectationIssuer, FinancialEventRecord, HoldStatus } from "./types.js";
4
+ /**
5
+ * Estimates and holds (schema 1.5): what the agent, a budget gateway or the operator expected work to cost before it
6
+ * ran, compared with what it actually cost. Record only: ATCN never enforces, blocks or reserves anything.
7
+ */
8
+ export declare const EXPECTATION_STATEMENT_TYPE = "atcn.subledger.expectation";
9
+ /** What an agent or gateway signs. It covers every field of the record that carries meaning. */
10
+ export interface ExpectationStatement {
11
+ document_type: typeof EXPECTATION_STATEMENT_TYPE;
12
+ type: "estimate" | "hold";
13
+ source: string;
14
+ source_event_id: string;
15
+ provider_reference: string | null;
16
+ amount_minor: number;
17
+ currency: string;
18
+ issued_at: string;
19
+ issued_by: ExpectationIssuer;
20
+ source_ref: string | null;
21
+ basis: string | null;
22
+ expires_at: string | null;
23
+ supersedes: string | null;
24
+ hold_status: HoldStatus | null;
25
+ }
26
+ export interface ExpectationStatementInput {
27
+ type: "estimate" | "hold";
28
+ source: string;
29
+ source_event_id: string;
30
+ provider_reference?: string | null;
31
+ amount_minor: number;
32
+ currency: string;
33
+ /** The event_date of the record. */
34
+ issued_at: string;
35
+ expectation: Omit<Expectation, "signer">;
36
+ }
37
+ export declare function buildExpectationStatement(input: ExpectationStatementInput): ExpectationStatement;
38
+ /** The statement a stored estimate or hold record was signed over. */
39
+ export declare function expectationStatementOf(record: FinancialEventRecord): ExpectationStatement;
40
+ /** Agent- or gateway-side signing. The operator never holds the signer's private key. */
41
+ export declare function signExpectation(statement: ExpectationStatement, privateKey: string): string;
42
+ export declare function verifyExpectationSignature(statement: ExpectationStatement, signature: string, publicKey: string): boolean;
43
+ /**
44
+ * Why a signed estimate or hold does not verify, or null when it does (or carries no signature). The key must be bound
45
+ * to the named provider and not revoked when the record was issued; an agent's estimate must be signed by the
46
+ * provider of the delegation it is attributed to.
47
+ */
48
+ export declare function expectationSignatureProblem(record: FinancialEventRecord, delegationProviderId: string | null, keyBindings: KeyBindingRecord[]): string | null;
49
+ /** Labels are never collapsed: a verified agent signature is provider_key_signed, a gateway's is gateway_signed, anything else is buyer_recorded. */
50
+ export declare function expectationAssurance(record: FinancialEventRecord, delegationProviderId: string | null, keyBindings: KeyBindingRecord[]): AssuranceLabel[];
51
+ export interface ExpectationReportInput {
52
+ task: {
53
+ task_id: string;
54
+ currency: string;
55
+ };
56
+ delegations: {
57
+ delegation_id: string;
58
+ provider_id: string | null;
59
+ }[];
60
+ claims: DeliveryClaim[];
61
+ /** The task's financial events with their attribution (node ID: the task ID or a delegation ID). */
62
+ events: {
63
+ record: FinancialEventRecord;
64
+ attributed_to: string;
65
+ }[];
66
+ rollup: Rollup;
67
+ key_bindings: KeyBindingRecord[];
68
+ }
69
+ /** Variance in basis points of `base`, rounded half away from zero, using integers only. Null when there is no base. */
70
+ export declare function varianceBps(difference: number, base: number | null): number | null;
71
+ /**
72
+ * Estimates and holds compared with actual cost, per node and for the task, in the task's currency. Null when the task
73
+ * has no estimates or holds. A node's actual cost is its own net cost (what was billed against it directly).
74
+ */
75
+ export declare function buildExpectationReport(input: ExpectationReportInput): ExpectationReport | null;
76
+ export interface ExpectationException {
77
+ kind: "actual_exceeds_estimate" | "actual_exceeds_hold" | "hold_not_released" | "estimate_after_charge";
78
+ dedupe_key: string;
79
+ delegation_id: string | null;
80
+ detail: string;
81
+ }
82
+ /**
83
+ * Signals from the expectation report. Recorded, not prevented: none of them blocks work or changes a clearing outcome.
84
+ * `closing` marks the derivation done when the task is closed, where an open hold with nothing charged is flagged.
85
+ */
86
+ export declare function expectationExceptions(report: ExpectationReport, input: {
87
+ task: {
88
+ task_id: string;
89
+ estimate_tolerance_bps?: number;
90
+ };
91
+ events: {
92
+ record: FinancialEventRecord;
93
+ }[];
94
+ now: string;
95
+ closing?: boolean;
96
+ }): ExpectationException[];