@sanning/proof 0.19.1 → 0.20.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.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
@@ -0,0 +1,608 @@
1
+ // Reading a `sanning.agent` record: agent-events.md §10, steps 1 to 7, as the
2
+ // pinned specs release states them (vendor/specs/agent-events.md).
3
+ //
4
+ // A record reaches this layer after the envelope layer verified its signature
5
+ // and payload hash. Steps 1 to 6 read the record on its own against the schemas
6
+ // bundled in agent-schemas.generated.ts; step 7 reads it with the records the
7
+ // verifier holds beside it. Nothing here reads a file or the network.
8
+
9
+ import { AGENT_SCHEMA_VERSIONS } from "./agent-schemas.generated.js";
10
+ import { sha256Hex, utf8 } from "./crypto.js";
11
+ import { deepEqual, escapeToken, has, isObject, SchemaSet, type SchemaError, unescapeToken } from "./json-schema.js";
12
+ import { jcs } from "./verifier.js";
13
+
14
+ /** A reading's verdict. `null`: the record is outside this vocabulary and this layer gives none. */
15
+ export type AgentVerdict = "verified" | "failed" | "incomplete";
16
+
17
+ /** One reason (section 10.1) and a JSON Pointer into the record for the field it is about. */
18
+ export interface AgentReason {
19
+ readonly reason: string;
20
+ readonly at: string;
21
+ }
22
+
23
+ export interface AgentRecordReading {
24
+ readonly verdict: AgentVerdict | null;
25
+ /** The first reason of the verdict, when the verdict is not `verified`. */
26
+ readonly reason?: string;
27
+ /** Where `reason` is about. */
28
+ readonly at?: string;
29
+ /** Every reason, in step order, when the verdict is not `verified`. */
30
+ readonly reasons?: readonly AgentReason[];
31
+ /** Findings in the order step 6, then steps 7a to 7j, produce them. */
32
+ readonly findings: string[];
33
+ /**
34
+ * For each `listed_record_absent` reason, in order, the `event_id` the closing record
35
+ * lists and the verifier does not hold. Not part of the reading as `expected.json`
36
+ * spells it: present only where there is one, and not enumerable, so `JSON.stringify`
37
+ * leaves it out. The claims name each record by it.
38
+ */
39
+ readonly absent?: readonly string[];
40
+ }
41
+
42
+ /** A record as the verifier holds it after the envelope layer: its envelope's `event_id` and key. */
43
+ export interface AgentHeldRecord {
44
+ readonly event_id: string;
45
+ readonly public_key: string;
46
+ readonly record: unknown;
47
+ }
48
+
49
+ /** The `sanning.events/v2` types outside the agent vocabulary (§3), read by the profile and run-records.md. */
50
+ export const RECORD_TYPES_OUTSIDE_AGENT: readonly string[] = Object.freeze([
51
+ "event",
52
+ "checkpoint",
53
+ "declaration",
54
+ "runner.run_opened",
55
+ "runner.run_closed",
56
+ "runner.egress",
57
+ "runner.relay",
58
+ ]);
59
+
60
+ /** The `agent.*` types of each vocabulary version this kernel knows, keyed by `extras.event_schema`. With RECORD_TYPES_OUTSIDE_AGENT they are that version's closed set. */
61
+ export const AGENT_EVENT_TYPES: Readonly<Record<string, readonly string[]>> = Object.freeze(
62
+ Object.fromEntries(
63
+ Object.entries(AGENT_SCHEMA_VERSIONS).map(([version, v]) => [version, Object.freeze(Object.keys(v.types))]),
64
+ ),
65
+ );
66
+
67
+ // Step 2: sanning.agent/<major>[.<minor>], each 1 to 9 ASCII digits with no
68
+ // leading zero, minor never 0. No `m` flag: `$` is the end of the string.
69
+ const EVENT_SCHEMA_RE = /^sanning\.agent\/[1-9][0-9]{0,8}(\.[1-9][0-9]{0,8})?$/;
70
+ const OUTSIDE = new Set(RECORD_TYPES_OUTSIDE_AGENT);
71
+ const SALT_RE = /^[0-9a-f]{32}$/;
72
+ // The two reasons that read incomplete: a check that could not be completed. Every
73
+ // other reason reads failed, with the reason kind nonconforming.
74
+ export const INCOMPLETE_REASONS: ReadonlySet<string> = new Set(["event_schema_unknown", "listed_record_absent"]);
75
+
76
+ const OPENER: Readonly<Record<string, string>> = {
77
+ "agent.step.ended": "agent.step.started",
78
+ "agent.model_call.ended": "agent.model_call.started",
79
+ "agent.tool_call.ended": "agent.tool_call.started",
80
+ "agent.retrieval.ended": "agent.retrieval.started",
81
+ "agent.approval.resolved": "agent.approval.requested",
82
+ };
83
+ const CLOSER: Readonly<Record<string, string>> = Object.fromEntries(Object.entries(OPENER).map(([c, o]) => [o, c]));
84
+
85
+ // Step 7a: the checks on a candidate start, worst first.
86
+ const PAIR_REASONS = ["pair_type_mismatch", "pair_run_mismatch", "pair_call_mismatch", "pair_field_mismatch"];
87
+ // §9.1: the fields both halves of one operation agree on, in the order they are checked.
88
+ const PAIR_FIELDS = [
89
+ "gen_ai.operation.name",
90
+ "gen_ai.provider.name",
91
+ "gen_ai.tool.name",
92
+ "approval.subject_event_id",
93
+ "approval.actor",
94
+ "approval.policy_hash",
95
+ ];
96
+ // Step 7h: the approval verdicts that tell the adapter not to run the tool.
97
+ const REFUSALS = new Set(["rejected", "responded"]);
98
+ // §10.1: of several schema errors, the reason first here is reported, then the smallest pointer.
99
+ const SCHEMA_REASONS = ["unknown_field", "missing_field", "bad_value"];
100
+
101
+ // A record's shape is known once it passed the schema check.
102
+ type Rec = any;
103
+
104
+ const schemaSet = new SchemaSet(
105
+ Object.values(AGENT_SCHEMA_VERSIONS).flatMap((v) => [v.common, ...Object.values(v.types)]),
106
+ );
107
+ const TYPES: ReadonlyMap<string, ReadonlyMap<string, { id: string; schema: Rec }>> = new Map(
108
+ Object.entries(AGENT_SCHEMA_VERSIONS).map(([version, v]) => [
109
+ version,
110
+ new Map(
111
+ Object.entries(v.types).map(([type, s]) => [type, { id: (s as Rec).$id as string, schema: s as Rec }]),
112
+ ),
113
+ ]),
114
+ );
115
+
116
+ function revealableOf(schema: Rec): string[] {
117
+ return schema.properties.context.properties.revealed?.items.enum ?? [];
118
+ }
119
+
120
+ /**
121
+ * One reading in the words both kernels print: the verdict, every reason and
122
+ * where, then the findings, which are reported beside the verdict and never
123
+ * move it. Byte-identical to `render_agent_reading` in the Python kernel.
124
+ */
125
+ export function renderAgentReading(reading: AgentRecordReading): string {
126
+ let text = reading.verdict ?? "no verdict";
127
+ if (reading.reasons !== undefined && reading.reasons.length > 0) {
128
+ text += `: ${reading.reasons.map((r) => `${r.reason} at ${r.at}`).join(", ")}`;
129
+ }
130
+ if (reading.findings.length > 0) text += `; findings: ${reading.findings.join(", ")}`;
131
+ return text;
132
+ }
133
+
134
+ /** Steps 1 to 6 of agent-events.md §10: one record on its own. */
135
+ export function readAgentRecord(record: unknown): AgentRecordReading {
136
+ const r = record as Rec;
137
+ const type = isObject(r) ? r.event_type : undefined;
138
+ const extras = isObject(r) ? r.extras : undefined;
139
+ const hasSchema = isObject(extras) && has(extras, "event_schema");
140
+ // Step 1: the namespace.
141
+ if (typeof type === "string" && OUTSIDE.has(type)) {
142
+ if (hasSchema) return failed("event_schema_misplaced", "/extras/event_schema");
143
+ // The salt is defined for every record; on these types only its form is read.
144
+ const ctx = r.context;
145
+ if (isObject(ctx) && has(ctx, "salt") && !(typeof ctx.salt === "string" && SALT_RE.test(ctx.salt))) {
146
+ return failed("bad_value", "/context/salt");
147
+ }
148
+ return { verdict: null, findings: ["not_an_agent_record"] };
149
+ }
150
+ if (typeof type !== "string" || !type.startsWith("agent.")) {
151
+ return failed("event_type_not_in_set", "/event_type");
152
+ }
153
+ // Step 2: the version, before the type, so a type a later minor adds reads incomplete.
154
+ if (!hasSchema) return failed("event_schema_missing", "/extras/event_schema");
155
+ const version = extras.event_schema;
156
+ if (typeof version !== "string" || !EVENT_SCHEMA_RE.test(version)) {
157
+ return failed("event_schema_invalid", "/extras/event_schema");
158
+ }
159
+ const types = TYPES.get(version);
160
+ if (types === undefined) {
161
+ const at = "/extras/event_schema";
162
+ return {
163
+ verdict: "incomplete",
164
+ reason: "event_schema_unknown",
165
+ at,
166
+ reasons: [{ reason: "event_schema_unknown", at }],
167
+ findings: [],
168
+ };
169
+ }
170
+ // Step 3: the type, in the closed set of that version.
171
+ const entry = types.get(type);
172
+ if (entry === undefined) return failed("event_type_not_in_set", "/event_type");
173
+ // Step 4: the schema.
174
+ const errors = schemaSet.validate(entry.id, r);
175
+ if (errors.length > 0) {
176
+ const e = pickError(r, errors);
177
+ return failed(e.reason, e.at);
178
+ }
179
+ // Step 5: the rules the schema cannot state, in this order.
180
+ const ctx = r.context;
181
+ const meta = r.metadata;
182
+ for (const path of Object.keys(ctx.hidden ?? {}).sort()) {
183
+ if (present(meta, path)) return failed("hidden_field_present", pointer(path));
184
+ }
185
+ const revealed = new Set<string>(ctx.revealed ?? []);
186
+ for (const path of [...revealed].sort()) {
187
+ if (!present(meta, path)) return failed("revealed_field_absent", pointer(path));
188
+ }
189
+ for (const path of [...revealableOf(entry.schema)].sort()) {
190
+ if (present(meta, path) && !revealed.has(path)) return failed("revealed_field_unlisted", pointer(path));
191
+ }
192
+ if (meta.capture !== undefined && meta.run.seq !== 0) return failed("capture_not_at_head", "/metadata/capture");
193
+ if (type.startsWith("agent.handoff.")) {
194
+ const h = meta.handoff;
195
+ if (h.file_parts > h.parts) return failed("file_parts_exceed_parts", "/metadata/handoff/file_parts");
196
+ if (type === "agent.handoff.received") {
197
+ const indexes: number[] | undefined = h.unchecked_held_parts;
198
+ const count: number | undefined = ctx.unchecked_held_part_count;
199
+ if (indexes !== undefined && count === undefined) {
200
+ return failed("unchecked_held_parts_missing", "/context/unchecked_held_part_count");
201
+ }
202
+ if (indexes === undefined && count !== undefined) {
203
+ return failed("unchecked_held_parts_missing", "/metadata/handoff/unchecked_held_parts");
204
+ }
205
+ if (count !== undefined && count !== indexes!.length) {
206
+ return failed("unchecked_held_parts_mismatch", "/context/unchecked_held_part_count");
207
+ }
208
+ if (count !== undefined && count > h.file_parts) {
209
+ return failed("unchecked_held_parts_exceed_file_parts", "/context/unchecked_held_part_count");
210
+ }
211
+ for (const [i, index] of (indexes ?? []).entries()) {
212
+ if (index >= h.parts) return failed("part_index_out_of_range", `/metadata/handoff/unchecked_held_parts/${i}`);
213
+ }
214
+ }
215
+ }
216
+ if (type === "agent.run_closed" && meta.closing.record_ids.length !== meta.closing.record_count) {
217
+ return failed("closing_count_mismatch", "/metadata/closing/record_count");
218
+ }
219
+ if (type === "agent.file") {
220
+ const unhashed = meta.outcome.status === "failed";
221
+ if (unhashed !== (meta.artifact?.reason !== undefined)) {
222
+ return failed("artifact_reason_mismatch", "/metadata/artifact/reason");
223
+ }
224
+ }
225
+ // Step 6: verified, with the findings a reader shows.
226
+ const findings: string[] = [];
227
+ if (meta.run.plumbing === true) findings.push("plumbing");
228
+ if (ctx.content_scrubbed === true) findings.push("content_scrubbed");
229
+ return { verdict: "verified", findings };
230
+ }
231
+
232
+ /**
233
+ * Steps 1 to 7 of agent-events.md §10: `subject` read with the records the
234
+ * verifier holds beside it. Asynchronous only because 7a hashes a field to
235
+ * compare it with a hidden one.
236
+ */
237
+ export async function readAgentRecordWithHeld(
238
+ subject: AgentHeldRecord,
239
+ held: readonly AgentHeldRecord[] = [],
240
+ ): Promise<AgentRecordReading> {
241
+ const one = readAgentRecord(isObject(subject) ? subject.record : undefined);
242
+ if (one.verdict !== "verified") return one;
243
+ // The held records are a set. An entry that is not an object with a string
244
+ // event_id is no record and is skipped. Step 7 reads only the held records
245
+ // that read verified at steps 1 to 6.
246
+ const all = distinct(
247
+ held.filter((h): h is AgentHeldRecord => isObject(h) && typeof h.event_id === "string"),
248
+ );
249
+ const peers = all.filter((h) => readAgentRecord(h.record).verdict === "verified");
250
+ return readBeside(subject, one, peers, await commitmentsOf([subject, ...peers]));
251
+ }
252
+
253
+ /**
254
+ * Read a pile: each item through steps 1 to 7, with every item held beside it.
255
+ * Each item is a `sanning.events/v2` record whose envelope verified and whose
256
+ * record bytes bound to it. Step 1 runs on every item, so a type outside the
257
+ * closed set reads `failed` whatever its namespace. `null` for an item
258
+ * that is not an object and for one of the seven types outside this vocabulary
259
+ * with no `event_schema`, which step 1 gives no verdict unless its salt is in
260
+ * the wrong form. The item is set aside from its own pile: a record is never
261
+ * one of its own held records. The one reading `verify`, `case` and the bundle
262
+ * verifiers wire in.
263
+ */
264
+ export async function readAgentRecords(
265
+ items: readonly AgentHeldRecord[],
266
+ ): Promise<(AgentRecordReading | null)[]> {
267
+ const all = distinct(
268
+ items.filter((h): h is AgentHeldRecord => isObject(h) && typeof h.event_id === "string"),
269
+ );
270
+ const peers = all.filter((h) => readAgentRecord(h.record).verdict === "verified");
271
+ const readable = items.filter((h) => isObject(h) && readAgentRecord(h.record).verdict === "verified");
272
+ const commitments = await commitmentsOf(readable);
273
+ return items.map((item) => {
274
+ if (!isObject(item)) return null;
275
+ const one = readAgentRecord(item.record);
276
+ if (one.verdict === null) return null;
277
+ if (one.verdict !== "verified") return one;
278
+ return readBeside(item, one, peers, commitments);
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Step 7: `subject`, which read `one` (verified) on its own, with the held
284
+ * records. A held copy of `subject` (`sameRecord`) is set aside first: a record
285
+ * is never one of its own held records. A held record with the subject's
286
+ * event_id and other bytes is another record and stays.
287
+ */
288
+ function readBeside(
289
+ subject: AgentHeldRecord,
290
+ one: AgentRecordReading,
291
+ held: readonly AgentHeldRecord[],
292
+ commitments: Commitments,
293
+ ): AgentRecordReading {
294
+ const peers = held.filter((h) => !sameRecord(h, subject));
295
+ const record = subject.record as Rec;
296
+ const type: string = record.event_type;
297
+ const run = record.metadata.run;
298
+ const key = keyOf(subject.public_key);
299
+ const findings = [...one.findings];
300
+ // Every sub-step of step 7 runs, and every reason it gives is kept, in step order.
301
+ const reasons: AgentReason[] = [];
302
+ const absentIds: string[] = [];
303
+ const mine = distinct([subject, ...peers]).filter(
304
+ (h) => keyOf(h.public_key) === key && (h.record as Rec).metadata.run.root_run_id === run.root_run_id,
305
+ );
306
+ const grade = (end: Rec, start: Rec, opener: string): Grade | undefined =>
307
+ gradePair(end, start, opener, commitments);
308
+ const endsOf = (start: AgentHeldRecord): AgentHeldRecord[] => {
309
+ const s = start.record as Rec;
310
+ const closer = CLOSER[s.event_type];
311
+ return peers.filter((h) => {
312
+ const e = h.record as Rec;
313
+ return (
314
+ e.event_type === closer &&
315
+ keyOf(h.public_key) === keyOf(start.public_key) &&
316
+ e.metadata.run.started_event_id === start.event_id &&
317
+ grade(e, s, s.event_type) === undefined
318
+ );
319
+ });
320
+ };
321
+
322
+ // Step 7a: an end and the record it names.
323
+ const opener = OPENER[type];
324
+ if (opener !== undefined && run.started_event_id === null) {
325
+ // The invocation's first record is its one record at run.seq 0.
326
+ const heads = mine.filter((h) => (h.record as Rec).metadata.run.seq === 0);
327
+ const declared =
328
+ heads.length === 1 && ((heads[0]!.record as Rec).metadata.capture?.not_captured ?? []).includes(opener);
329
+ findings.push(declared ? "start_not_captured" : "start_undeclared");
330
+ } else if (opener !== undefined) {
331
+ // A candidate is a record this key signed: a record another key signed is never this end's start.
332
+ const candidates = peers.filter((h) => h.event_id === run.started_event_id && keyOf(h.public_key) === key);
333
+ if (candidates.length === 0) findings.push("start_absent");
334
+ if (candidates.length > 1) findings.push("start_ambiguous");
335
+ let worst: Grade | undefined;
336
+ for (const c of candidates) {
337
+ const g = grade(record, c.record, opener);
338
+ if (g !== undefined && (worst === undefined || compareGrade(g, worst) < 0)) worst = g;
339
+ }
340
+ if (worst !== undefined) reasons.push({ reason: worst.reason, at: worst.at });
341
+ }
342
+ // Step 7b: a start and the records that end it.
343
+ if (CLOSER[type] !== undefined) {
344
+ const ends = endsOf(subject);
345
+ if (ends.length === 0) findings.push("unended");
346
+ if (ends.length > 1) findings.push("ended_more_than_once");
347
+ }
348
+ // Step 7c: a closing record, judged only on every record it lists.
349
+ if (type === "agent.run_closed") {
350
+ const ids: string[] = record.metadata.closing.record_ids;
351
+ let absent = 0;
352
+ for (const [i, id] of ids.entries()) {
353
+ if (!peers.some((h) => h.event_id === id && keyOf(h.public_key) === key)) {
354
+ reasons.push({ reason: "listed_record_absent", at: `/metadata/closing/record_ids/${i}` });
355
+ absentIds.push(id);
356
+ absent++;
357
+ }
358
+ }
359
+ if (absent === 0 && record.metadata.closing.outcome.observed.status === "completed") {
360
+ const listed = new Set(ids);
361
+ const open = peers.some(
362
+ (h) =>
363
+ listed.has(h.event_id) &&
364
+ keyOf(h.public_key) === key &&
365
+ CLOSER[(h.record as Rec).event_type] !== undefined &&
366
+ !endsOf(h).some((e) => listed.has(e.event_id)),
367
+ );
368
+ if (open) findings.push("completed_with_open_operation");
369
+ }
370
+ }
371
+ // Step 7d: the two records of one hand-off message agree on its parts.
372
+ if (type.startsWith("agent.handoff.")) {
373
+ const other = type === "agent.handoff.sent" ? "agent.handoff.received" : "agent.handoff.sent";
374
+ const named = keyOf(record.event.counterparty_public_key);
375
+ const hash = String(record.event.message_hash).toLowerCase();
376
+ // Candidates for each other: each is signed by the key the other names.
377
+ const counterparts = peers.filter((h) => {
378
+ const c = h.record as Rec;
379
+ return (
380
+ c.event_type === other &&
381
+ keyOf(h.public_key) === named &&
382
+ keyOf(c.event.counterparty_public_key) === key &&
383
+ String(c.event.message_hash).toLowerCase() === hash
384
+ );
385
+ });
386
+ // A finding on both records, never a verdict: two keys signed them, so a verdict
387
+ // would let one side's count make the other side's honest record read failed.
388
+ const counts = (r: Rec): string => `${r.metadata.handoff.parts}/${r.metadata.handoff.file_parts}`;
389
+ if (counterparts.some((h) => counts(h.record) !== counts(record))) findings.push("handoff_parts_mismatch");
390
+ }
391
+ // Step 7e is not in sanning.agent/1: the runner cross-check waits for the runner's fields.
392
+ // Step 7f: one invocation, one data mode.
393
+ const mixed = peers.some((h) => {
394
+ const p = h.record as Rec;
395
+ return (
396
+ keyOf(h.public_key) === key &&
397
+ p.metadata.run.root_run_id === run.root_run_id &&
398
+ p.context.data_mode !== record.context.data_mode
399
+ );
400
+ });
401
+ if (mixed) findings.push("data_mode_mixed");
402
+ // Step 7g: one record at each position of the invocation's chain.
403
+ if (mine.some((h) => !sameRecord(h, subject) && (h.record as Rec).metadata.run.seq === run.seq)) {
404
+ findings.push("seq_duplicate");
405
+ }
406
+ // Step 7h: a tool call that ran after its approval refused it. A refusal is an
407
+ // answer, under this key, whose subject is this call's start and whose verdict
408
+ // is rejected or responded.
409
+ if (type === "agent.tool_call.ended" && run.started_event_id !== null) {
410
+ const refused = peers.some((h) => {
411
+ const a = h.record as Rec;
412
+ return (
413
+ a.event_type === "agent.approval.resolved" &&
414
+ keyOf(h.public_key) === key &&
415
+ a.metadata.approval.subject_event_id === run.started_event_id &&
416
+ REFUSALS.has(a.metadata.approval.verdict)
417
+ );
418
+ });
419
+ const status = record.metadata.outcome.status;
420
+ if (refused && (status === "completed" || status === "failed")) findings.push("ran_after_refusal");
421
+ }
422
+ // Step 7i: the position directly before this record is not held, and an earlier
423
+ // one of this invocation is. The verifier cannot tell a missing record from one
424
+ // never written.
425
+ const seqs = mine.map((h) => (h.record as Rec).metadata.run.seq as number);
426
+ if (run.seq > 0 && seqs.some((n) => n < run.seq) && !seqs.some((n) => n === run.seq - 1)) {
427
+ findings.push("seq_gap");
428
+ }
429
+ // Step 7j: the run promised a closing record, and none of this invocation is held.
430
+ if (type === "agent.run_opened" && !mine.some((h) => (h.record as Rec).event_type === "agent.run_closed")) {
431
+ findings.push("closing_not_held");
432
+ }
433
+ return reading(reasons, findings, absentIds);
434
+ }
435
+
436
+ /**
437
+ * Step 7: the reading from every reason step 7 gave. The verdict is the worst
438
+ * (failed, then incomplete); `reason` and `at` are the first reason of that verdict.
439
+ */
440
+ function reading(reasons: AgentReason[], findings: string[], absent: string[]): AgentRecordReading {
441
+ if (reasons.length === 0) return { verdict: "verified", findings };
442
+ const failed = reasons.some((r) => !INCOMPLETE_REASONS.has(r.reason));
443
+ const first = reasons.find((r) => INCOMPLETE_REASONS.has(r.reason) !== failed)!;
444
+ const out: AgentRecordReading = {
445
+ verdict: failed ? "failed" : "incomplete",
446
+ reason: first.reason,
447
+ at: first.at,
448
+ reasons,
449
+ findings,
450
+ };
451
+ // Carried for the claims and never serialized: not part of the reading `expected.json`
452
+ // spells, as Python's `to_dict` leaves it out.
453
+ if (absent.length > 0) Object.defineProperty(out, "absent", { value: absent, enumerable: false });
454
+ return out;
455
+ }
456
+
457
+ interface Grade {
458
+ readonly reason: string;
459
+ readonly at: string;
460
+ readonly rank: readonly [number, number];
461
+ }
462
+
463
+ type Commitments = ReadonlyMap<unknown, ReadonlyMap<string, string | undefined>>;
464
+
465
+ /** Step 7a: one candidate start, under the end's key, graded against the end. undefined when it is the end's start. */
466
+ function gradePair(end: Rec, start: Rec, opener: string, commitments: Commitments): Grade | undefined {
467
+ const at = "/metadata/run/started_event_id";
468
+ if (start.event_type !== opener) return grade("pair_type_mismatch", at);
469
+ const a = end.metadata;
470
+ const b = start.metadata;
471
+ if (a.run.run_id !== b.run.run_id || a.run.root_run_id !== b.run.root_run_id) {
472
+ // The one exception: an approval answered in a later invocation of the same
473
+ // conversation, where both ids differ.
474
+ const approval = end.event_type === "agent.approval.resolved";
475
+ const conv = a.gen_ai?.conversation?.id;
476
+ const both = a.run.run_id !== b.run.run_id && a.run.root_run_id !== b.run.root_run_id;
477
+ if (!approval || !both || conv === undefined || conv !== b.gen_ai?.conversation?.id) {
478
+ return grade("pair_run_mismatch", at);
479
+ }
480
+ }
481
+ if (end.event_type === "agent.tool_call.ended" && a.gen_ai.tool.call.id !== b.gen_ai.tool.call.id) {
482
+ return grade("pair_call_mismatch", at);
483
+ }
484
+ for (const [i, path] of PAIR_FIELDS.entries()) {
485
+ const x = commitments.get(end)?.get(path);
486
+ const y = commitments.get(start)?.get(path);
487
+ if (x !== undefined && y !== undefined && x !== y) return grade("pair_field_mismatch", pointer(path), i);
488
+ }
489
+ return undefined;
490
+ }
491
+
492
+ function grade(reason: string, at: string, field = -1): Grade {
493
+ return { reason, at, rank: [PAIR_REASONS.indexOf(reason), field] };
494
+ }
495
+
496
+ function compareGrade(a: Grade, b: Grade): number {
497
+ return a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1];
498
+ }
499
+
500
+ /**
501
+ * What each record commits to for each field of §9.1: SHA-256 of the JCS bytes
502
+ * of its value, or its hidden hash. A value JCS cannot encode commits to
503
+ * nothing a hidden hash can equal, and still equals the same value in clear.
504
+ */
505
+ async function commitmentsOf(items: readonly AgentHeldRecord[]): Promise<Commitments> {
506
+ const out = new Map<unknown, Map<string, string | undefined>>();
507
+ for (const h of items) {
508
+ const record = h.record as Rec;
509
+ if (out.has(record)) continue;
510
+ const m = new Map<string, string | undefined>();
511
+ for (const path of PAIR_FIELDS) {
512
+ if (present(record.metadata, path)) {
513
+ const value = lookup(record.metadata, path);
514
+ const bytes = jcsOrUndefined(value);
515
+ m.set(path, bytes === undefined ? `unencodable:${String(value)}` : await sha256Hex(utf8(bytes)));
516
+ } else {
517
+ m.set(path, record.context.hidden?.[path]);
518
+ }
519
+ }
520
+ out.set(record, m);
521
+ }
522
+ return out;
523
+ }
524
+
525
+ function jcsOrUndefined(value: unknown): string | undefined {
526
+ try {
527
+ return jcs(value);
528
+ } catch {
529
+ return undefined;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Two held records are one when their event ids, their keys and their JCS bytes
535
+ * are equal. Equal JSON values have equal JCS bytes, so this compares values:
536
+ * a copy JCS refuses to encode is still a copy.
537
+ */
538
+ function sameRecord(x: AgentHeldRecord, y: AgentHeldRecord): boolean {
539
+ if (x.event_id !== y.event_id || keyOf(x.public_key) !== keyOf(y.public_key)) return false;
540
+ return deepEqual(x.record, y.record);
541
+ }
542
+
543
+ /** The held records as a set: the first of each group of copies. */
544
+ function distinct(list: readonly AgentHeldRecord[]): AgentHeldRecord[] {
545
+ return list.filter((h, i) => list.findIndex((x) => sameRecord(x, h)) === i);
546
+ }
547
+
548
+ /** A key compared as a key: 64 hex characters, case ignored. */
549
+ function keyOf(k: unknown): string {
550
+ return String(k).toLowerCase();
551
+ }
552
+
553
+ /** Steps 1 to 6 stop at the first failure: one reason. */
554
+ function failed(reason: string, at: string): AgentRecordReading {
555
+ return { verdict: "failed", reason, at, reasons: [{ reason, at }], findings: [] };
556
+ }
557
+
558
+ /** A member is present when its key is there, whatever its value: a null value is present. */
559
+ function present(obj: unknown, dotted: string): boolean {
560
+ let cur = obj;
561
+ for (const k of dotted.split(".")) {
562
+ if (!isObject(cur) || !has(cur, k)) return false;
563
+ cur = cur[k];
564
+ }
565
+ return true;
566
+ }
567
+
568
+ function lookup(obj: Rec, dotted: string): unknown {
569
+ let cur = obj;
570
+ for (const k of dotted.split(".")) cur = cur[k];
571
+ return cur;
572
+ }
573
+
574
+ function pointer(dotted: string): string {
575
+ return "/metadata/" + dotted.split(".").map(escapeToken).join("/");
576
+ }
577
+
578
+ /**
579
+ * §10.1: of several schema errors, `unknown_field` before `missing_field`
580
+ * before `bad_value`, then the smallest pointer. Pointers compare token by
581
+ * token, an array index as a number and any other token by UTF-16 code units; a
582
+ * pointer comes before every pointer it is a prefix of.
583
+ */
584
+ function pickError(record: Rec, errors: readonly SchemaError[]): SchemaError {
585
+ return [...errors].sort(
586
+ (a, b) =>
587
+ SCHEMA_REASONS.indexOf(a.reason) - SCHEMA_REASONS.indexOf(b.reason) || comparePointers(record, a.at, b.at),
588
+ )[0]!;
589
+ }
590
+
591
+ function tokens(p: string): string[] {
592
+ return p === "" ? [] : p.slice(1).split("/").map(unescapeToken);
593
+ }
594
+
595
+ function comparePointers(record: Rec, a: string, b: string): number {
596
+ const x = tokens(a);
597
+ const y = tokens(b);
598
+ let container: Rec = record;
599
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
600
+ if (x[i] !== y[i]) {
601
+ if (Array.isArray(container)) return Number(x[i]) - Number(y[i]);
602
+ return x[i]! < y[i]! ? -1 : 1;
603
+ }
604
+ container = container?.[x[i]!];
605
+ }
606
+ return x.length - y.length;
607
+ }
608
+