@atcn/subledger 1.3.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/LICENSE +202 -0
- package/README.md +22 -0
- package/dist/allocation.d.ts +29 -0
- package/dist/allocation.js +69 -0
- package/dist/bridge.d.ts +104 -0
- package/dist/bridge.js +161 -0
- package/dist/documents.d.ts +1877 -0
- package/dist/documents.js +310 -0
- package/dist/exceptions.d.ts +46 -0
- package/dist/exceptions.js +52 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/matching.d.ts +12 -0
- package/dist/matching.js +15 -0
- package/dist/projection.d.ts +99 -0
- package/dist/projection.js +218 -0
- package/dist/response.d.ts +24 -0
- package/dist/response.js +34 -0
- package/dist/rollup.d.ts +78 -0
- package/dist/rollup.js +176 -0
- package/dist/types.d.ts +280 -0
- package/dist/types.js +188 -0
- package/dist/verify.d.ts +30 -0
- package/dist/verify.js +372 -0
- package/package.json +40 -0
- package/test-vectors/vectors.json +108 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
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
|
+
/**
|
|
4
|
+
* Signed documents of the Agent Work Subledger (PRD v1.2 §6, §16).
|
|
5
|
+
* Schema 1.3 adds closure `obligation_links`, the clearing-network claim asserters, and the `network_recorded` assurance label;
|
|
6
|
+
* 1.2 documents must use neither (see packages/schema/COMPATIBILITY.md).
|
|
7
|
+
*/
|
|
8
|
+
export const SUBLEDGER_SCHEMA_VERSION = "1.3";
|
|
9
|
+
export const SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS = ["1.2", "1.3"];
|
|
10
|
+
/** Must equal this package's version in package.json (checked by a test). */
|
|
11
|
+
export const SUBLEDGER_VERIFIER_VERSION = "1.3.0";
|
|
12
|
+
export const RECEIPT_DOCUMENT_TYPE = "atcn.subledger.receipt";
|
|
13
|
+
export const CLOSURE_DOCUMENT_TYPE = "atcn.subledger.closure";
|
|
14
|
+
export const RESPONSE_STATEMENT_TYPE = "atcn.subledger.receipt_response";
|
|
15
|
+
export const SIGNED_BY_HOSTED_SERVICE = "atcn-hosted-service";
|
|
16
|
+
export const SIGNED_BY_LOCAL_RUNNER = "atcn-local-runner";
|
|
17
|
+
const Iso = z.string().min(1);
|
|
18
|
+
const Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
19
|
+
const Assurance = z.array(z.enum(ASSURANCE_LABELS));
|
|
20
|
+
export const SignatureSchema = z.object({
|
|
21
|
+
key_id: z.string(),
|
|
22
|
+
key_version: z.number().int().positive(),
|
|
23
|
+
algorithm: z.literal("Ed25519"),
|
|
24
|
+
value: z.string(),
|
|
25
|
+
});
|
|
26
|
+
/** A countersignature by the operator's own key over the same canonical payload bytes the service signed. */
|
|
27
|
+
export const OperatorSignatureSchema = z.object({
|
|
28
|
+
key_id: z.string(),
|
|
29
|
+
algorithm: z.literal("Ed25519"),
|
|
30
|
+
value: z.string(),
|
|
31
|
+
/** Recorded by the service when the countersignature was submitted; not covered by the signature. */
|
|
32
|
+
signed_at: Iso,
|
|
33
|
+
});
|
|
34
|
+
/** A published operator public key (GET /v1/operators/{operator_id}/keys). The private key never reaches ATCN. */
|
|
35
|
+
export const OperatorKeyRecordSchema = z.object({
|
|
36
|
+
operator_id: z.string(),
|
|
37
|
+
key_id: z.string(),
|
|
38
|
+
algorithm: z.literal("Ed25519"),
|
|
39
|
+
public_key: z.string(),
|
|
40
|
+
created_at: Iso,
|
|
41
|
+
revoked_at: Iso.nullable(),
|
|
42
|
+
});
|
|
43
|
+
export const IssuerSchema = z.object({
|
|
44
|
+
operator_id: z.string(),
|
|
45
|
+
operator_name: z.string(),
|
|
46
|
+
/**
|
|
47
|
+
* The hosted service signs on the operator's behalf with its published key. The local reference runner signs
|
|
48
|
+
* with a key generated on the developer's machine, which only that developer can vouch for.
|
|
49
|
+
*/
|
|
50
|
+
signed_by: z.enum([SIGNED_BY_HOSTED_SERVICE, SIGNED_BY_LOCAL_RUNNER]),
|
|
51
|
+
});
|
|
52
|
+
export const TotalsSchema = z.object({
|
|
53
|
+
quoted: Minor,
|
|
54
|
+
accepted: Minor,
|
|
55
|
+
invoiced: Minor,
|
|
56
|
+
charged: Minor,
|
|
57
|
+
fees: Minor,
|
|
58
|
+
adjustments: Minor,
|
|
59
|
+
refunded: Minor,
|
|
60
|
+
credits: Minor,
|
|
61
|
+
reported_paid: Minor,
|
|
62
|
+
net_cost: Minor,
|
|
63
|
+
unresolved: Minor,
|
|
64
|
+
downstream_reported: Minor,
|
|
65
|
+
allocated: Minor,
|
|
66
|
+
unallocated: Minor,
|
|
67
|
+
});
|
|
68
|
+
export const CurrencyTotalsSchema = z.record(Currency, TotalsSchema);
|
|
69
|
+
export const ReceiptTotalsSchema = z.record(Currency, TotalsSchema.omit({ allocated: true, unallocated: true }));
|
|
70
|
+
export const FinancialEventRecordSchema = z.object({
|
|
71
|
+
financial_event_id: z.string(),
|
|
72
|
+
type: z.enum(FINANCIAL_EVENT_TYPES),
|
|
73
|
+
source: z.string(),
|
|
74
|
+
source_event_id: z.string(),
|
|
75
|
+
provider_id: z.string().nullable(),
|
|
76
|
+
provider_reference: z.string().nullable(),
|
|
77
|
+
amount_minor: Minor,
|
|
78
|
+
currency: Currency,
|
|
79
|
+
event_date: Iso,
|
|
80
|
+
imported_at: Iso,
|
|
81
|
+
provider_status: z.string().nullable(),
|
|
82
|
+
normalized_status: z.enum(NORMALIZED_STATUSES),
|
|
83
|
+
evidence: EvidenceRefSchema.nullable(),
|
|
84
|
+
retrospective: z.boolean(),
|
|
85
|
+
payer: z.enum(PAYERS),
|
|
86
|
+
liability_owner: z.string().nullable(),
|
|
87
|
+
economic_event_id: z.string().nullable(),
|
|
88
|
+
included_in_event_id: z.string().nullable(),
|
|
89
|
+
reverses_event_id: z.string().nullable(),
|
|
90
|
+
settles_event_id: z.string().nullable(),
|
|
91
|
+
fx: FxSchema.nullable(),
|
|
92
|
+
reason: z.string().nullable(),
|
|
93
|
+
});
|
|
94
|
+
export const DeliveryClaimSchema = z.object({
|
|
95
|
+
event_id: z.string(),
|
|
96
|
+
delegation_id: z.string(),
|
|
97
|
+
type: z.enum(DELIVERY_EVENT_TYPES),
|
|
98
|
+
asserted_by: z.enum(CLAIM_ASSERTERS),
|
|
99
|
+
assurance: Assurance,
|
|
100
|
+
note: z.string().nullable(),
|
|
101
|
+
evidence: z.array(EvidenceRefSchema),
|
|
102
|
+
supersedes_event_id: z.string().nullable(),
|
|
103
|
+
reason: z.string().nullable(),
|
|
104
|
+
retrospective: z.boolean(),
|
|
105
|
+
occurred_at: Iso,
|
|
106
|
+
recorded_at: Iso,
|
|
107
|
+
});
|
|
108
|
+
export const FIELD_STATES = ["missing", "imported", "buyer_asserted", "provider_reported", "contested"];
|
|
109
|
+
export const FieldStatusSchema = z.record(z.enum(ATTESTABLE_FIELDS), z.enum(FIELD_STATES));
|
|
110
|
+
export const CorrectionSchema = z.object({ field: z.enum(ATTESTABLE_FIELDS), proposed_value: z.string().max(1000), reason: z.string().max(2000) });
|
|
111
|
+
/** The exact statement a provider signs (or a link holder submits). Binds receipt, digest, revision, issuer tenant, type, and fields. */
|
|
112
|
+
export const ResponseStatementSchema = z.object({
|
|
113
|
+
document_type: z.literal(RESPONSE_STATEMENT_TYPE),
|
|
114
|
+
receipt_id: z.string(),
|
|
115
|
+
receipt_digest: Digest,
|
|
116
|
+
receipt_revision: z.number().int().positive(),
|
|
117
|
+
issuer_operator_id: z.string(),
|
|
118
|
+
response_type: z.enum(RESPONSE_TYPES),
|
|
119
|
+
fields: z.array(z.enum(ATTESTABLE_FIELDS)),
|
|
120
|
+
note: z.string().max(2000).nullable(),
|
|
121
|
+
evidence: z.array(EvidenceRefSchema).max(20),
|
|
122
|
+
corrections: z.array(CorrectionSchema).max(20),
|
|
123
|
+
});
|
|
124
|
+
export const ResponseRecordSchema = z.object({
|
|
125
|
+
response_id: z.string(),
|
|
126
|
+
receipt_id: z.string(),
|
|
127
|
+
receipt_revision: z.number().int().positive(),
|
|
128
|
+
provider_id: z.string().nullable(),
|
|
129
|
+
statement: ResponseStatementSchema,
|
|
130
|
+
statement_digest: Digest,
|
|
131
|
+
provider_signature: z.object({ key_id: z.string(), binding_id: z.string(), value: z.string() }).nullable(),
|
|
132
|
+
assurance: Assurance,
|
|
133
|
+
decision: z.object({ status: z.enum(["accepted", "rejected"]), reason: z.string(), decided_by: z.string(), decided_at: Iso }).nullable(),
|
|
134
|
+
created_at: Iso,
|
|
135
|
+
});
|
|
136
|
+
export const KeyBindingRecordSchema = z.object({
|
|
137
|
+
binding_id: z.string(),
|
|
138
|
+
provider_id: z.string(),
|
|
139
|
+
key_id: z.string(),
|
|
140
|
+
public_key: z.string(),
|
|
141
|
+
method: z.enum(["operator_configured", "domain_challenge"]),
|
|
142
|
+
/** The domain whose DNS TXT record named this key (domain_challenge bindings only). */
|
|
143
|
+
domain: z.string().nullable().optional(),
|
|
144
|
+
created_by: z.string(),
|
|
145
|
+
created_at: Iso,
|
|
146
|
+
revoked_at: Iso.nullable(),
|
|
147
|
+
});
|
|
148
|
+
export const CaptureGapSchema = z.object({
|
|
149
|
+
gap_id: z.string(),
|
|
150
|
+
delegation_id: z.string().nullable(),
|
|
151
|
+
kind: z.string(),
|
|
152
|
+
detail: z.string(),
|
|
153
|
+
reported_at: Iso,
|
|
154
|
+
});
|
|
155
|
+
// ---------- Provider receipt (one delegation; PRD §16 "Receipt contents") ----------
|
|
156
|
+
export const ReceiptFinancialEventSchema = FinancialEventRecordSchema.omit({ liability_owner: true, economic_event_id: true, fx: true }).extend({
|
|
157
|
+
allocation_version: z.number().int().nonnegative(),
|
|
158
|
+
});
|
|
159
|
+
export const ReceiptPayloadSchema = z.object({
|
|
160
|
+
document_type: z.literal(RECEIPT_DOCUMENT_TYPE),
|
|
161
|
+
schema_version: z.enum(SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS),
|
|
162
|
+
receipt_id: z.string(),
|
|
163
|
+
revision: z.number().int().positive(),
|
|
164
|
+
previous_receipt_id: z.string().nullable(),
|
|
165
|
+
previous_receipt_digest: Digest.nullable(),
|
|
166
|
+
issued_at: Iso,
|
|
167
|
+
expires_at: Iso.nullable(),
|
|
168
|
+
issuer: IssuerSchema,
|
|
169
|
+
delegation: z.object({
|
|
170
|
+
delegation_id: z.string(),
|
|
171
|
+
root_task_id: z.string(),
|
|
172
|
+
external_ref: z.string().nullable(),
|
|
173
|
+
provider_job_ref: z.string().nullable(),
|
|
174
|
+
shared_description: z.string().nullable(),
|
|
175
|
+
terms_digest: Digest.nullable(),
|
|
176
|
+
currency: Currency,
|
|
177
|
+
quoted_max_minor: Minor.nullable(),
|
|
178
|
+
quote_basis: z.string().nullable(),
|
|
179
|
+
accepted_amount_minor: Minor.nullable(),
|
|
180
|
+
expected_delivery: Iso.nullable(),
|
|
181
|
+
retrospective: z.boolean(),
|
|
182
|
+
downstream_visibility: z.enum(["unknown", "disclosed", "none"]),
|
|
183
|
+
}),
|
|
184
|
+
provider: z.object({
|
|
185
|
+
provider_id: z.string().nullable(),
|
|
186
|
+
name_stated: z.string().nullable(),
|
|
187
|
+
provider_own_id: z.string().nullable(),
|
|
188
|
+
identity_binding: z.enum(["key_bound", "not_bound"]),
|
|
189
|
+
}),
|
|
190
|
+
delivery_claims: z.array(DeliveryClaimSchema.omit({ delegation_id: true })),
|
|
191
|
+
financial_events: z.array(ReceiptFinancialEventSchema),
|
|
192
|
+
totals: ReceiptTotalsSchema,
|
|
193
|
+
field_status: FieldStatusSchema,
|
|
194
|
+
/** Every non-missing field is unverified by the provider at issuance; attestations live in responses bound to this revision. */
|
|
195
|
+
unverified_fields: z.array(z.enum(ATTESTABLE_FIELDS)),
|
|
196
|
+
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"]) })),
|
|
197
|
+
lineage: z.object({ complete: z.boolean(), capture_gaps: z.array(CaptureGapSchema.omit({ delegation_id: true })) }),
|
|
198
|
+
});
|
|
199
|
+
export const SignedReceiptSchema = z.object({ payload: ReceiptPayloadSchema, signature: SignatureSchema, operator_signatures: z.array(OperatorSignatureSchema).optional() });
|
|
200
|
+
// ---------- Private closure snapshot (full root task; PRD §4 step 6, §6) ----------
|
|
201
|
+
export const ClosureDelegationSchema = z.object({
|
|
202
|
+
delegation_id: z.string(),
|
|
203
|
+
parent_delegation_id: z.string().nullable(),
|
|
204
|
+
depth: z.number().int().positive(),
|
|
205
|
+
provider_id: z.string().nullable(),
|
|
206
|
+
provider_name_stated: z.string().nullable(),
|
|
207
|
+
provider_own_id: z.string().nullable(),
|
|
208
|
+
external_ref: z.string().nullable(),
|
|
209
|
+
provider_job_ref: z.string().nullable(),
|
|
210
|
+
scope_ref: z.string().nullable(),
|
|
211
|
+
currency: Currency,
|
|
212
|
+
quoted_max_minor: Minor.nullable(),
|
|
213
|
+
quote_basis: z.string().nullable(),
|
|
214
|
+
quote_valid_until: Iso.nullable(),
|
|
215
|
+
accepted_amount_minor: Minor.nullable(),
|
|
216
|
+
terms_digest: Digest.nullable(),
|
|
217
|
+
expected_delivery: Iso.nullable(),
|
|
218
|
+
downstream_visibility: z.enum(["unknown", "disclosed", "none"]),
|
|
219
|
+
delivery_status: z.string(),
|
|
220
|
+
retrospective: z.boolean(),
|
|
221
|
+
created_at: Iso,
|
|
222
|
+
});
|
|
223
|
+
export const AllocationRecordSchema = z.object({
|
|
224
|
+
allocation_id: z.string(),
|
|
225
|
+
financial_event_id: z.string(),
|
|
226
|
+
version: z.number().int().positive(),
|
|
227
|
+
source_event_digest: Digest,
|
|
228
|
+
source_amount_minor: Minor,
|
|
229
|
+
currency: Currency,
|
|
230
|
+
lines: z.array(z.object({ target: z.object({ type: z.enum(["task", "delegation", "cost_center", "unallocated"]), id: z.string().nullable() }), amount_minor: Minor.nonnegative() })),
|
|
231
|
+
rounding: z.object({ method: z.enum(["largest_remainder", "none"]), remainder_units: z.array(z.object({ line_index: z.number().int().nonnegative(), units: z.number().int().positive() })) }),
|
|
232
|
+
rule: z.object({ rule_id: z.string(), version: z.number().int().positive() }).nullable(),
|
|
233
|
+
reason: z.string(),
|
|
234
|
+
after_close: z.boolean(),
|
|
235
|
+
created_by: z.string(),
|
|
236
|
+
created_at: Iso,
|
|
237
|
+
});
|
|
238
|
+
export const ExceptionRecordSchema = z.object({
|
|
239
|
+
exception_id: z.string(),
|
|
240
|
+
kind: z.string(),
|
|
241
|
+
status: z.enum(["open", "resolved", "dismissed"]),
|
|
242
|
+
delegation_id: z.string().nullable(),
|
|
243
|
+
financial_event_id: z.string().nullable(),
|
|
244
|
+
detail: z.string(),
|
|
245
|
+
created_at: Iso,
|
|
246
|
+
});
|
|
247
|
+
export const NodeRollupSchema = z.object({
|
|
248
|
+
node_id: z.string(),
|
|
249
|
+
parent_id: z.string().nullable(),
|
|
250
|
+
direct: CurrencyTotalsSchema,
|
|
251
|
+
descendant: CurrencyTotalsSchema,
|
|
252
|
+
total: CurrencyTotalsSchema,
|
|
253
|
+
event_ids: z.array(z.string()),
|
|
254
|
+
});
|
|
255
|
+
export const RollupSchema = z.object({
|
|
256
|
+
root_task_id: z.string(),
|
|
257
|
+
nodes: z.array(NodeRollupSchema),
|
|
258
|
+
root_total: CurrencyTotalsSchema,
|
|
259
|
+
excluded_event_ids: z.object({ reversed: z.array(z.string()), reversals: z.array(z.string()), fx_rates: z.array(z.string()) }),
|
|
260
|
+
});
|
|
261
|
+
export const DisclosureSchema = z.object({
|
|
262
|
+
missing: z.array(z.string()),
|
|
263
|
+
unverified: z.array(z.string()),
|
|
264
|
+
contested: z.array(z.string()),
|
|
265
|
+
provider_reported: z.array(z.string()),
|
|
266
|
+
retrospective: z.array(z.string()),
|
|
267
|
+
});
|
|
268
|
+
/** A delegation backed by a clearing-network obligation, with the obligation's latest decision when the task was closed. */
|
|
269
|
+
export const ObligationLinkSchema = z.object({
|
|
270
|
+
delegation_id: z.string(),
|
|
271
|
+
obligation_id: z.string(),
|
|
272
|
+
decision_id: z.string().nullable(),
|
|
273
|
+
decision_digest: Digest.nullable(),
|
|
274
|
+
});
|
|
275
|
+
export const ClosurePayloadSchema = z.object({
|
|
276
|
+
document_type: z.literal(CLOSURE_DOCUMENT_TYPE),
|
|
277
|
+
schema_version: z.enum(SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS),
|
|
278
|
+
closure_id: z.string(),
|
|
279
|
+
version: z.number().int().positive(),
|
|
280
|
+
previous_closure_id: z.string().nullable(),
|
|
281
|
+
previous_closure_digest: Digest.nullable(),
|
|
282
|
+
generated_at: Iso,
|
|
283
|
+
issuer: IssuerSchema,
|
|
284
|
+
task: z.object({
|
|
285
|
+
task_id: z.string(),
|
|
286
|
+
external_ref: z.string(),
|
|
287
|
+
currency: Currency,
|
|
288
|
+
budget_minor: Minor.nullable(),
|
|
289
|
+
customer_ref: z.string().nullable(),
|
|
290
|
+
project_ref: z.string().nullable(),
|
|
291
|
+
cost_center: z.string().nullable(),
|
|
292
|
+
scope_ref: z.string().nullable(),
|
|
293
|
+
retrospective: z.boolean(),
|
|
294
|
+
created_at: Iso,
|
|
295
|
+
}),
|
|
296
|
+
delegations: z.array(ClosureDelegationSchema),
|
|
297
|
+
delivery_claims: z.array(DeliveryClaimSchema),
|
|
298
|
+
financial_events: z.array(z.object({ record: FinancialEventRecordSchema, event_digest: Digest, attributed_to: z.string() })),
|
|
299
|
+
allocations: z.array(AllocationRecordSchema),
|
|
300
|
+
rollup: RollupSchema,
|
|
301
|
+
open_exceptions: z.array(ExceptionRecordSchema),
|
|
302
|
+
receipts: z.array(z.object({ receipt_id: z.string(), delegation_id: z.string(), revision: z.number().int().positive(), digest: Digest })),
|
|
303
|
+
responses: z.array(ResponseRecordSchema),
|
|
304
|
+
key_bindings: z.array(KeyBindingRecordSchema),
|
|
305
|
+
lineage: z.object({ complete: z.boolean(), capture_gaps: z.array(CaptureGapSchema), unknown_downstream: z.array(z.string()) }),
|
|
306
|
+
disclosure: DisclosureSchema,
|
|
307
|
+
/** Present only when at least one delegation is backed by an obligation, so older closures keep their bytes. */
|
|
308
|
+
obligation_links: z.array(ObligationLinkSchema).optional(),
|
|
309
|
+
});
|
|
310
|
+
export const SignedClosureSchema = z.object({ payload: ClosurePayloadSchema, signature: SignatureSchema, operator_signatures: z.array(OperatorSignatureSchema).optional() });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Rollup } from "./rollup.js";
|
|
2
|
+
import type { DeliveryClaim } from "./documents.js";
|
|
3
|
+
import type { ExceptionKind } from "./types.js";
|
|
4
|
+
/** Exception kinds recomputed from current state; they resolve automatically when the condition clears. */
|
|
5
|
+
export declare const DERIVED_EXCEPTION_KINDS: ExceptionKind[];
|
|
6
|
+
export interface DerivedException {
|
|
7
|
+
kind: ExceptionKind;
|
|
8
|
+
/** Stable per condition, so re-deriving never duplicates an open exception. */
|
|
9
|
+
dedupe_key: string;
|
|
10
|
+
delegation_id: string | null;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DeriveInput {
|
|
14
|
+
task: {
|
|
15
|
+
task_id: string;
|
|
16
|
+
currency: string;
|
|
17
|
+
budget_minor: number | null;
|
|
18
|
+
};
|
|
19
|
+
delegations: {
|
|
20
|
+
delegation_id: string;
|
|
21
|
+
currency: string;
|
|
22
|
+
quoted_max_minor: number | null;
|
|
23
|
+
accepted_amount_minor: number | null;
|
|
24
|
+
quote_valid_until: string | null;
|
|
25
|
+
}[];
|
|
26
|
+
claims: DeliveryClaim[];
|
|
27
|
+
rollup: Rollup;
|
|
28
|
+
now: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Condition-based exceptions recomputed from current state (PRD §6). They resolve automatically when the
|
|
32
|
+
* condition clears. Flags only: the product records an overrun, it never claims to have prevented spend.
|
|
33
|
+
*/
|
|
34
|
+
export declare function deriveTaskExceptions(input: DeriveInput): DerivedException[];
|
|
35
|
+
/** The latest exception recorded under a derived exception's dedupe key. */
|
|
36
|
+
export interface LatestException {
|
|
37
|
+
status: string;
|
|
38
|
+
resolved_by: string | null;
|
|
39
|
+
detail: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What to do with a derived exception given the latest one under its dedupe key: a person's resolution of this exact
|
|
43
|
+
* condition stands ("keep"); an open exception whose condition changed shows the current detail ("update_detail");
|
|
44
|
+
* otherwise it is opened, which is a no-op when one is already open.
|
|
45
|
+
*/
|
|
46
|
+
export declare function derivedExceptionAction(latest: LatestException | null, derived: DerivedException): "keep" | "update_detail" | "open";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** 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"];
|
|
3
|
+
/**
|
|
4
|
+
* Condition-based exceptions recomputed from current state (PRD §6). They resolve automatically when the
|
|
5
|
+
* condition clears. Flags only: the product records an overrun, it never claims to have prevented spend.
|
|
6
|
+
*/
|
|
7
|
+
export function deriveTaskExceptions(input) {
|
|
8
|
+
const result = [];
|
|
9
|
+
const rootTotals = input.rollup.root_total[input.task.currency];
|
|
10
|
+
if (input.task.budget_minor !== null && rootTotals && rootTotals.net_cost > input.task.budget_minor) {
|
|
11
|
+
result.push({
|
|
12
|
+
kind: "budget_overrun",
|
|
13
|
+
dedupe_key: `budget_overrun:${input.task.task_id}`,
|
|
14
|
+
delegation_id: null,
|
|
15
|
+
detail: `net cost ${rootTotals.net_cost} ${input.task.currency} exceeds budget ${input.task.budget_minor}; recorded after the fact, not prevented`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
for (const d of input.delegations) {
|
|
19
|
+
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;
|
|
22
|
+
const claims = input.claims.filter((c) => c.delegation_id === d.delegation_id);
|
|
23
|
+
const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
|
|
24
|
+
const active = claims.filter((c) => !superseded.has(c.event_id));
|
|
25
|
+
const hasDeliveryReceipt = active.some((c) => c.type === "completion" || c.type === "partial_completion");
|
|
26
|
+
if (billed > 0 && !hasDeliveryReceipt) {
|
|
27
|
+
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
|
+
}
|
|
29
|
+
const agreed = d.accepted_amount_minor ?? d.quoted_max_minor;
|
|
30
|
+
if (agreed !== null && billed > agreed) {
|
|
31
|
+
const basis = d.accepted_amount_minor !== null ? "accepted amount" : "quoted maximum";
|
|
32
|
+
result.push({ kind: "amount_mismatch", dedupe_key: `amount_mismatch:${d.delegation_id}`, delegation_id: d.delegation_id, detail: `billed ${billed} ${d.currency} exceeds ${basis} ${agreed}` });
|
|
33
|
+
}
|
|
34
|
+
const accepted = active.some((c) => c.type === "acceptance");
|
|
35
|
+
if (d.quote_valid_until !== null && d.quote_valid_until < input.now && !accepted) {
|
|
36
|
+
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
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What to do with a derived exception given the latest one under its dedupe key: a person's resolution of this exact
|
|
43
|
+
* condition stands ("keep"); an open exception whose condition changed shows the current detail ("update_detail");
|
|
44
|
+
* otherwise it is opened, which is a no-op when one is already open.
|
|
45
|
+
*/
|
|
46
|
+
export function derivedExceptionAction(latest, derived) {
|
|
47
|
+
if (latest && latest.status !== "open" && latest.resolved_by !== "system" && latest.detail === derived.detail)
|
|
48
|
+
return "keep";
|
|
49
|
+
if (latest?.status === "open" && latest.detail !== derived.detail)
|
|
50
|
+
return "update_detail";
|
|
51
|
+
return "open";
|
|
52
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./documents.js";
|
|
3
|
+
export * from "./allocation.js";
|
|
4
|
+
export * from "./rollup.js";
|
|
5
|
+
export * from "./projection.js";
|
|
6
|
+
export * from "./exceptions.js";
|
|
7
|
+
export * from "./response.js";
|
|
8
|
+
export * from "./verify.js";
|
|
9
|
+
export * from "./bridge.js";
|
|
10
|
+
export * from "./matching.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./documents.js";
|
|
3
|
+
export * from "./allocation.js";
|
|
4
|
+
export * from "./rollup.js";
|
|
5
|
+
export * from "./projection.js";
|
|
6
|
+
export * from "./exceptions.js";
|
|
7
|
+
export * from "./response.js";
|
|
8
|
+
export * from "./verify.js";
|
|
9
|
+
export * from "./bridge.js";
|
|
10
|
+
export * from "./matching.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A node a financial event's stable references point to, and which references matched. */
|
|
2
|
+
export interface MatchCandidate {
|
|
3
|
+
task_id: string;
|
|
4
|
+
delegation_id: string | null;
|
|
5
|
+
reason: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Candidates from stable references only (PRD §6): one per node, with the matching references joined by "+". A
|
|
9
|
+
* delegation candidate replaces a task-level candidate for the same task, because it is more specific. An event is
|
|
10
|
+
* attributed automatically only when exactly one candidate remains.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mergeMatchCandidates(found: MatchCandidate[]): MatchCandidate[];
|
package/dist/matching.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidates from stable references only (PRD §6): one per node, with the matching references joined by "+". A
|
|
3
|
+
* delegation candidate replaces a task-level candidate for the same task, because it is more specific. An event is
|
|
4
|
+
* attributed automatically only when exactly one candidate remains.
|
|
5
|
+
*/
|
|
6
|
+
export function mergeMatchCandidates(found) {
|
|
7
|
+
const byNode = new Map();
|
|
8
|
+
for (const c of found) {
|
|
9
|
+
const key = c.delegation_id ?? `task:${c.task_id}`;
|
|
10
|
+
const existing = byNode.get(key);
|
|
11
|
+
byNode.set(key, existing ? { ...existing, reason: `${existing.reason}+${c.reason}` } : c);
|
|
12
|
+
}
|
|
13
|
+
const delegationTasks = new Set([...byNode.values()].filter((c) => c.delegation_id).map((c) => c.task_id));
|
|
14
|
+
return [...byNode.values()].filter((c) => c.delegation_id !== null || !delegationTasks.has(c.task_id));
|
|
15
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type Rollup } from "./rollup.js";
|
|
2
|
+
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 AttestableField, type FinancialEventRecord } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Pure builders for the two projections of one root task (PRD §16): the private closure snapshot
|
|
6
|
+
* and the provider-specific receipt. The service loads rows; these functions decide what is disclosed.
|
|
7
|
+
*/
|
|
8
|
+
export interface TaskRecord {
|
|
9
|
+
task_id: string;
|
|
10
|
+
external_ref: string;
|
|
11
|
+
currency: string;
|
|
12
|
+
budget_minor: number | null;
|
|
13
|
+
customer_ref: string | null;
|
|
14
|
+
project_ref: string | null;
|
|
15
|
+
cost_center: string | null;
|
|
16
|
+
scope_ref: string | null;
|
|
17
|
+
retrospective: boolean;
|
|
18
|
+
created_at: string;
|
|
19
|
+
}
|
|
20
|
+
export type DelegationRecord = ClosureDelegation & {
|
|
21
|
+
root_task_id: string;
|
|
22
|
+
shared_description: string | null;
|
|
23
|
+
};
|
|
24
|
+
/** Claims superseded by a later correction stay visible and gain the "superseded" label. */
|
|
25
|
+
export declare function labelClaims(claims: DeliveryClaim[]): DeliveryClaim[];
|
|
26
|
+
/** Delivery status is the latest non-superseded status claim. It is a recorded claim, not an adjudicated truth. */
|
|
27
|
+
export declare function deliveryStatus(claims: DeliveryClaim[]): string;
|
|
28
|
+
/** Totals for a single delegation's own events (no allocation detail, which may name other cost centers). */
|
|
29
|
+
export declare function receiptTotals(delegation: Pick<DelegationRecord, "delegation_id" | "currency" | "quoted_max_minor" | "accepted_amount_minor">, events: FinancialEventRecord[]): ReceiptTotals;
|
|
30
|
+
export declare function receiptFieldStatus(delegation: Pick<DelegationRecord, "terms_digest">, claims: DeliveryClaim[], events: FinancialEventRecord[], priorResponses: ResponseRecord[]): Record<AttestableField, FieldState>;
|
|
31
|
+
export interface ReceiptInput {
|
|
32
|
+
receipt_id: string;
|
|
33
|
+
revision: number;
|
|
34
|
+
previous: {
|
|
35
|
+
receipt_id: string;
|
|
36
|
+
digest: string;
|
|
37
|
+
} | null;
|
|
38
|
+
issued_at: string;
|
|
39
|
+
expires_at: string | null;
|
|
40
|
+
issuer: Issuer;
|
|
41
|
+
delegation: DelegationRecord;
|
|
42
|
+
provider: {
|
|
43
|
+
provider_id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
provider_own_id: string | null;
|
|
46
|
+
} | null;
|
|
47
|
+
provider_key_bound: boolean;
|
|
48
|
+
claims: DeliveryClaim[];
|
|
49
|
+
/** Only events attributed to this delegation, including reversals of them. */
|
|
50
|
+
events: FinancialEventRecord[];
|
|
51
|
+
/** Current allocation version per financial event (0 = never allocated). */
|
|
52
|
+
allocation_versions: Record<string, number>;
|
|
53
|
+
/** Responses to earlier revisions of this receipt chain. */
|
|
54
|
+
prior_responses: ResponseRecord[];
|
|
55
|
+
capture_gaps: CaptureGap[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
|
|
59
|
+
* allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildReceiptPayload(input: ReceiptInput): ReceiptPayload;
|
|
62
|
+
/** Latest allocation lines per financial event, the input the roll-up uses. */
|
|
63
|
+
export declare function latestAllocations(allocations: AllocationRecord[]): Record<string, AllocationRecord>;
|
|
64
|
+
export declare function rollupFor(task: TaskRecord, delegations: ClosureDelegation[], events: {
|
|
65
|
+
record: FinancialEventRecord;
|
|
66
|
+
attributed_to: string;
|
|
67
|
+
}[], allocations: AllocationRecord[]): Rollup;
|
|
68
|
+
export interface ClosureInput {
|
|
69
|
+
closure_id: string;
|
|
70
|
+
version: number;
|
|
71
|
+
previous: {
|
|
72
|
+
closure_id: string;
|
|
73
|
+
digest: string;
|
|
74
|
+
} | null;
|
|
75
|
+
generated_at: string;
|
|
76
|
+
issuer: Issuer;
|
|
77
|
+
task: TaskRecord;
|
|
78
|
+
delegations: ClosureDelegation[];
|
|
79
|
+
claims: DeliveryClaim[];
|
|
80
|
+
events: {
|
|
81
|
+
record: FinancialEventRecord;
|
|
82
|
+
attributed_to: string;
|
|
83
|
+
}[];
|
|
84
|
+
allocations: AllocationRecord[];
|
|
85
|
+
open_exceptions: ExceptionRecord[];
|
|
86
|
+
receipts: {
|
|
87
|
+
receipt_id: string;
|
|
88
|
+
delegation_id: string;
|
|
89
|
+
revision: number;
|
|
90
|
+
digest: string;
|
|
91
|
+
}[];
|
|
92
|
+
responses: ResponseRecord[];
|
|
93
|
+
key_bindings: KeyBindingRecord[];
|
|
94
|
+
capture_gaps: CaptureGap[];
|
|
95
|
+
obligation_links?: ObligationLink[];
|
|
96
|
+
}
|
|
97
|
+
export declare function closureDisclosure(input: Pick<ClosureInput, "task" | "delegations" | "claims" | "events" | "responses" | "receipts">): Disclosure;
|
|
98
|
+
/** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
|
|
99
|
+
export declare function buildClosurePayload(input: ClosureInput): ClosurePayload;
|