@atcn/subledger 1.4.1 → 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/README.md +11 -6
- package/dist/documents.d.ts +1067 -26
- package/dist/documents.js +121 -7
- package/dist/exceptions.d.ts +47 -2
- package/dist/exceptions.js +173 -3
- package/dist/expectations.d.ts +96 -0
- package/dist/expectations.js +236 -0
- package/dist/importing.d.ts +53 -0
- package/dist/importing.js +192 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/outcome.d.ts +36 -0
- package/dist/outcome.js +64 -0
- package/dist/projection.d.ts +28 -3
- package/dist/projection.js +69 -4
- package/dist/rails.d.ts +108 -0
- package/dist/rails.js +242 -0
- package/dist/response.d.ts +2 -0
- package/dist/response.js +1 -0
- package/dist/rollup.d.ts +1 -1
- package/dist/rollup.js +10 -4
- package/dist/types.d.ts +279 -7
- package/dist/types.js +179 -6
- package/dist/usage.d.ts +41 -0
- package/dist/usage.js +86 -0
- package/dist/verify.d.ts +8 -1
- package/dist/verify.js +385 -7
- package/package.json +6 -4
- package/test-vectors/reconciliation-results.json +151 -0
- package/test-vectors/reconciliation.json +107 -0
- package/test-vectors/vectors.json +25666 -1
package/dist/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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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() });
|
package/dist/exceptions.d.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/exceptions.js
CHANGED
|
@@ -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 = [
|
|
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
|
|
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[];
|