@atcn/core 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/README.md +13 -3
- package/dist/clearing.d.ts +14 -3
- package/dist/clearing.js +48 -3
- package/dist/conflicts.d.ts +26 -0
- package/dist/conflicts.js +85 -0
- package/dist/evidence.js +26 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/package.d.ts +10 -1
- package/dist/package.js +294 -8
- package/dist/templates.d.ts +11 -0
- package/dist/templates.js +37 -1
- package/dist/verdict.d.ts +29 -0
- package/dist/verdict.js +97 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
# @atcn/core
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The obligation engine of [ATCN](https://github.com/fadnisnikhil/atcn). An obligation is a piece of paid work with agreed terms: for example "fix this bug for USD 100, accepted if the tests pass". This package:
|
|
4
|
+
|
|
5
|
+
- turns the results of evidence checks into a decision under the agreed policy (the reference policies are in `REFERENCE_POLICIES`);
|
|
6
|
+
- posts a balanced journal: the provider's amount and the platform fee;
|
|
7
|
+
- exports a **closure package**, the signed record of the obligation, and verifies it offline with `verifyClosurePackage`;
|
|
8
|
+
- builds and verifies **clearing verdicts**: the decision in effect, signed for a payment rail to read. The rail decides whether to pay; ATCN moves no money.
|
|
4
9
|
|
|
5
10
|
```bash
|
|
6
11
|
npm install @atcn/core
|
|
7
12
|
```
|
|
8
13
|
|
|
9
14
|
```ts
|
|
10
|
-
import { verifyClosurePackage } from "@atcn/core";
|
|
15
|
+
import { verifyClosurePackage, verifyClearingVerdict } from "@atcn/core";
|
|
11
16
|
|
|
12
17
|
const report = verifyClosurePackage(closurePackage, { trustedKeys });
|
|
13
18
|
console.log(report.valid, report.checks);
|
|
19
|
+
|
|
20
|
+
const verdictReport = verifyClearingVerdict(verdict, { trustedKeys, closurePackage });
|
|
21
|
+
console.log(verdictReport.valid);
|
|
14
22
|
```
|
|
15
23
|
|
|
16
|
-
See [`examples/local-runner/src/network.ts`](
|
|
24
|
+
The verifier replays the policy decision for every automated decision, so a package whose decision doesn't follow from its evidence fails. See [`examples/local-runner/src/network.ts`](https://github.com/fadnisnikhil/atcn/blob/main/examples/local-runner/src/network.ts) for the full flow: evaluation, clearing and simulated settlement.
|
|
25
|
+
|
|
26
|
+
Part of [ATCN](https://github.com/fadnisnikhil/atcn). Apache-2.0.
|
package/dist/clearing.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { type DecisionBody, type EvidenceEnvelope, type ObligationTerms, type PolicyCheck, type PolicyTemplate, type VerifierResult, type ClearingOutcome, type EventType } from "@atcn/schema";
|
|
2
|
-
|
|
1
|
+
import { type AttestationConflict, type DecisionBody, type EvidenceEnvelope, type ObligationTerms, type PolicyCheck, type PolicyTemplate, type VerifierResult, type ClearingOutcome, type EventType } from "@atcn/schema";
|
|
2
|
+
/**
|
|
3
|
+
* Version 1.1.0 sends a deliverable with conflicting attestations to the reviewer instead of using the newest result.
|
|
4
|
+
* Replays honour the version a decision names: decisions by 1.0.0 are replayed without attestation conflicts.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CLEARING_ENGINE_ID = "atcn-clearing-engine@1.1.0";
|
|
7
|
+
export declare const CLEARING_ENGINE_ID_V1_0 = "atcn-clearing-engine@1.0.0";
|
|
3
8
|
/** The service event that records each decision outcome. */
|
|
4
9
|
export declare const OUTCOME_EVENT: Record<ClearingOutcome, EventType>;
|
|
5
|
-
export type ProducerRole = "issuer" | "counterparty" | "verifier" | "other";
|
|
10
|
+
export type ProducerRole = "issuer" | "counterparty" | "verifier" | "witness" | "other";
|
|
6
11
|
export interface EvidenceInput {
|
|
7
12
|
envelope: EvidenceEnvelope;
|
|
8
13
|
event_id: string;
|
|
@@ -22,10 +27,16 @@ export interface ClearingInput {
|
|
|
22
27
|
type: "automated" | "human";
|
|
23
28
|
id: string;
|
|
24
29
|
};
|
|
30
|
+
/** Conflicts among the obligation's attestations at evaluation time (see obligationAttestationConflicts). */
|
|
31
|
+
attestation_conflicts?: AttestationConflict[];
|
|
25
32
|
}
|
|
26
33
|
export declare function isAdmissible(evidence: EvidenceInput, policy: PolicyTemplate): boolean;
|
|
27
34
|
/** Latest admissible evidence of a type covering a deliverable (ordered by created_at, then evidence_id). */
|
|
28
35
|
export declare function selectEvidence(evidence: EvidenceInput[], policy: PolicyTemplate, evidenceType: string, deliverableId: string): EvidenceInput | null;
|
|
36
|
+
/** Every admissible agent_trace evidence item covering a deliverable, oldest first. Retries each count. */
|
|
37
|
+
export declare function traceEvidenceFor(evidence: EvidenceInput[], policy: PolicyTemplate, deliverableId: string): EvidenceInput[];
|
|
38
|
+
/** Every admissible witness_attestation evidence item covering a deliverable, oldest first, for witness_quorum. */
|
|
39
|
+
export declare function witnessEvidenceFor(evidence: EvidenceInput[], policy: PolicyTemplate, deliverableId: string): EvidenceInput[];
|
|
29
40
|
export interface CheckPlanItem {
|
|
30
41
|
deliverable_id: string;
|
|
31
42
|
check: PolicyCheck;
|
package/dist/clearing.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { digestOf, } from "@atcn/schema";
|
|
2
|
-
|
|
1
|
+
import { AGENT_TRACE_EVIDENCE_TYPE, digestOf, WITNESS_ATTESTATION_EVIDENCE_TYPE, } from "@atcn/schema";
|
|
2
|
+
/**
|
|
3
|
+
* Version 1.1.0 sends a deliverable with conflicting attestations to the reviewer instead of using the newest result.
|
|
4
|
+
* Replays honour the version a decision names: decisions by 1.0.0 are replayed without attestation conflicts.
|
|
5
|
+
*/
|
|
6
|
+
export const CLEARING_ENGINE_ID = "atcn-clearing-engine@1.1.0";
|
|
7
|
+
export const CLEARING_ENGINE_ID_V1_0 = "atcn-clearing-engine@1.0.0";
|
|
3
8
|
/** The service event that records each decision outcome. */
|
|
4
9
|
export const OUTCOME_EVENT = {
|
|
5
10
|
accepted: "completion.accepted",
|
|
@@ -29,6 +34,23 @@ export function selectEvidence(evidence, policy, evidenceType, deliverableId) {
|
|
|
29
34
|
});
|
|
30
35
|
return candidates[0] ?? null;
|
|
31
36
|
}
|
|
37
|
+
function allEvidenceFor(evidence, policy, evidenceType, deliverableId) {
|
|
38
|
+
return evidence
|
|
39
|
+
.filter((e) => isAdmissible(e, policy) && e.envelope.evidence_type === evidenceType && covers(e.envelope, deliverableId))
|
|
40
|
+
.sort((a, b) => {
|
|
41
|
+
if (a.envelope.created_at !== b.envelope.created_at)
|
|
42
|
+
return a.envelope.created_at < b.envelope.created_at ? -1 : 1;
|
|
43
|
+
return a.envelope.evidence_id < b.envelope.evidence_id ? -1 : 1;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Every admissible agent_trace evidence item covering a deliverable, oldest first. Retries each count. */
|
|
47
|
+
export function traceEvidenceFor(evidence, policy, deliverableId) {
|
|
48
|
+
return allEvidenceFor(evidence, policy, AGENT_TRACE_EVIDENCE_TYPE, deliverableId);
|
|
49
|
+
}
|
|
50
|
+
/** Every admissible witness_attestation evidence item covering a deliverable, oldest first, for witness_quorum. */
|
|
51
|
+
export function witnessEvidenceFor(evidence, policy, deliverableId) {
|
|
52
|
+
return allEvidenceFor(evidence, policy, WITNESS_ATTESTATION_EVIDENCE_TYPE, deliverableId);
|
|
53
|
+
}
|
|
32
54
|
/** Which verifier runs are needed for an evaluation: one per (deliverable, required check). */
|
|
33
55
|
export function planChecks(input) {
|
|
34
56
|
const plan = [];
|
|
@@ -70,6 +92,9 @@ export function producerRole(terms, producerId) {
|
|
|
70
92
|
return "counterparty";
|
|
71
93
|
if (terms.verifier_agent_ids.includes(producerId))
|
|
72
94
|
return "verifier";
|
|
95
|
+
const witnesses = terms.witness_policy?.witness_agent_ids;
|
|
96
|
+
if (terms.witness_policy && (witnesses === undefined || witnesses.includes(producerId)))
|
|
97
|
+
return "witness";
|
|
73
98
|
return "other";
|
|
74
99
|
}
|
|
75
100
|
/**
|
|
@@ -81,6 +106,7 @@ export function evaluateClearing(input) {
|
|
|
81
106
|
const usedEvidence = new Map();
|
|
82
107
|
const usedResults = new Map();
|
|
83
108
|
const deliverableOutcomes = [];
|
|
109
|
+
const conflicts = input.attestation_conflicts ?? [];
|
|
84
110
|
for (const deliverable of terms.deliverables) {
|
|
85
111
|
const reasons = [];
|
|
86
112
|
const verdicts = [];
|
|
@@ -138,6 +164,10 @@ export function evaluateClearing(input) {
|
|
|
138
164
|
reasons.push({ code: "probabilistic_review_required", check_id: checkId, detail: `probabilistic result: ${result.status}` });
|
|
139
165
|
verdicts.push(policy.probabilistic_routing === "human_review" ? "disputed" : "insufficient_evidence");
|
|
140
166
|
}
|
|
167
|
+
else if (result.status === "fail" && check.verifier === "witness_quorum") {
|
|
168
|
+
reasons.push({ code: "witness_quorum_not_met", check_id: checkId, detail: String(result.details.refused ?? "") });
|
|
169
|
+
verdicts.push("insufficient_evidence");
|
|
170
|
+
}
|
|
141
171
|
else if (result.status === "fail") {
|
|
142
172
|
reasons.push({ code: "failed_criteria", check_id: checkId });
|
|
143
173
|
verdicts.push("rejected");
|
|
@@ -146,11 +176,26 @@ export function evaluateClearing(input) {
|
|
|
146
176
|
reasons.push({ code: "passed", check_id: checkId });
|
|
147
177
|
verdicts.push("accepted");
|
|
148
178
|
}
|
|
179
|
+
const onCheck = conflicts.filter((c) => c.subject === `${terms.obligation_id}/${deliverable.deliverable_id}/${checkId}`);
|
|
180
|
+
if (onCheck.length > 0) {
|
|
181
|
+
reasons.push({ code: "conflicting_attestations", check_id: checkId, detail: onCheck.map((c) => c.kind).join(",") });
|
|
182
|
+
verdicts.push("disputed");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const onRun = conflicts.filter((c) => c.subject.startsWith(`${terms.obligation_id}/execution:`));
|
|
186
|
+
if (onRun.length > 0) {
|
|
187
|
+
reasons.push({ code: "conflicting_attestations", detail: `the counterparty declared conflicting descriptors for ${onRun.map((c) => c.subject.split("/execution:")[1]).join(", ")}` });
|
|
188
|
+
verdicts.push("disputed");
|
|
189
|
+
}
|
|
190
|
+
let verdict = combineVerdicts(verdicts);
|
|
191
|
+
if (verdict === "rejected" && terms.refund_terms?.on_failure === "dispute") {
|
|
192
|
+
verdict = "disputed";
|
|
193
|
+
reasons.push({ code: "failure_terms_dispute" });
|
|
149
194
|
}
|
|
150
195
|
deliverableOutcomes.push({
|
|
151
196
|
deliverable_id: deliverable.deliverable_id,
|
|
152
197
|
amount_minor: deliverable.amount_minor,
|
|
153
|
-
outcome:
|
|
198
|
+
outcome: verdict,
|
|
154
199
|
reasons,
|
|
155
200
|
});
|
|
156
201
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AttestationConflict, type ObligationTerms, type RecordedEvent } from "@atcn/schema";
|
|
2
|
+
export interface AttestationKey {
|
|
3
|
+
actor_id: string;
|
|
4
|
+
public_key: string;
|
|
5
|
+
revoked_at: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface ConflictInput {
|
|
8
|
+
/** The obligation's recorded events. */
|
|
9
|
+
events: RecordedEvent[];
|
|
10
|
+
/** Its accepted terms. */
|
|
11
|
+
terms: ObligationTerms;
|
|
12
|
+
/** Attestation text by content digest (the SHA-256 of the evidence bytes). Items without text are skipped. */
|
|
13
|
+
contents: Map<string, string>;
|
|
14
|
+
resolveKey: (keyId: string, keyVersion: number) => AttestationKey | null;
|
|
15
|
+
/** Reference time: the evaluation time for clearing, generated_at for a closure package. */
|
|
16
|
+
at: string;
|
|
17
|
+
/** Only events up to this sequence count (a decision's input cutoff). */
|
|
18
|
+
cutoffSequence?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Conflicts among an obligation's attestations in effect at `at`, plus runs the counterparty declared twice with
|
|
22
|
+
* different descriptors. An attestation counts only when its signature verifies with a key of its signer, and the signer
|
|
23
|
+
* may attest: an agreed verifier for verifier attestations; for witness attestations, anyone but the parties, limited to
|
|
24
|
+
* the agreed witnesses when the terms list them. Superseded evidence does not count; who uploaded it does not matter.
|
|
25
|
+
*/
|
|
26
|
+
export declare function obligationAttestationConflicts(input: ConflictInput): AttestationConflict[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ATTESTATION_EVIDENCE_TYPES, digestOf, disputesInEffect, findConflicts, inEffect, resolveAttestations, SignedExternalAttestationSchema, verifyPayload, } from "@atcn/schema";
|
|
2
|
+
import { buildEvidenceInputs, declaredExecutions, resolveCounterparty } from "./evidence.js";
|
|
3
|
+
/**
|
|
4
|
+
* Conflicts among an obligation's attestations in effect at `at`, plus runs the counterparty declared twice with
|
|
5
|
+
* different descriptors. An attestation counts only when its signature verifies with a key of its signer, and the signer
|
|
6
|
+
* may attest: an agreed verifier for verifier attestations; for witness attestations, anyone but the parties, limited to
|
|
7
|
+
* the agreed witnesses when the terms list them. Superseded evidence does not count; who uploaded it does not matter.
|
|
8
|
+
*/
|
|
9
|
+
export function obligationAttestationConflicts(input) {
|
|
10
|
+
const visible = input.events.filter((e) => input.cutoffSequence === undefined || e.sequence <= input.cutoffSequence);
|
|
11
|
+
const terms = resolveCounterparty(input.terms, visible);
|
|
12
|
+
const parties = [terms.issuer_agent_id, terms.counterparty_agent_id, terms.principal_id];
|
|
13
|
+
const items = [];
|
|
14
|
+
const claims = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const evidence of buildEvidenceInputs(visible, input.terms)) {
|
|
17
|
+
if (evidence.superseded || !ATTESTATION_EVIDENCE_TYPES.includes(evidence.envelope.evidence_type))
|
|
18
|
+
continue;
|
|
19
|
+
const content = input.contents.get(evidence.envelope.content_digest);
|
|
20
|
+
if (content === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = JSON.parse(content);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const parsed = SignedExternalAttestationSchema.safeParse(raw);
|
|
30
|
+
if (!parsed.success)
|
|
31
|
+
continue;
|
|
32
|
+
const { payload: p, signature } = parsed.data;
|
|
33
|
+
if (p.obligation_id !== terms.obligation_id)
|
|
34
|
+
continue;
|
|
35
|
+
const role = p.role ?? "verifier";
|
|
36
|
+
const allowed = role === "verifier"
|
|
37
|
+
? terms.verifier_agent_ids.includes(p.verifier_id)
|
|
38
|
+
: !parties.includes(p.verifier_id) && (terms.witness_policy?.witness_agent_ids ?? [p.verifier_id]).includes(p.verifier_id);
|
|
39
|
+
if (!allowed)
|
|
40
|
+
continue;
|
|
41
|
+
const key = input.resolveKey(signature.key_id, signature.key_version);
|
|
42
|
+
// An attestation whose text is not canonical JSON (a fraction in an unknown member) cannot be checked or counted.
|
|
43
|
+
let verified;
|
|
44
|
+
let digest;
|
|
45
|
+
try {
|
|
46
|
+
verified = !!key && key.actor_id === p.verifier_id && verifyPayload(raw, key.public_key);
|
|
47
|
+
digest = digestOf(raw.payload);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!key || !verified)
|
|
53
|
+
continue;
|
|
54
|
+
if (key.revoked_at !== null && Date.parse(key.revoked_at) <= Date.parse(p.issued_at ?? input.at))
|
|
55
|
+
continue;
|
|
56
|
+
if (seen.has(digest))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(digest);
|
|
59
|
+
items.push({ digest, signer: p.verifier_id, issued_at: p.issued_at, expires_at: p.expires_at, refs: p.refs });
|
|
60
|
+
claims.push({
|
|
61
|
+
digest,
|
|
62
|
+
signer: p.verifier_id,
|
|
63
|
+
subject: `${terms.obligation_id}/${p.deliverable_id}/${p.check_id}`,
|
|
64
|
+
status: p.status,
|
|
65
|
+
...(p.execution ? { execution_digest: p.execution.execution_digest } : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const resolution = resolveAttestations(items, input.at);
|
|
69
|
+
const effective = claims.filter((c) => inEffect(resolution, c.digest));
|
|
70
|
+
return [...findConflicts(effective, disputesInEffect(items, resolution)), ...executionConflicts(visible, terms)].sort((a, b) => a.subject !== b.subject ? (a.subject < b.subject ? -1 : 1) : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
|
|
71
|
+
}
|
|
72
|
+
/** Two obligation.started events that give one execution_id different descriptors: the counterparty equivocated about the run. */
|
|
73
|
+
function executionConflicts(events, terms) {
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
for (const e of declaredExecutions(events, terms.counterparty_agent_id))
|
|
76
|
+
byId.set(e.execution_id, (byId.get(e.execution_id) ?? new Set()).add(e.execution_digest));
|
|
77
|
+
return [...byId.entries()]
|
|
78
|
+
.filter(([, digests]) => digests.size > 1)
|
|
79
|
+
.map(([executionId, digests]) => ({
|
|
80
|
+
kind: "equivocation",
|
|
81
|
+
subject: `${terms.obligation_id}/execution:${executionId}`,
|
|
82
|
+
attestation_digests: [...digests].sort(),
|
|
83
|
+
signers: [terms.counterparty_agent_id],
|
|
84
|
+
}));
|
|
85
|
+
}
|
package/dist/evidence.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { digestOf, ExecutionDescriptorSchema } from "@atcn/schema";
|
|
1
|
+
import { digestOf, EvidenceEnvelopeSchema, ExecutionDescriptorSchema } from "@atcn/schema";
|
|
2
2
|
import { producerRole } from "./clearing.js";
|
|
3
3
|
/**
|
|
4
4
|
* Runs the counterparty declared in its signed obligation.started events. A descriptor naming another agent is
|
|
@@ -11,7 +11,15 @@ export function declaredExecutions(events, counterpartyAgentId) {
|
|
|
11
11
|
const parsed = ExecutionDescriptorSchema.safeParse(e.payload.data.execution);
|
|
12
12
|
if (!parsed.success || parsed.data.agent.agent_id !== e.payload.actor_id)
|
|
13
13
|
return [];
|
|
14
|
-
return [
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
execution_id: parsed.data.execution_id,
|
|
17
|
+
execution_digest: digestOf(e.payload.data.execution),
|
|
18
|
+
started_event_id: e.payload.event_id,
|
|
19
|
+
descriptor: parsed.data,
|
|
20
|
+
started_at: e.received_at,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
15
23
|
});
|
|
16
24
|
}
|
|
17
25
|
/**
|
|
@@ -30,16 +38,22 @@ export function buildEvidenceInputs(events, signedTerms, cutoffSequence) {
|
|
|
30
38
|
if (actorOf.get(target) === e.payload.actor_id)
|
|
31
39
|
superseded.add(target);
|
|
32
40
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// An evidence.submitted event without a well-formed envelope carries no evidence.
|
|
42
|
+
return visible.flatMap((e) => {
|
|
43
|
+
if (e.payload.event_type !== "evidence.submitted")
|
|
44
|
+
return [];
|
|
45
|
+
const parsed = EvidenceEnvelopeSchema.safeParse(e.payload.data.envelope);
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
return [];
|
|
48
|
+
const envelope = parsed.data;
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
envelope,
|
|
52
|
+
event_id: e.payload.event_id,
|
|
53
|
+
producer_role: producerRole(terms, envelope.producer_id),
|
|
54
|
+
superseded: superseded.has(e.payload.event_id),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
43
57
|
});
|
|
44
58
|
}
|
|
45
59
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from "./allocation.js";
|
|
2
2
|
export * from "./clearing.js";
|
|
3
|
+
export * from "./conflicts.js";
|
|
3
4
|
export * from "./delegation.js";
|
|
4
5
|
export * from "./evidence.js";
|
|
5
6
|
export * from "./journal.js";
|
|
6
7
|
export * from "./package.js";
|
|
7
8
|
export * from "./service.js";
|
|
8
9
|
export * from "./templates.js";
|
|
10
|
+
export * from "./verdict.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from "./allocation.js";
|
|
2
2
|
export * from "./clearing.js";
|
|
3
|
+
export * from "./conflicts.js";
|
|
3
4
|
export * from "./delegation.js";
|
|
4
5
|
export * from "./evidence.js";
|
|
5
6
|
export * from "./journal.js";
|
|
6
7
|
export * from "./package.js";
|
|
7
8
|
export * from "./service.js";
|
|
8
9
|
export * from "./templates.js";
|
|
10
|
+
export * from "./verdict.js";
|
package/dist/package.d.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import { type PublicKeyRecord } from "@atcn/schema";
|
|
1
|
+
import { type AttestationConflict, type ClosurePackage, type PublicKeyRecord } from "@atcn/schema";
|
|
2
|
+
import { type AttestationKey } from "./conflicts.js";
|
|
2
3
|
export interface CheckResult {
|
|
3
4
|
name: string;
|
|
4
5
|
ok: boolean;
|
|
5
6
|
details: string[];
|
|
7
|
+
/** Set when the check had nothing it could inspect (for example, trace evidence whose files were not supplied). Not a pass. */
|
|
8
|
+
state?: "not_inspected";
|
|
6
9
|
}
|
|
7
10
|
export interface PackageVerificationReport {
|
|
8
11
|
valid: boolean;
|
|
12
|
+
/** Set when the package declares a version this verifier does not know; upgrade the verifier. */
|
|
13
|
+
unsupported_schema_version?: string;
|
|
9
14
|
checks: CheckResult[];
|
|
10
15
|
}
|
|
11
16
|
export interface VerifyOptions {
|
|
12
17
|
/** Published public keys trusted out of band. The ATCN service key must be among them. */
|
|
13
18
|
trustedKeys: PublicKeyRecord[];
|
|
19
|
+
/** Trace files (raw bytes) behind agent_trace evidence, to recheck the traces and recompute usage_cost results. */
|
|
20
|
+
traces?: Uint8Array[];
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* Offline verification of a closure package (PRD scenario L): signatures, event
|
|
@@ -18,3 +25,5 @@ export interface VerifyOptions {
|
|
|
18
25
|
* journal balance, and settlement references. Makes no network calls.
|
|
19
26
|
*/
|
|
20
27
|
export declare function verifyClosurePackage(input: unknown, options: VerifyOptions): PackageVerificationReport;
|
|
28
|
+
/** The conflicts a package must record: every unredacted obligation's attestation conflicts at generated_at. */
|
|
29
|
+
export declare function packageAttestationConflicts(body: Pick<ClosurePackage["payload"], "obligations" | "events" | "generated_at">, contents: Map<string, string>, resolveKey: (keyId: string, keyVersion: number) => AttestationKey | null): AttestationConflict[];
|
package/dist/package.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ClosurePackageSchema, digestOf, verifyPayload, } from "@atcn/schema";
|
|
2
|
-
import { decisionDigest, evaluateClearing, verifierOutputDigest } from "./clearing.js";
|
|
3
|
-
import {
|
|
1
|
+
import { AGENT_TRACE_EVIDENCE_TYPE, ATTESTATION_EVIDENCE_TYPES, ClosurePackageSchema, ObligationTermsSchema, checkTrace, digestOf, sha256Digest, SUPPORTED_PACKAGE_VERSIONS, traceDigest, usageCostDetails, utf8Decode, utf8Encode, verifyPayload, } from "@atcn/schema";
|
|
2
|
+
import { CLEARING_ENGINE_ID_V1_0, decisionDigest, evaluateClearing, verifierOutputDigest } from "./clearing.js";
|
|
3
|
+
import { obligationAttestationConflicts } from "./conflicts.js";
|
|
4
|
+
import { buildEvidenceInputs, declaredExecutions, resolveCounterparty } from "./evidence.js";
|
|
4
5
|
import { checkBalanced } from "./journal.js";
|
|
5
6
|
const SERVICE_ACTOR = "svc_atcn";
|
|
6
7
|
function keyRef(keyId, version) {
|
|
@@ -9,6 +10,12 @@ function keyRef(keyId, version) {
|
|
|
9
10
|
function keyValidAt(key, at) {
|
|
10
11
|
return key.valid_from <= at && (key.revoked_at === null || key.revoked_at > at);
|
|
11
12
|
}
|
|
13
|
+
function sameInstant(a, b) {
|
|
14
|
+
return a === null || b === null ? a === b : Date.parse(a) === Date.parse(b);
|
|
15
|
+
}
|
|
16
|
+
function sameKey(a, b) {
|
|
17
|
+
return a.actor_id === b.actor_id && a.public_key === b.public_key && sameInstant(a.valid_from, b.valid_from) && sameInstant(a.revoked_at, b.revoked_at);
|
|
18
|
+
}
|
|
12
19
|
/**
|
|
13
20
|
* Offline verification of a closure package (PRD scenario L): signatures, event
|
|
14
21
|
* references, decision inputs (re-running the deterministic clearing engine),
|
|
@@ -16,6 +23,14 @@ function keyValidAt(key, at) {
|
|
|
16
23
|
*/
|
|
17
24
|
export function verifyClosurePackage(input, options) {
|
|
18
25
|
const checks = [];
|
|
26
|
+
const declared = input?.payload?.package_version;
|
|
27
|
+
if (typeof declared === "string" && !SUPPORTED_PACKAGE_VERSIONS.includes(declared)) {
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
unsupported_schema_version: declared,
|
|
31
|
+
checks: [{ name: "schema", ok: false, details: [`package_version ${declared} is not supported by this verifier (supports ${SUPPORTED_PACKAGE_VERSIONS.join(", ")})`] }],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
19
34
|
const parsed = ClosurePackageSchema.safeParse(input);
|
|
20
35
|
if (!parsed.success) {
|
|
21
36
|
return {
|
|
@@ -34,10 +49,19 @@ export function verifyClosurePackage(input, options) {
|
|
|
34
49
|
for (const trusted of options.trustedKeys) {
|
|
35
50
|
const ref = keyRef(trusted.key_id, trusted.key_version);
|
|
36
51
|
const existing = keys.get(ref);
|
|
37
|
-
if (existing && existing
|
|
52
|
+
if (existing && !sameKey(existing, trusted))
|
|
38
53
|
keyProblems.push(`package key ${ref} contradicts published key`);
|
|
39
54
|
keys.set(ref, trusted);
|
|
40
55
|
}
|
|
56
|
+
const usedKeys = new Set([
|
|
57
|
+
keyRef(pkg.signature.key_id, pkg.signature.key_version),
|
|
58
|
+
...body.events.map((e) => keyRef(e.signature.key_id, e.signature.key_version)),
|
|
59
|
+
...attestationSignatureKeys(body),
|
|
60
|
+
]);
|
|
61
|
+
for (const k of body.public_keys) {
|
|
62
|
+
if (!usedKeys.has(keyRef(k.key_id, k.key_version)))
|
|
63
|
+
keyProblems.push(`package key ${keyRef(k.key_id, k.key_version)} signs nothing in the package`);
|
|
64
|
+
}
|
|
41
65
|
checks.push({ name: "keys_consistent", ok: keyProblems.length === 0, details: keyProblems });
|
|
42
66
|
// Package signature by the ATCN service.
|
|
43
67
|
const serviceKey = options.trustedKeys.find((k) => k.key_id === pkg.signature.key_id && k.key_version === pkg.signature.key_version && k.actor_id === SERVICE_ACTOR);
|
|
@@ -74,21 +98,33 @@ export function verifyClosurePackage(input, options) {
|
|
|
74
98
|
// Obligation lineage and terms digests.
|
|
75
99
|
const obligationIds = new Set(body.obligations.map((o) => o.obligation_id));
|
|
76
100
|
const lineageProblems = [];
|
|
101
|
+
const root = body.obligations.find((o) => o.obligation_id === body.root_obligation_id);
|
|
102
|
+
if (!root)
|
|
103
|
+
lineageProblems.push(`root obligation ${body.root_obligation_id} missing`);
|
|
104
|
+
else if (root.parent_obligation_id !== null)
|
|
105
|
+
lineageProblems.push(`root obligation ${body.root_obligation_id} has a parent`);
|
|
106
|
+
if (!obligationIds.has(body.requested_obligation_id))
|
|
107
|
+
lineageProblems.push(`requested obligation ${body.requested_obligation_id} missing`);
|
|
77
108
|
const termsByDigest = new Map();
|
|
78
109
|
for (const event of body.events) {
|
|
79
110
|
const data = event.payload.data;
|
|
80
111
|
if (data.terms && data.terms_digest) {
|
|
81
112
|
if (digestOf(data.terms) !== data.terms_digest)
|
|
82
113
|
lineageProblems.push(`${event.payload.event_id}: terms_digest mismatch`);
|
|
83
|
-
|
|
114
|
+
// Only valid terms can be replayed; a decision citing other terms finds none.
|
|
115
|
+
if (ObligationTermsSchema.safeParse(data.terms).success)
|
|
116
|
+
termsByDigest.set(data.terms_digest, data.terms);
|
|
84
117
|
}
|
|
85
118
|
}
|
|
86
119
|
for (const o of body.obligations) {
|
|
87
120
|
if (o.parent_obligation_id && !obligationIds.has(o.parent_obligation_id)) {
|
|
88
121
|
lineageProblems.push(`${o.obligation_id}: parent ${o.parent_obligation_id} missing`);
|
|
89
122
|
}
|
|
90
|
-
if (o.redacted)
|
|
123
|
+
if (o.redacted) {
|
|
124
|
+
if (o.effective_terms !== null || o.effective_terms_digest !== null)
|
|
125
|
+
lineageProblems.push(`${o.obligation_id}: redacted obligation carries terms`);
|
|
91
126
|
continue;
|
|
127
|
+
}
|
|
92
128
|
if (!o.effective_terms || !o.effective_terms_digest) {
|
|
93
129
|
lineageProblems.push(`${o.obligation_id}: unredacted obligation without terms`);
|
|
94
130
|
continue;
|
|
@@ -121,8 +157,10 @@ export function verifyClosurePackage(input, options) {
|
|
|
121
157
|
const evidenceDigests = new Set(body.evidence.map((e) => e.content_digest));
|
|
122
158
|
const resultDigests = new Set(body.verifier_results.map(verifierOutputDigest));
|
|
123
159
|
const policies = new Map(body.policies.map((p) => [`${p.policy_id}@${p.policy_version}`, p]));
|
|
160
|
+
const contents = attestationContents(body);
|
|
161
|
+
const resolveKey = (keyId, keyVersion) => keys.get(keyRef(keyId, keyVersion)) ?? null;
|
|
124
162
|
for (const decision of body.decisions) {
|
|
125
|
-
decisionProblems.push(...checkDecision(decision, body.events, termsByDigest, policies, body.verifier_results, eventIds, evidenceDigests, resultDigests));
|
|
163
|
+
decisionProblems.push(...checkDecision(decision, body.events, termsByDigest, policies, body.verifier_results, eventIds, evidenceDigests, resultDigests, contents, resolveKey));
|
|
126
164
|
}
|
|
127
165
|
checks.push({ name: "decision_inputs_and_replay", ok: decisionProblems.length === 0, details: decisionProblems });
|
|
128
166
|
// Journal balance and references.
|
|
@@ -163,9 +201,254 @@ export function verifyClosurePackage(input, options) {
|
|
|
163
201
|
}
|
|
164
202
|
}
|
|
165
203
|
checks.push({ name: "settlement_references", ok: settlementProblems.length === 0, details: settlementProblems });
|
|
204
|
+
checks.push(recordsMatchEventsCheck(body));
|
|
205
|
+
checks.push(traceEvidenceCheck(body, options.traces ?? []));
|
|
206
|
+
if (body.package_version !== "1.0")
|
|
207
|
+
checks.push(attestationConflictsCheck(body, contents, resolveKey));
|
|
166
208
|
return { valid: checks.every((c) => c.ok), checks };
|
|
167
209
|
}
|
|
168
|
-
|
|
210
|
+
/** Attestation text by content digest, from a 1.1 package's attestations. */
|
|
211
|
+
function attestationContents(body) {
|
|
212
|
+
return new Map((body.attestations ?? []).map((a) => [sha256Digest(utf8Encode(a.content)), a.content]));
|
|
213
|
+
}
|
|
214
|
+
/** Key references of the signatures on the package's attestations, so their keys count as used. */
|
|
215
|
+
function attestationSignatureKeys(body) {
|
|
216
|
+
return (body.attestations ?? []).flatMap((a) => {
|
|
217
|
+
try {
|
|
218
|
+
const signature = JSON.parse(a.content).signature;
|
|
219
|
+
return typeof signature?.key_id === "string" && typeof signature.key_version === "number" ? [keyRef(signature.key_id, signature.key_version)] : [];
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/** The conflicts a package must record: every unredacted obligation's attestation conflicts at generated_at. */
|
|
227
|
+
export function packageAttestationConflicts(body, contents, resolveKey) {
|
|
228
|
+
return body.obligations
|
|
229
|
+
.filter((o) => !o.redacted && o.effective_terms !== null)
|
|
230
|
+
.flatMap((o) => obligationAttestationConflicts({
|
|
231
|
+
events: body.events.filter((e) => e.payload.obligation_id === o.obligation_id),
|
|
232
|
+
terms: o.effective_terms,
|
|
233
|
+
contents,
|
|
234
|
+
resolveKey,
|
|
235
|
+
at: body.generated_at,
|
|
236
|
+
}))
|
|
237
|
+
.sort((a, b) => (a.subject !== b.subject ? (a.subject < b.subject ? -1 : 1) : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Package 1.1: every attestation evidence item must come with its exact text (whose SHA-256 is the envelope's
|
|
241
|
+
* content_digest), and attestation_conflicts must be exactly what those attestations produce at generated_at, so a
|
|
242
|
+
* conflict cannot be removed to make the package look clean.
|
|
243
|
+
*/
|
|
244
|
+
function attestationConflictsCheck(body, contents, resolveKey) {
|
|
245
|
+
const problems = [];
|
|
246
|
+
const envelopes = new Map(body.evidence.map((e) => [e.evidence_id, e]));
|
|
247
|
+
for (const a of body.attestations ?? []) {
|
|
248
|
+
const envelope = envelopes.get(a.evidence_id);
|
|
249
|
+
if (!envelope || !ATTESTATION_EVIDENCE_TYPES.includes(envelope.evidence_type))
|
|
250
|
+
problems.push(`attestation ${a.evidence_id} is not attestation evidence in the package`);
|
|
251
|
+
else if (sha256Digest(utf8Encode(a.content)) !== envelope.content_digest)
|
|
252
|
+
problems.push(`attestation ${a.evidence_id} text does not match its content digest`);
|
|
253
|
+
}
|
|
254
|
+
const supplied = new Set((body.attestations ?? []).map((a) => a.evidence_id));
|
|
255
|
+
for (const e of body.evidence) {
|
|
256
|
+
if (ATTESTATION_EVIDENCE_TYPES.includes(e.evidence_type) && !supplied.has(e.evidence_id))
|
|
257
|
+
problems.push(`attestation evidence ${e.evidence_id} has no text in the package`);
|
|
258
|
+
}
|
|
259
|
+
const expected = packageAttestationConflicts(body, contents, resolveKey);
|
|
260
|
+
if (digestOf(expected) !== digestOf(body.attestation_conflicts ?? [])) {
|
|
261
|
+
problems.push(`attestation_conflicts do not match the attestations (expected ${expected.length} conflict(s), recorded ${(body.attestation_conflicts ?? []).length})`);
|
|
262
|
+
}
|
|
263
|
+
if (problems.length > 0)
|
|
264
|
+
return { name: "attestation_conflicts", ok: false, details: problems };
|
|
265
|
+
return { name: "attestation_conflicts", ok: true, details: [expected.length === 0 ? "no conflicting attestations" : `${expected.length} conflict(s) recomputed: ${expected.map((c) => `${c.kind} on ${c.subject}`).join("; ")}`] };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The package's tables repeat facts the service also signed as events. Each table row must match its event:
|
|
269
|
+
* settlement events their settlement.reported event, posting batches their journal event, and decisions their outcome
|
|
270
|
+
* event. Posting lines may name only the obligation's payer, payee and the actors and platforms in its events, and
|
|
271
|
+
* settlement instructions may not exceed what the decision's clearing batch made payable to the beneficiary.
|
|
272
|
+
*/
|
|
273
|
+
function recordsMatchEventsCheck(body) {
|
|
274
|
+
const problems = [];
|
|
275
|
+
const eventsOfType = (types) => body.events.filter((e) => types.includes(e.payload.event_type));
|
|
276
|
+
// A value that is not canonical JSON (batch totals past the safe integer range) matches nothing.
|
|
277
|
+
const same = (a, b) => {
|
|
278
|
+
try {
|
|
279
|
+
return digestOf(a ?? null) === digestOf(b ?? null);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const reports = eventsOfType(["settlement.reported"]);
|
|
286
|
+
for (const s of body.settlement_events) {
|
|
287
|
+
const data = reports.find((e) => e.payload.data.settlement_event_id === s.settlement_event_id)?.payload.data;
|
|
288
|
+
if (!data) {
|
|
289
|
+
problems.push(`settlement event ${s.settlement_event_id} has no settlement.reported event`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const fields = ["instruction_id", "provider", "provider_reference", "provider_status", "normalized_status", "amount_minor", "currency"];
|
|
293
|
+
const differing = fields.filter((f) => !same(s[f], data[f]));
|
|
294
|
+
if (differing.length > 0)
|
|
295
|
+
problems.push(`settlement event ${s.settlement_event_id} ${differing.join(", ")} differ from its settlement.reported event`);
|
|
296
|
+
}
|
|
297
|
+
const journalEvents = eventsOfType(["journal.posted", "journal.reversed"]);
|
|
298
|
+
for (const b of body.posting_batches) {
|
|
299
|
+
const event = journalEvents.find((e) => e.payload.data.batch_id === b.batch_id);
|
|
300
|
+
if (!event) {
|
|
301
|
+
problems.push(`posting batch ${b.batch_id} has no journal event`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const data = event.payload.data;
|
|
305
|
+
const totals = Object.fromEntries(Object.entries(checkBalanced(b.lines).byCurrency).map(([currency, t]) => [currency, t.debit]));
|
|
306
|
+
if (event.payload.obligation_id !== b.obligation_id)
|
|
307
|
+
problems.push(`posting batch ${b.batch_id} belongs to ${event.payload.obligation_id} by its journal event`);
|
|
308
|
+
if (!same(data.entry_type, b.entry_type) || !same(data.reverses_batch_id, b.reverses_batch_id) || !same(data.decision_id, b.decision_id)) {
|
|
309
|
+
problems.push(`posting batch ${b.batch_id} entry type, reversal or decision differ from its journal event`);
|
|
310
|
+
}
|
|
311
|
+
if (!same(data.totals, totals))
|
|
312
|
+
problems.push(`posting batch ${b.batch_id} totals differ from its journal event`);
|
|
313
|
+
if (!same([...b.source_event_ids].sort(), [...event.payload.causation_ids].sort()))
|
|
314
|
+
problems.push(`posting batch ${b.batch_id} source events differ from its journal event's causes`);
|
|
315
|
+
const obligationEvents = body.events.filter((e) => e.payload.obligation_id === b.obligation_id);
|
|
316
|
+
const agreedPolicyVersions = obligationEvents.map((e) => e.payload.data.terms?.acceptance_policy?.policy_version);
|
|
317
|
+
if (b.policy_version !== null && !agreedPolicyVersions.includes(b.policy_version))
|
|
318
|
+
problems.push(`posting batch ${b.batch_id} policy version ${b.policy_version} is not in the obligation's terms`);
|
|
319
|
+
const obligation = body.obligations.find((o) => o.obligation_id === b.obligation_id);
|
|
320
|
+
if (obligation?.effective_terms) {
|
|
321
|
+
const terms = resolveCounterparty(obligation.effective_terms, obligationEvents);
|
|
322
|
+
const parties = new Set([terms.payer_id, ...(terms.counterparty_agent_id ? [terms.counterparty_agent_id] : [])]);
|
|
323
|
+
for (const e of obligationEvents)
|
|
324
|
+
parties.add(e.payload.actor_id).add(e.payload.actor_platform_id);
|
|
325
|
+
for (const line of b.lines)
|
|
326
|
+
if (!parties.has(line.party_id))
|
|
327
|
+
problems.push(`posting batch ${b.batch_id} names ${line.party_id}, who is not a party to ${b.obligation_id}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const outcomeEvents = eventsOfType(["completion.accepted", "completion.partially_accepted", "completion.rejected", "completion.insufficient_evidence", "completion.disputed"]);
|
|
331
|
+
for (const d of body.decisions) {
|
|
332
|
+
const event = outcomeEvents.find((e) => e.payload.data.decision_id === d.decision_id);
|
|
333
|
+
if (!event) {
|
|
334
|
+
problems.push(`decision ${d.decision_id} has no outcome event`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const data = event.payload.data;
|
|
338
|
+
if (!same(data.decision_digest, d.decision_digest) || !same(data.outcome, d.outcome) || !same(data.supersedes_decision_id, d.supersedes_decision_id)) {
|
|
339
|
+
problems.push(`decision ${d.decision_id} differs from its outcome event`);
|
|
340
|
+
}
|
|
341
|
+
if (Date.parse(d.decided_at) > Date.parse(event.payload.event_time))
|
|
342
|
+
problems.push(`decision ${d.decision_id} was decided after its outcome event`);
|
|
343
|
+
if (d.input_cutoff_sequence >= event.sequence)
|
|
344
|
+
problems.push(`decision ${d.decision_id} input cutoff is not before its outcome event`);
|
|
345
|
+
}
|
|
346
|
+
const instructed = new Map();
|
|
347
|
+
for (const i of body.settlement_instructions) {
|
|
348
|
+
const key = `${i.decision_id}|${i.beneficiary_party_id}|${i.currency}`;
|
|
349
|
+
instructed.set(key, (instructed.get(key) ?? 0) + i.amount_minor);
|
|
350
|
+
}
|
|
351
|
+
for (const [key, amount] of instructed) {
|
|
352
|
+
const [decisionId, beneficiary, currency] = key.split("|");
|
|
353
|
+
const payable = body.posting_batches
|
|
354
|
+
.filter((b) => b.entry_type === "clearing" && b.decision_id === decisionId)
|
|
355
|
+
.flatMap((b) => b.lines)
|
|
356
|
+
.filter((l) => l.account_type === "payable" && l.party_id === beneficiary && l.currency === currency)
|
|
357
|
+
.reduce((sum, l) => sum + l.credit_minor - l.debit_minor, 0);
|
|
358
|
+
if (amount > payable)
|
|
359
|
+
problems.push(`settlement instructions for decision ${decisionId} pay ${beneficiary} ${amount} ${currency}, more than the ${payable} its clearing made payable`);
|
|
360
|
+
}
|
|
361
|
+
return { name: "records_match_signed_events", ok: problems.length === 0, details: problems };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Trace evidence, when its files are supplied: each file must match an agent_trace envelope's content digest, pass
|
|
365
|
+
* every agent_trace rule against the obligation's declared runs, and each recorded usage_cost result must recompute
|
|
366
|
+
* from the terms' pricing and its traces. Evidence whose file was not supplied is reported as not inspected.
|
|
367
|
+
*/
|
|
368
|
+
function traceEvidenceCheck(body, files) {
|
|
369
|
+
const name = "trace_evidence";
|
|
370
|
+
const envelopes = body.evidence.filter((e) => e.evidence_type === AGENT_TRACE_EVIDENCE_TYPE);
|
|
371
|
+
const usageResults = body.verifier_results.filter((r) => r.verifier_name === "usage_cost" && typeof r.details.trace_digests === "string");
|
|
372
|
+
if (envelopes.length === 0 && usageResults.length === 0)
|
|
373
|
+
return { name, ok: true, details: ["no trace evidence"] };
|
|
374
|
+
const fileByDigest = new Map(files.map((bytes) => [sha256Digest(bytes), bytes]));
|
|
375
|
+
const obligationOfEvidence = new Map();
|
|
376
|
+
for (const e of body.events) {
|
|
377
|
+
const envelope = e.payload.data.envelope;
|
|
378
|
+
if (e.payload.event_type === "evidence.submitted" && envelope?.evidence_id)
|
|
379
|
+
obligationOfEvidence.set(envelope.evidence_id, e.payload.obligation_id);
|
|
380
|
+
}
|
|
381
|
+
const problems = [];
|
|
382
|
+
const notes = [];
|
|
383
|
+
const tracesByObligation = new Map();
|
|
384
|
+
let inspected = 0;
|
|
385
|
+
for (const envelope of envelopes) {
|
|
386
|
+
const bytes = fileByDigest.get(envelope.content_digest);
|
|
387
|
+
const obligationId = obligationOfEvidence.get(envelope.evidence_id);
|
|
388
|
+
if (!bytes) {
|
|
389
|
+
notes.push(`not inspected: trace evidence ${envelope.evidence_id} (file not supplied)`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const obligation = body.obligations.find((o) => o.obligation_id === obligationId);
|
|
393
|
+
if (!obligationId || !obligation?.effective_terms) {
|
|
394
|
+
problems.push(`trace evidence ${envelope.evidence_id}: its obligation is not in the package`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
inspected += 1;
|
|
398
|
+
let raw;
|
|
399
|
+
try {
|
|
400
|
+
raw = JSON.parse(utf8Decode(bytes));
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
problems.push(`trace evidence ${envelope.evidence_id}: file is not JSON`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const events = body.events.filter((e) => e.payload.obligation_id === obligationId);
|
|
407
|
+
const terms = resolveCounterparty(obligation.effective_terms, events);
|
|
408
|
+
const checked = checkTrace(raw, declaredExecutions(events, terms.counterparty_agent_id), null);
|
|
409
|
+
if (!checked.ok) {
|
|
410
|
+
problems.push(`trace evidence ${envelope.evidence_id}: ${checked.code}: ${checked.error}`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const traces = tracesByObligation.get(obligationId) ?? new Map();
|
|
414
|
+
traces.set(traceDigest(checked.trace), checked.trace);
|
|
415
|
+
tracesByObligation.set(obligationId, traces);
|
|
416
|
+
}
|
|
417
|
+
for (const result of usageResults) {
|
|
418
|
+
const digests = String(result.details.trace_digests).split(",").filter((d) => d.length > 0);
|
|
419
|
+
const available = tracesByObligation.get(result.obligation_id) ?? new Map();
|
|
420
|
+
const missing = digests.filter((d) => !available.has(d));
|
|
421
|
+
const label = `usage_cost result for ${result.obligation_id}/${result.deliverable_id}`;
|
|
422
|
+
if (digests.length === 0 || missing.length > 0) {
|
|
423
|
+
notes.push(`not inspected: ${label} (${missing.length || "no"} trace file(s) not supplied)`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const terms = body.obligations.find((o) => o.obligation_id === result.obligation_id)?.effective_terms;
|
|
427
|
+
const deliverable = terms?.deliverables.find((d) => d.deliverable_id === result.deliverable_id);
|
|
428
|
+
if (!terms?.pricing || !deliverable) {
|
|
429
|
+
problems.push(`${label}: the package's terms carry no pricing for this deliverable`);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
inspected += 1;
|
|
433
|
+
let recomputed;
|
|
434
|
+
try {
|
|
435
|
+
recomputed = usageCostDetails(terms.pricing, deliverable.amount_minor, digests.map((d) => available.get(d)));
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
problems.push(`${label}: usage too large to price exactly`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const fields = ["expected_minor", "amount_minor", "within_tolerance", "trace_digests", "lines_digest", "unpriced"];
|
|
442
|
+
const differing = fields.filter((f) => recomputed[f] !== result.details[f]);
|
|
443
|
+
if (differing.length > 0)
|
|
444
|
+
problems.push(`${label}: recorded ${differing.join(", ")} do not match the traces and pricing`);
|
|
445
|
+
}
|
|
446
|
+
if (problems.length > 0)
|
|
447
|
+
return { name, ok: false, details: problems };
|
|
448
|
+
const details = [...(inspected > 0 ? [`${inspected} trace item(s) and usage result(s) rechecked`] : []), ...notes];
|
|
449
|
+
return inspected === 0 ? { name, ok: true, details, state: "not_inspected" } : { name, ok: true, details };
|
|
450
|
+
}
|
|
451
|
+
function checkDecision(decision, events, termsByDigest, policies, verifierResults, eventIds, evidenceDigests, resultDigests, contents, resolveKey) {
|
|
169
452
|
const problems = [];
|
|
170
453
|
const id = decision.decision_id;
|
|
171
454
|
const { decision_id: _id, decision_digest: storedDigest, decided_at: _decidedAt, supersedes_decision_id: _supersedes, input_cutoff_sequence: cutoff, ...body } = decision;
|
|
@@ -207,6 +490,9 @@ function checkDecision(decision, events, termsByDigest, policies, verifierResult
|
|
|
207
490
|
evidence: buildEvidenceInputs(obligationEvents, terms, cutoff),
|
|
208
491
|
verifier_results: verifierResults.filter((r) => r.obligation_id === decision.obligation_id && r.executed_at <= decision.decided_at),
|
|
209
492
|
decision_maker: decision.decision_maker,
|
|
493
|
+
attestation_conflicts: decision.decision_maker.id === CLEARING_ENGINE_ID_V1_0
|
|
494
|
+
? []
|
|
495
|
+
: obligationAttestationConflicts({ events: obligationEvents, terms, contents, resolveKey, at: decision.decided_at, cutoffSequence: cutoff }),
|
|
210
496
|
});
|
|
211
497
|
if (decisionDigest(replay) !== storedDigest)
|
|
212
498
|
problems.push(`${id}: replaying the clearing policy produced a different decision`);
|
package/dist/templates.d.ts
CHANGED
|
@@ -5,4 +5,15 @@ export declare const CODE_CHANGE_POLICY_V1: PolicyTemplate;
|
|
|
5
5
|
export declare const CODE_CHANGE_POLICY_V1_1: PolicyTemplate;
|
|
6
6
|
/** Worker-to-subworker template without platform fee (child obligations in the reference flow). */
|
|
7
7
|
export declare const CODE_CHANGE_SUBTASK_POLICY_V1: PolicyTemplate;
|
|
8
|
+
/**
|
|
9
|
+
* Usage-priced agent work: the counterparty submits the run's trace, which must belong to its declared run, and the
|
|
10
|
+
* usage in it must support each deliverable's amount under the terms' pricing (terms schema 1.2).
|
|
11
|
+
*/
|
|
12
|
+
export declare const AGENT_USAGE_POLICY_V1: PolicyTemplate;
|
|
13
|
+
/**
|
|
14
|
+
* Witnessed agent work: a deliverable requiring "witnesses" is accepted only when enough independent witnesses, as the
|
|
15
|
+
* terms' witness_policy sets, attested to the declared run. "review_bound" is the run-bound review (external_attestation@1.1.0).
|
|
16
|
+
* Witnesses may submit their attestations themselves.
|
|
17
|
+
*/
|
|
18
|
+
export declare const WITNESSED_AGENT_WORK_POLICY_V1: PolicyTemplate;
|
|
8
19
|
export declare const REFERENCE_POLICIES: PolicyTemplate[];
|
package/dist/templates.js
CHANGED
|
@@ -59,4 +59,40 @@ export const CODE_CHANGE_SUBTASK_POLICY_V1 = {
|
|
|
59
59
|
description: "Subtask of a code change; same checks, no platform fee.",
|
|
60
60
|
allocation: { platform_fee_bps: 0 },
|
|
61
61
|
};
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Usage-priced agent work: the counterparty submits the run's trace, which must belong to its declared run, and the
|
|
64
|
+
* usage in it must support each deliverable's amount under the terms' pricing (terms schema 1.2).
|
|
65
|
+
*/
|
|
66
|
+
export const AGENT_USAGE_POLICY_V1 = {
|
|
67
|
+
...CODE_CHANGE_POLICY_V1,
|
|
68
|
+
policy_id: "agent-usage-checks",
|
|
69
|
+
policy_version: "1.0.0",
|
|
70
|
+
task_type: "agent_work",
|
|
71
|
+
description: "Agent work accepted when the run's trace belongs to the declared run and its usage, priced with the agreed rates, supports the amount. Deliverables may also require the code-change checks.",
|
|
72
|
+
required_evidence: ["agent_trace"],
|
|
73
|
+
checks: [
|
|
74
|
+
...CODE_CHANGE_POLICY_V1.checks,
|
|
75
|
+
{ check_id: "trace", verifier: "agent_trace", verifier_version: "1.0.0", evidence_type: "agent_trace", config: {} },
|
|
76
|
+
{ check_id: "usage_cost", verifier: "usage_cost", verifier_version: "1.0.0", evidence_type: "agent_trace", config: {} },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Witnessed agent work: a deliverable requiring "witnesses" is accepted only when enough independent witnesses, as the
|
|
81
|
+
* terms' witness_policy sets, attested to the declared run. "review_bound" is the run-bound review (external_attestation@1.1.0).
|
|
82
|
+
* Witnesses may submit their attestations themselves.
|
|
83
|
+
*/
|
|
84
|
+
export const WITNESSED_AGENT_WORK_POLICY_V1 = {
|
|
85
|
+
...CODE_CHANGE_POLICY_V1,
|
|
86
|
+
policy_id: "witnessed-agent-work",
|
|
87
|
+
policy_version: "1.0.0",
|
|
88
|
+
task_type: "agent_work",
|
|
89
|
+
description: "Agent work accepted when the required checks pass, including a quorum of independent witnesses to the declared run when a deliverable requires it.",
|
|
90
|
+
evidence_admissibility: { allowed_producers: ["counterparty", "verifier", "witness"], require_digest_match: true },
|
|
91
|
+
required_evidence: [],
|
|
92
|
+
checks: [
|
|
93
|
+
...AGENT_USAGE_POLICY_V1.checks,
|
|
94
|
+
{ check_id: "review_bound", verifier: "external_attestation", verifier_version: "1.1.0", evidence_type: "verifier_attestation", config: {} },
|
|
95
|
+
{ check_id: "witnesses", verifier: "witness_quorum", verifier_version: "1.0.0", evidence_type: "witness_attestation", config: {} },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
export const REFERENCE_POLICIES = [CODE_CHANGE_POLICY_V1, CODE_CHANGE_POLICY_V1_1, CODE_CHANGE_SUBTASK_POLICY_V1, AGENT_USAGE_POLICY_V1, WITNESSED_AGENT_WORK_POLICY_V1];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ClearingDecision, type ClearingVerdictPayload, type ClosurePackage, type PublicKeyRecord } from "@atcn/schema";
|
|
2
|
+
import { type CheckResult } from "./package.js";
|
|
3
|
+
/** The obligation's decision in effect: the latest one that no other decision supersedes. */
|
|
4
|
+
export declare function currentDecision(pkg: ClosurePackage, obligationId: string): ClearingDecision | null;
|
|
5
|
+
/**
|
|
6
|
+
* The verdict payload for an obligation, read from its closure package. Sign it with the service key (signPayload).
|
|
7
|
+
* Throws when the package holds no decision for the obligation.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildClearingVerdict(pkg: ClosurePackage, options: {
|
|
10
|
+
obligationId?: string;
|
|
11
|
+
escrow?: {
|
|
12
|
+
rail: string;
|
|
13
|
+
escrow_ref: string;
|
|
14
|
+
} | null;
|
|
15
|
+
issuedAt: string;
|
|
16
|
+
}): ClearingVerdictPayload;
|
|
17
|
+
export interface VerdictVerificationReport {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
checks: CheckResult[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Offline verification of a clearing verdict: the service signature, and, given the closure package it names, that
|
|
23
|
+
* the package verifies and the verdict states the decision in effect in it. Without the package the decision is not
|
|
24
|
+
* compared with its evidence (reported as not inspected).
|
|
25
|
+
*/
|
|
26
|
+
export declare function verifyClearingVerdict(input: unknown, options: {
|
|
27
|
+
trustedKeys: PublicKeyRecord[];
|
|
28
|
+
closurePackage?: unknown;
|
|
29
|
+
}): VerdictVerificationReport;
|
package/dist/verdict.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CLEARING_VERDICT_TYPE, ClearingVerdictSchema, digestOf, verifyPayload, } from "@atcn/schema";
|
|
2
|
+
import { verifyClosurePackage } from "./package.js";
|
|
3
|
+
import { SERVICE_ACTOR_ID } from "./service.js";
|
|
4
|
+
/** The obligation's decision in effect: the latest one that no other decision supersedes. */
|
|
5
|
+
export function currentDecision(pkg, obligationId) {
|
|
6
|
+
const decisions = pkg.payload.decisions.filter((d) => d.obligation_id === obligationId);
|
|
7
|
+
const superseded = new Set(decisions.map((d) => d.supersedes_decision_id).filter((id) => id !== null));
|
|
8
|
+
const current = decisions.filter((d) => !superseded.has(d.decision_id));
|
|
9
|
+
return current.reduce((latest, d) => (latest === null || d.decided_at > latest.decided_at ? d : latest), null);
|
|
10
|
+
}
|
|
11
|
+
function isFinal(decision) {
|
|
12
|
+
return decision.outcome !== "disputed" && decision.outcome !== "insufficient_evidence" && decision.disputed_amount_minor === 0 && decision.pending_amount_minor === 0;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The verdict payload for an obligation, read from its closure package. Sign it with the service key (signPayload).
|
|
16
|
+
* Throws when the package holds no decision for the obligation.
|
|
17
|
+
*/
|
|
18
|
+
export function buildClearingVerdict(pkg, options) {
|
|
19
|
+
const obligationId = options.obligationId ?? pkg.payload.requested_obligation_id;
|
|
20
|
+
const decision = currentDecision(pkg, obligationId);
|
|
21
|
+
if (!decision)
|
|
22
|
+
throw new Error(`obligation ${obligationId} has no clearing decision yet`);
|
|
23
|
+
return {
|
|
24
|
+
document_type: CLEARING_VERDICT_TYPE,
|
|
25
|
+
verdict_version: "1.0",
|
|
26
|
+
issued_at: new Date(options.issuedAt).toISOString(),
|
|
27
|
+
obligation_id: obligationId,
|
|
28
|
+
decision: {
|
|
29
|
+
decision_id: decision.decision_id,
|
|
30
|
+
decision_digest: decision.decision_digest,
|
|
31
|
+
decided_at: decision.decided_at,
|
|
32
|
+
outcome: decision.outcome,
|
|
33
|
+
currency: decision.currency,
|
|
34
|
+
accepted_amount_minor: decision.accepted_amount_minor,
|
|
35
|
+
rejected_amount_minor: decision.rejected_amount_minor,
|
|
36
|
+
disputed_amount_minor: decision.disputed_amount_minor,
|
|
37
|
+
pending_amount_minor: decision.pending_amount_minor,
|
|
38
|
+
},
|
|
39
|
+
final: isFinal(decision),
|
|
40
|
+
escrow: options.escrow ?? null,
|
|
41
|
+
package_digest: digestOf(pkg.payload),
|
|
42
|
+
stance: "record_only",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Offline verification of a clearing verdict: the service signature, and, given the closure package it names, that
|
|
47
|
+
* the package verifies and the verdict states the decision in effect in it. Without the package the decision is not
|
|
48
|
+
* compared with its evidence (reported as not inspected).
|
|
49
|
+
*/
|
|
50
|
+
export function verifyClearingVerdict(input, options) {
|
|
51
|
+
const parsed = ClearingVerdictSchema.safeParse(input);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
return { valid: false, checks: [{ name: "schema", ok: false, details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }] };
|
|
54
|
+
}
|
|
55
|
+
const verdict = parsed.data;
|
|
56
|
+
const checks = [{ name: "schema", ok: true, details: [] }];
|
|
57
|
+
const serviceKey = options.trustedKeys.find((k) => k.key_id === verdict.signature.key_id && k.key_version === verdict.signature.key_version && k.actor_id === SERVICE_ACTOR_ID);
|
|
58
|
+
const keyValid = !!serviceKey && serviceKey.valid_from <= verdict.payload.issued_at && (serviceKey.revoked_at === null || serviceKey.revoked_at > verdict.payload.issued_at);
|
|
59
|
+
const signatureOk = keyValid && verifyPayload(verdict, serviceKey.public_key);
|
|
60
|
+
const signatureProblem = !serviceKey ? "service key not among trusted keys" : !keyValid ? "service key was not valid when the verdict was issued" : "signature does not verify";
|
|
61
|
+
checks.push({ name: "verdict_signature", ok: signatureOk, details: signatureOk ? [] : [signatureProblem] });
|
|
62
|
+
if (options.closurePackage === undefined) {
|
|
63
|
+
checks.push({ name: "decision", ok: true, state: "not_inspected", details: ["no closure package supplied; the decision was not compared with its evidence"] });
|
|
64
|
+
return { valid: checks.every((c) => c.ok), checks };
|
|
65
|
+
}
|
|
66
|
+
const packageReport = verifyClosurePackage(options.closurePackage, { trustedKeys: options.trustedKeys });
|
|
67
|
+
checks.push({ name: "closure_package", ok: packageReport.valid, details: packageReport.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.details.join("; ")}`) });
|
|
68
|
+
if (!packageReport.valid)
|
|
69
|
+
return { valid: false, checks };
|
|
70
|
+
const pkg = options.closurePackage;
|
|
71
|
+
// Unknown members are not signed, so a valid package can still hold values canonical JSON refuses; such a package is not the one read.
|
|
72
|
+
let digestOk;
|
|
73
|
+
try {
|
|
74
|
+
digestOk = digestOf(pkg.payload) === verdict.payload.package_digest;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
digestOk = false;
|
|
78
|
+
}
|
|
79
|
+
checks.push({ name: "package_digest", ok: digestOk, details: digestOk ? [] : ["the closure package is not the one the verdict was read from"] });
|
|
80
|
+
const decision = currentDecision(pkg, verdict.payload.obligation_id);
|
|
81
|
+
const decisionProblems = [];
|
|
82
|
+
if (!decision) {
|
|
83
|
+
decisionProblems.push(`the package holds no decision for ${verdict.payload.obligation_id}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (decision.decision_id !== verdict.payload.decision.decision_id)
|
|
87
|
+
decisionProblems.push(`the decision in effect is ${decision.decision_id}, not ${verdict.payload.decision.decision_id}`);
|
|
88
|
+
for (const field of Object.keys(verdict.payload.decision)) {
|
|
89
|
+
if (decision[field] !== verdict.payload.decision[field])
|
|
90
|
+
decisionProblems.push(`decision.${field} differs from the package`);
|
|
91
|
+
}
|
|
92
|
+
if (isFinal(decision) !== verdict.payload.final)
|
|
93
|
+
decisionProblems.push("final differs from the decision's amounts and outcome");
|
|
94
|
+
}
|
|
95
|
+
checks.push({ name: "decision", ok: decisionProblems.length === 0, details: decisionProblems });
|
|
96
|
+
return { valid: checks.every((c) => c.ok), checks };
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atcn/core",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "ATCN obligations: policy decisions on submitted evidence, a balanced journal, and closure packages and clearing verdicts you can verify offline",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"build": "tsc -p tsconfig.build.json"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@atcn/schema": "1.
|
|
35
|
+
"@atcn/schema": "1.5.0"
|
|
36
36
|
}
|
|
37
37
|
}
|