@cygnuxlabs/tracekit 1.3.0 → 1.4.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.
@@ -10,25 +10,48 @@
10
10
  * SLH-DSA-SHA2-128s hybrid checkpoint lines (Web Crypto has no SLH-DSA), Rekor v2 anchors, record-key certificates,
11
11
  * tracekit why findings and replay runs.
12
12
  * Monitor reports, revocations and the v1 format bridge are inputs of the reference's CLI that this one doesn't take.
13
+ * A bundle that needs a newer verifier is UNVERIFIABLE, with the commands that verify it; one whose records carry ext
14
+ * names this verifier doesn't interpret (format 1.4's non-critical slot) verifies, with a `not interpreted` warning
15
+ * per name; it interprets `amended` (the fields an LLM gateway changed in a request to show reasoning) and
16
+ * `openshell.policy` (OpenShell's sandbox policy changes, from the OpenShell middleware's OCSF import).
17
+ * `verify(bundle, null)` (no trust config) checks what needs no pinned key and is never VERIFIED. web.ts is this
18
+ * verifier as a browser page (verify.html).
13
19
  */
14
- import { canonicalize, strictParseBytes } from "./jcs.js";
20
+ import { canonicalize, repr as quoted, strictParseBytes } from "./jcs.js";
15
21
  import { HYBRID, NoteError, SIGNER_LEAVES, b64decode, bytes, equal, keyAlg, leafHash, openNote, parseLeaf, parseVkey, registryOrigin, runHash, sha256hex, spkiKid, unhex, verifyConsistency, verifyInclusion, verifyV2, vkey, } from "./primitives.js";
16
22
  import { Detector } from "./detect.js";
17
23
  import { same, validateEvent } from "./schema.js";
18
- import { MAX_ENTRY, readZip } from "./zip.js";
24
+ import { MAX_ENTRY, Unusable, readZip } from "./zip.js";
19
25
  export { MAX_ENTRIES, MAX_ENTRY, MAX_TOTAL, Unusable, readZip } from "./zip.js";
20
26
  export { StrictJSONError, canonicalize, strictParse, strictParseBytes } from "./jcs.js";
21
27
  /** The bundle format version this verifier implements: a bundle needing a later one is UNVERIFIABLE. */
22
- export const VERSION = "1.3.0";
28
+ export const VERSION = "1.4.0";
29
+ /** The browser verifier of the latest release (docs/RELEASING.md). */
30
+ const PAGE = "https://cygnux-labs.github.io/Tracekit/verify/";
23
31
  export const EXIT_OK = 0, EXIT_FAIL = 1, EXIT_BAD = 2, EXIT_WARN = 3;
24
32
  const FORMAT = "tracekit.bundle.v2";
25
33
  const MAX_LINE = 1 << 20;
26
- const MAX_LINES = MAX_ENTRY / 256; // per JSON lines file: no signed record is shorter than 256 bytes
34
+ const MIN_RECORD = 256; // no signed record's line is shorter: its hash, signature, ids and timestamp alone are longer
35
+ const MAX_LINES = MAX_ENTRY / MIN_RECORD; // per JSON lines file
27
36
  const CLASSES = ["public", "customer", "tracekit", "operator"];
28
37
  const KEY_TYPES = ["signer.epoch", "key.retire"];
29
38
  const ZERO_HASH = "sha256:" + "0".repeat(64);
30
39
  const MATRIX = ["model_calls", "tool_calls", "network", "processes", "services"];
31
40
  const COVERED = ["none", "reported", "observed", "enforced"]; // weakest first
41
+ const SEVERITIES = ["critical", "high", "medium", "low", "info"]; // a signed finding's, worst first
42
+ // the closed fields of a signed finding's verdict (tracekit/signer/rpc_schema.py REVIEW) and the values each may take
43
+ const FAMILIES = ["anthropic", "openai", "google", "meta", "mistral", "deepseek", "qwen", "xai"];
44
+ const VERDICT = {
45
+ kind: ["finding", "coverage"], severity: SEVERITIES, tier: ["gateway", "harness", "transcript", "sdk"],
46
+ order: ["signed_before", "transcript_order", "after"], alignment: ["ok", "drift", "misaligned"],
47
+ reason: ["none", "unrequested_action", "unexplained_action", "contradicted_reasoning", "scope_creep", "workaround",
48
+ "false_success", "followed_tool_output", "injection", "other"],
49
+ };
50
+ /** The reference verifier's exception names, which reports print: a bundle the format refuses, a key it lacks. */
51
+ class ValueError extends Error {
52
+ }
53
+ class KeyError extends Error {
54
+ }
32
55
  export class Report {
33
56
  checks = [];
34
57
  failures = [];
@@ -36,6 +59,8 @@ export class Report {
36
59
  notes = [];
37
60
  integrity = "UNUSABLE BUNDLE";
38
61
  assurance = "none";
62
+ /** The Findings line: signed findings, an opinion of the run kept apart from its integrity (null: none). */
63
+ findings = null;
39
64
  /** Checks the bundle needs that this verifier does not implement. */
40
65
  unsupported = [];
41
66
  check(check, ok, detail = "", problems = [], warn = false) {
@@ -66,7 +91,7 @@ function at(o, k) {
66
91
  if (!isObj(o))
67
92
  throw new TypeError(`${kind(o)} is not an object`);
68
93
  if (!Object.hasOwn(o, k))
69
- throw new TypeError(`missing ${JSON.stringify(k)}`);
94
+ throw new KeyError(repr(k));
70
95
  return o[k];
71
96
  }
72
97
  function get(o, k, missing = null) {
@@ -103,20 +128,36 @@ function text(v) {
103
128
  const truthy = (v) => Array.isArray(v) ? v.length > 0 : isObj(v) ? Object.keys(v).length > 0 : !!v;
104
129
  const str = (v) => v === null ? "None" : v === true ? "True" : v === false ? "False"
105
130
  : typeof v === "string" ? v : JSON.stringify(v);
106
- const repr = (v) => typeof v === "string" ? `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'` : str(v);
131
+ const repr = (v) => typeof v === "string" ? quoted(v) : str(v);
107
132
  const cut = (s, n) => [...s].slice(0, n).join("");
133
+ /** `xs` appended to `to` one by one: a spread call (`push(...xs)`, `Math.max(...xs)`) puts every element on the stack,
134
+ * which a bundle of 60k records overflows. */
135
+ const extend = (to, xs) => {
136
+ for (const x of xs)
137
+ to.push(x);
138
+ };
139
+ /** Python's order of strings, by code point (JavaScript's `<` and sort() compare UTF-16 code units, which put a
140
+ * character above U+FFFF before one in U+E000..U+FFFF). */
141
+ const byCodePoint = (a, b) => {
142
+ const unit = (u) => u >= 0xd800 && u < 0xe000 ? u + 0x2000 : u >= 0xe000 ? u - 0x800 : u;
143
+ for (let i = 0; i < Math.min(a.length, b.length); i++)
144
+ if (a.charCodeAt(i) !== b.charCodeAt(i))
145
+ return unit(a.charCodeAt(i)) - unit(b.charCodeAt(i));
146
+ return a.length - b.length;
147
+ };
148
+ const byKey = ([a], [b]) => byCodePoint(a, b);
108
149
  const count = (xs) => {
109
150
  const m = new Map();
110
151
  for (const x of xs)
111
152
  m.set(x, (m.get(x) ?? 0) + 1);
112
- return [...m].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
153
+ return [...m].sort(byKey);
113
154
  };
114
155
  const fmt = (ts) => {
115
156
  if (ts > 253402300799)
116
157
  throw new RangeError("year is out of range");
117
158
  return new Date(Number(ts) * 1000).toISOString().slice(0, 19) + "Z";
118
159
  };
119
- async function loadTrust(data) {
160
+ async function loadTrust(data, name) {
120
161
  const floats = new WeakMap();
121
162
  const t = strictParseBytes(data.subarray(0, MAX_LINE), floats);
122
163
  const int = (o, k, d) => !Object.hasOwn(o, k) ? d
@@ -126,7 +167,7 @@ async function loadTrust(data) {
126
167
  const opt = (o, k) => Object.hasOwn(o, k) ? o[k] : [];
127
168
  const r = isObj(t) ? get(t, "rekor", {}) : null;
128
169
  const ok = isObj(t) && Object.keys(t).every((k) => ["logs", "witnesses", "algs", "witnesses_required", "rekor",
129
- "monitors", "issuers"].includes(k))
170
+ "monitors", "issuers", "analyzers"].includes(k))
130
171
  && isObj(r) && (!Object.keys(r).length || exactly(r, ["trusted_root", "publishing_key", "class"])
131
172
  && isObj(r.trusted_root) && typeof r.publishing_key === "string" && CLASSES.includes(r.class))
132
173
  && strings(t.logs)
@@ -136,17 +177,34 @@ async function loadTrust(data) {
136
177
  && Array.isArray(opt(t, "monitors")) && opt(t, "monitors").every((m) => exactly(m, ["vkey", "class", "max_age_s"])
137
178
  && typeof m.vkey === "string" && CLASSES.includes(m.class) && int(m, "max_age_s", 0) > 0)
138
179
  && Array.isArray(opt(t, "issuers")) && opt(t, "issuers").every((i) => exactly(i, ["vkey", "issuance_log_vkey"])
139
- && Object.values(i).every((v) => typeof v === "string"));
180
+ && Object.values(i).every((v) => typeof v === "string"))
181
+ && Array.isArray(opt(t, "analyzers"))
182
+ && opt(t, "analyzers").every((a) => typeof a === "string" && /^[a-z0-9_]+:.+$/.test(a));
140
183
  if (!ok)
141
- throw new Error("not a v2 trust config (logs, witnesses, algs, witnesses_required, rekor, monitors, issuers)");
184
+ throw new ValueError(`${name}: not a v2 trust config (logs, witnesses, algs, witnesses_required, rekor, monitors, `
185
+ + "issuers, analyzers)");
142
186
  for (const k of [...t.logs, ...opt(t, "witnesses").map((w) => w.vkey), ...opt(t, "monitors").map((m) => m.vkey),
143
187
  ...opt(t, "issuers").flatMap((i) => Object.values(i))])
144
188
  await parseVkey(k);
145
189
  t.witnesses ??= [];
146
190
  t.witnesses_required ??= 0;
147
191
  t.issuers ??= [];
192
+ t.analyzers ??= [];
148
193
  return t;
149
194
  }
195
+ /** `s` as one shell argument, as the reference quotes it (shlex.quote; on Windows subprocess.list2cmdline). */
196
+ function quote(s) {
197
+ if (globalThis.process?.platform === "win32")
198
+ return /[ \t]/.test(s) || !s ? `"${s}"` : s;
199
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'"'"'`)}'`;
200
+ }
201
+ /** Ways to verify the bundle with a verifier of version `need` or later, as commands with the user's own paths. */
202
+ function upgrade(need, names) {
203
+ const [b, t] = [quote(names.bundle), quote(names.trust)];
204
+ return [`npx -y @cygnuxlabs/tracekit@latest verify ${b} --trust ${t}`,
205
+ `uvx --from "tracekit-ai>=${need}" tracekit verify ${b} --trust ${t}`,
206
+ `or open ${PAGE} and choose both files: the page verifies them in your browser and sends nothing`];
207
+ }
150
208
  function newer(need, have) {
151
209
  const a = need.split(".").map(Number), b = have.split(".").map(Number);
152
210
  for (let i = 0; i < Math.min(a.length, b.length); i++)
@@ -155,38 +213,55 @@ function newer(need, have) {
155
213
  return a.length > b.length;
156
214
  }
157
215
  const errorText = (e) => `${e?.constructor?.name ?? "Error"}: ${e?.message ?? e}`;
158
- /** Verify a v2 bundle (zip bytes) against the verifier's own pinned trust config (JSON bytes). Never throws. */
159
- export async function verify(bundle, trust) {
216
+ /** Without a trust config: the bundle against its own checkpoint, whose signature no pinned key checks. */
217
+ const UNANCHORED = "UNVERIFIABLE (no trust config: the records match their own checkpoint, which no pinned key checked)";
218
+ /**
219
+ * Verify a v2 bundle (zip bytes) against the verifier's own pinned trust config (JSON bytes). Never throws. `names`: the
220
+ * files' paths, for the commands a bundle that needs a newer verifier is told to run. `trust` null (the browser page,
221
+ * when no trust config is chosen): every check that needs no pinned key, and never VERIFIED: a changed record still
222
+ * FAILS, and an intact bundle is UNVERIFIABLE, since anyone can sign a bundle and its checkpoint with keys of their own.
223
+ */
224
+ export async function verify(bundle, trust, names = { bundle: "BUNDLE", trust: "TRUST.json" }) {
160
225
  const rep = new Report();
161
- let t, files, manifest;
162
- try {
163
- t = await loadTrust(trust);
164
- }
165
- catch (e) {
166
- rep.check("trust config", false, errorText(e));
167
- return { report: rep, code: EXIT_BAD };
226
+ let t, files, manifest, v1 = false;
227
+ if (trust === null) {
228
+ t = { logs: [], witnesses: [], algs: ["ed25519"], witnesses_required: 0, issuers: [], analyzers: [] };
229
+ rep.check("trust config", false, "none: the checkpoint's signature and its witnesses are not checked, so nothing ties "
230
+ + "these records to a signer you trust", [], true);
168
231
  }
232
+ else
233
+ try {
234
+ t = await loadTrust(trust, names.trust);
235
+ }
236
+ catch (e) {
237
+ rep.check("trust config", false, errorText(e));
238
+ return { report: rep, code: EXIT_BAD };
239
+ }
169
240
  const floats = new WeakMap();
170
241
  try {
171
242
  files = await readZip(bundle);
172
243
  const m = files.get("manifest.json");
173
244
  if (!m)
174
- throw new Error("no manifest.json");
245
+ throw new KeyError(repr("manifest.json"));
175
246
  files.delete("manifest.json");
176
247
  manifest = strictParseBytes(m, floats);
248
+ v1 = isObj(manifest) && manifest.format === "tracekit.bundle.v1";
177
249
  if (!isObj(manifest) || manifest.format !== FORMAT)
178
- throw new Error(`format is not ${FORMAT}`);
250
+ throw new Unusable(`format is not ${FORMAT}`);
179
251
  const need = manifest.verifier_min_version;
180
252
  if (!(typeof need === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(need)))
181
- throw new Error("verifier_min_version is not a version number");
253
+ throw new Unusable("verifier_min_version is not a version number");
182
254
  if (newer(need, VERSION)) {
183
255
  rep.integrity = `UNVERIFIABLE (needs tracekit >= ${need})`;
184
- rep.check("bundle readable", false, rep.integrity);
256
+ rep.check("bundle readable", false, rep.integrity, upgrade(need, names));
185
257
  return { report: rep, code: EXIT_BAD };
186
258
  }
187
259
  }
188
260
  catch (e) {
189
- rep.check("bundle readable", false, `cannot read bundle: ${errorText(e)}`);
261
+ // a format v1 bundle (the laptop signer's): the reference verifier checks it, against the signer.pub the sender pins
262
+ rep.check("bundle readable", false, `cannot read bundle: ${errorText(e)}`, v1 ? ["a format v1 bundle: verify it with "
263
+ + `the signer's public key (signer.pub, from the sender): uvx --from tracekit-ai tracekit verify ${quote(names.bundle)}`
264
+ + " --key signer.pub"] : []);
190
265
  return { report: rep, code: EXIT_BAD };
191
266
  }
192
267
  try {
@@ -197,6 +272,8 @@ export async function verify(bundle, trust) {
197
272
  }
198
273
  if (rep.failures.length)
199
274
  rep.integrity = "FAILED";
275
+ else if (trust === null)
276
+ rep.integrity = UNANCHORED;
200
277
  else if (rep.unsupported.length)
201
278
  rep.integrity = `UNVERIFIABLE (not checked by this verifier: ${rep.unsupported.join(", ")})`;
202
279
  return { report: rep, code: rep.failures.length ? EXIT_FAIL : rep.integrity.startsWith("UNVERIFIABLE") ? EXIT_BAD : EXIT_OK };
@@ -205,23 +282,27 @@ async function check(rep, manifest, files, trust, floats) {
205
282
  const file = (name) => {
206
283
  const f = files.get(name);
207
284
  if (!f)
208
- throw new TypeError(`no ${name}`);
285
+ throw new KeyError(repr(name));
209
286
  return f;
210
287
  };
211
288
  const json = (name) => strictParseBytes(file(name), floats);
289
+ /** A JSON lines file's texts, each line at least MIN_RECORD bytes: checked before any is parsed, so a bundle's lines
290
+ * can't outnumber MAX_TOTAL / MIN_RECORD, whatever it unzips to. */
212
291
  const jsonl = (name) => {
213
292
  const data = file(name);
214
293
  if (data.length && data.at(-1) !== 0x0a)
215
- throw new Error("a JSON lines file must end in a newline");
294
+ throw new ValueError("a JSON lines file must end in a newline");
216
295
  const lines = [];
217
296
  for (let i = 0, j; (j = data.indexOf(0x0a, i)) >= 0; i = j + 1) {
218
297
  // checked before any is parsed: a run of tiny lines costs no memory
219
298
  if (lines.length === MAX_LINES)
220
- throw new Error("more lines than the bundle limits");
299
+ throw new ValueError("more lines than the bundle limits");
221
300
  lines.push(data.subarray(i, j));
222
301
  }
223
302
  if (lines.some((l) => l.length > MAX_LINE))
224
- throw new Error("line longer than the bundle limits");
303
+ throw new ValueError("line longer than the bundle limits");
304
+ if (lines.some((l) => l.length < MIN_RECORD))
305
+ throw new ValueError("line shorter than a signed record");
225
306
  return lines.map((l) => strictParseBytes(l, floats));
226
307
  };
227
308
  const listed = manifest.files, hashes = new Map();
@@ -234,11 +315,12 @@ async function check(rep, manifest, files, trust, floats) {
234
315
  const proofs = json("proofs/records.json");
235
316
  const name = at(proofs, "checkpoint");
236
317
  if (!(typeof name === "string" && name.startsWith("checkpoints/") && name.endsWith(".note")))
237
- throw new Error("proofs name no checkpoint note");
318
+ throw new ValueError("proofs name no checkpoint note");
238
319
  const witnesses = new Map(trust.witnesses.map((w) => [w.vkey, w.class]));
320
+ const anchored = trust.logs.length > 0; // a trust config pins at least one log key
239
321
  let note;
240
322
  try {
241
- note = await openNote(file(name), trust.logs, [...witnesses.keys()]);
323
+ note = await openNote(file(name), trust.logs, [...witnesses.keys()], anchored);
242
324
  }
243
325
  catch (e) {
244
326
  if (!(e instanceof NoteError))
@@ -247,16 +329,18 @@ async function check(rep, manifest, files, trust, floats) {
247
329
  return;
248
330
  }
249
331
  const { origin, size, root, cosigs } = note;
250
- rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size}, signed by its pinned log key`);
332
+ rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size}, `
333
+ + (anchored ? "signed by its pinned log key" : "its signature not checked (no trust config)"));
251
334
  if (note.unchecked)
252
335
  rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
253
- rep.check("witness quorum", cosigs.length >= trust.witnesses_required, `${cosigs.length} pinned cosignature(s), ${trust.witnesses_required} required`);
336
+ if (anchored)
337
+ rep.check("witness quorum", cosigs.length >= trust.witnesses_required, `${cosigs.length} pinned cosignature(s), ${trust.witnesses_required} required`);
254
338
  if (truthy(trust.rekor) && files.has(`rekor/${size}.json`))
255
339
  rep.unsupport("rekor anchor");
256
340
  const included = async (r) => {
257
341
  const seq = at(at(r, "event"), "seq");
258
342
  const path = get(at(proofs, "inclusion"), str(seq));
259
- return path !== null && verifyInclusion(num(seq), size, await leafHash(unhex(text(at(r, "hash")).slice(7))), list(path).map(b64decode), root);
343
+ return Array.isArray(path) && verifyInclusion(num(seq), size, await leafHash(unhex(text(at(r, "hash")).slice(7))), path.map(b64decode), root);
260
344
  };
261
345
  // keys: declared and retired by records the checkpointed tree includes, so the log key vouches for them
262
346
  const keyRecords = jsonl("keys/records.jsonl"), keyProblems = [], timeline = new Map();
@@ -317,18 +401,26 @@ async function check(rep, manifest, files, trust, floats) {
317
401
  for (const [kid, k] of declared)
318
402
  timeline.set(kid, k);
319
403
  }
320
- // the runs: one, or with a run-set any number, or one with its tracekit why findings and replay runs
321
- // lean: findings (verify/v2.py's _findings) are not checked here: a bundle with them is UNVERIFIABLE; port it when
322
- // TS users verify findings bundles
323
- const names = [...files.keys()].filter((n) => n.startsWith("runs/")).sort();
404
+ // the runs: one, or with a run-set any number, or one with its findings runs (and replay runs)
405
+ // lean: tracekit why findings (verify/v2.py's _findings) are not checked here: a bundle with a why.finding or a replay
406
+ // run is UNVERIFIABLE; port it when TS users verify tracekit why bundles
407
+ const names = runFiles(files.keys());
324
408
  const runSet = files.has("registry/run-set.json");
325
409
  const runs = new Map(), problems = [], chain = [], outside = [];
326
- for (const name of names) {
327
- const records = jsonl(name);
410
+ const layout = [], seen = new Set();
411
+ for (const [name, parts] of names) {
412
+ const pieces = parts.map(jsonl), records = pieces.flat(), whole = parts.length === 1 && parts[0] === name;
328
413
  runs.set(name, records);
414
+ extend(layout, parted(name, parts, pieces));
329
415
  if (!records.length)
330
- throw new Error("a run has no records");
331
- const head = at(records[0], "event");
416
+ throw new ValueError("a run has no records");
417
+ const head = at(records[0], "event"), tenant = get(head, "tenant"), runId = get(head, "run_id");
418
+ if (typeof tenant === "string" && typeof runId === "string") { // a second file of one run would count it twice
419
+ const key = JSON.stringify([tenant, runId]);
420
+ if (seen.has(key))
421
+ chain.push(`run ${repr(cut(runId, 200))} of tenant ${repr(cut(tenant, 64))} again in ${name}`);
422
+ seen.add(key);
423
+ }
332
424
  for (const [i, r] of records.entries()) {
333
425
  await checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), problems);
334
426
  const e = r.event, prev = i ? records[i - 1] : null;
@@ -339,19 +431,19 @@ async function check(rep, manifest, files, trust, floats) {
339
431
  + "continue the run");
340
432
  }
341
433
  if (!(await included(records[0]) && await included(records.at(-1))))
342
- outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}`);
434
+ outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}` + (!whole && await included(records[0])
435
+ ? `: the last record of ${parts.at(-1)} is not in the checkpointed tree, so a later part may be missing` : ""));
343
436
  }
344
- const why = [...runs.values()].some((rs) => rs.some((r) => get(r.event, "type") === "why.finding")
345
- || get(rs[0].event, "type") === "run.registered" && ["analyzes", "replay_of"].some((k) => has(get(rs[0].event, "data"), k)));
437
+ const why = [...runs.values()].some((rs) => rs.some((r) => get(r.event, "type") === "why.finding") || about(rs)[1] !== null);
346
438
  if (why)
347
439
  rep.unsupport("tracekit why findings");
348
- else if (names.length !== 1 && !runSet)
349
- throw new Error("a v2 bundle holds exactly one run, or a run-set");
440
+ else if (runs.size !== 1 && !runSet && !aboutOne(runs))
441
+ throw new ValueError("a v2 bundle holds exactly one run, one run with its findings and replay runs, or a run-set");
350
442
  let retired = [], provenTo = -1, regCosigs = [];
351
443
  if (runSet) {
352
444
  [retired, provenTo, regCosigs] = await checkRunSet(rep, json("registry/run-set.json"), jsonl, file, trust, witnesses, origin, size, runs, included, (r, p) => checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), p), floats);
353
445
  const keyed = new Set(keyRecords.map((r) => `${at(at(r, "event"), "seq")} ${at(r, "hash")}`));
354
- keyProblems.push(...retired.filter(([seq, h]) => !keyed.has(`${seq} ${h}`))
446
+ extend(keyProblems, retired.filter(([seq, h]) => !keyed.has(`${seq} ${h}`))
355
447
  .map(([seq]) => `seq ${seq}: key.retire withheld (the registry log has it)`));
356
448
  }
357
449
  rep.check("keys", timeline.size > 0 && !keyProblems.length, `${timeline.size} record key(s) from ${keyRecords.length} key `
@@ -359,8 +451,8 @@ async function check(rep, manifest, files, trust, floats) {
359
451
  const every = [...runs.values()].flat();
360
452
  const seqs = [...keyRecords, ...every].map((r) => num(at(at(r, "event"), "seq")));
361
453
  if (!seqs.length)
362
- throw new Error("the bundle holds no key or run records");
363
- const relied = Math.max(...seqs);
454
+ throw new ValueError("the bundle holds no key or run records");
455
+ const relied = seqs.reduce((a, b) => b > a ? b : a);
364
456
  const rotated = timeline.size > 1 || keyRecords.some((r) => get(r.event, "type") === "key.retire");
365
457
  if (provenTo < relied && rotated) // one key never retired: the assurance line notes it, no warning
366
458
  rep.check("keys", false, "retirements not proven complete", [`no run-set from registry size 0 reaches seq ${relied}, so a withheld key.retire would not show`], true);
@@ -368,6 +460,10 @@ async function check(rep, manifest, files, trust, floats) {
368
460
  if (bridge && truthy(bridge.data.bridge))
369
461
  rep.notes.push(`this log continues the v1 ledger of key ${str(at(bridge.data.bridge, "v1_kid"))}; pass the v1 ledger and `
370
462
  + "its signer.pub to the reference verifier to check the format bridge");
463
+ const inParts = [...names].filter(([n, ps]) => !(ps.length === 1 && ps[0] === n)).map(([, ps]) => ps.length);
464
+ if (inParts.length)
465
+ rep.check("run parts", !layout.length, `${inParts.length} run(s) in ${inParts.reduce((a, b) => a + b, 0)} parts, `
466
+ + "each part continuing the one before it", layout);
371
467
  rep.check("signatures", !problems.length, `${every.length} record(s), keys valid at their position`, problems);
372
468
  const only = runs.size === 1 ? [...runs.values()][0][0].event : null;
373
469
  rep.check("run chain", !chain.length, only ? `run ${repr(cut(str(get(only, "run_id")), 200))} of tenant `
@@ -440,14 +536,16 @@ async function check(rep, manifest, files, trust, floats) {
440
536
  + MATRIX.map((k) => {
441
537
  const cs = matrices.map((m) => at(m, k));
442
538
  const status = cs.map((c) => str(at(c, "status"))).reduce((a, b) => COVERED.indexOf(b) < COVERED.indexOf(a) ? b : a);
443
- const layers = [...new Set(cs.flatMap((c) => list(at(c, "layers")).map(str)))].sort()
539
+ const layers = [...new Set(cs.flatMap((c) => list(at(c, "layers")).map(str)))].sort(byCodePoint)
444
540
  .concat(cs.some((c) => get(c, "asserted") === true) ? ["asserted"] : []);
445
541
  return `${k.replace("_", " ")} ` + (status === "none" ? "not covered"
446
542
  : status + (layers.length ? ` (${layers.join(", ")})` : ""));
447
543
  }).join(", "));
448
544
  const lost = gaps("observer_lost");
449
545
  if (lost.length)
450
- rep.check("observers", false, `${lost.length} egress observer(s) went silent while their run was open`, lost.slice(0, 20), true);
546
+ rep.check("observers", false, `${lost.length} observer gap(s): an observer went silent, restarted or lost events while its run was open`, lost.slice(0, 20), true);
547
+ checkReviews(rep, runs, trust.analyzers);
548
+ checkConstraints(rep, runs);
451
549
  checkIncidents(rep, runs, policies, floats);
452
550
  const accessed = typed("content.accessed").length, erased = typed("content.erased").length;
453
551
  if (accessed || erased) // signer records of the content vault (docs/privacy.md): who read or erased retained content
@@ -476,7 +574,7 @@ async function check(rep, manifest, files, trust, floats) {
476
574
  tiers.set(k, t);
477
575
  const split = [...paths].filter(([, p]) => p.size > 1).map(([k, p]) => {
478
576
  const [run, tcid] = JSON.parse(k);
479
- return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort().join(", ")}`;
577
+ return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort(byCodePoint).join(", ")}`;
480
578
  });
481
579
  if (tiers.size) {
482
580
  const n = (t) => [...tiers.values()].filter((x) => x === t).length;
@@ -506,17 +604,107 @@ async function check(rep, manifest, files, trust, floats) {
506
604
  if (!isObj(modes))
507
605
  throw new TypeError("fail_modes is not an object");
508
606
  return Object.entries(modes).filter(([, m]) => m === "open").map(([c]) => cut(c, 64));
509
- }))].sort();
607
+ }))].sort(byCodePoint);
510
608
  rep.check("fail-open classes", !failOpen.length, failOpen.join(", ") || "none", failOpen.length ? ["calls the agent classed as one of these may have run unrecorded while the signer was down"] : [], true);
511
609
  rep.check("key assurance", true, "asserted: the log declares its record keys; none is attested");
512
610
  const algs = new Set([...every, ...keyRecords].map((r) => text(at(r, "alg"))));
513
- rep.assurance = await assurance(origin, cosigs, witnesses, trust, algs, selfApproved, regCosigs)
514
- + (provenTo < relied ? "; key retirements not proven complete" : "");
611
+ rep.assurance = !anchored ? "none: no trust config pins a log key or witness"
612
+ : await assurance(origin, cosigs, witnesses, trust, algs, selfApproved, regCosigs)
613
+ + (provenTo < relied ? "; key retirements not proven complete" : "");
515
614
  approvals(rep, runs, policies, floats);
516
615
  const glass = typed("approval").filter((e) => get(at(e, "data"), "break_glass") === true);
517
616
  if (glass.length)
518
617
  rep.check("break-glass approvals", false, `${glass.length} approval(s) answered under the break-glass role`, glass.map((e) => `seq ${e.seq}: ${str(at(e.data, "decision"))} ${str(get(e.data, "approval_id"))} by `
519
618
  + `${cut(text(at(e.data, "approver")), 256)}: ${repr(cut(str(get(e.data, "reason")), 200))}`).slice(0, 20), true);
619
+ const pointed = runSet ? jsonl("registry/records.jsonl") : []; // the records a run-set's leaves name
620
+ checkExt(rep, [...new Map([...keyRecords, ...pointed, ...every].map((r) => [at(at(r, "event"), "seq"), r.event]))
621
+ .values()], get(manifest, "verifier_version"));
622
+ }
623
+ /**
624
+ * ext.amended: the JSON pointers of the fields an LLM gateway changed in a model request before sending it, to show the
625
+ * reasoning the request has the model do. The signer writes it on a gateway's request record only, whose content digest
626
+ * is of the request as sent; anywhere else, or not a list of pointers, it is a warning.
627
+ */
628
+ function checkAmended(rep, events) {
629
+ const fields = (e) => {
630
+ const v = isObj(get(e, "ext")) ? get(e.ext, "amended") : null;
631
+ const data = get(e, "data", {});
632
+ return get(e, "type") === "model.exchange" && get(e, "source") === "gateway" && isObj(data)
633
+ && get(data, "phase") === "request" && Array.isArray(v) && v.length > 0 && v.every((p) => typeof p === "string"
634
+ && [...p].length <= 256 && /^(\/([^~/]|~[01])*)+$/u.test(p)) ? v : null;
635
+ };
636
+ const good = events.filter((e) => fields(e)), bad = events.filter((e) => !fields(e));
637
+ const n = new Map();
638
+ for (const e of good)
639
+ for (const p of fields(e))
640
+ n.set(p, (n.get(p) ?? 0) + 1);
641
+ const shown = [...n].sort(byKey).slice(0, 8).map(([p, c]) => `${p}: ${c}`);
642
+ rep.check("gateway amendments", bad.length === 0, `gateway amended ${good.length} request(s) to show reasoning`
643
+ + (shown.length ? ` (${shown.join(", ")})` : "") + "; each record commits to the request as sent", bad.map((e) => `seq ${str(get(e, "seq"))}: ext.amended is not a list of JSON pointers on a gateway's model request`)
644
+ .slice(0, 20), true);
645
+ }
646
+ /**
647
+ * ext.openshell.policy: a change of the sandbox's policy or configuration that OpenShell reported (its name for the
648
+ * change, its hash of the policy, who made it; its message committed), an `ext` record the signer writes from the
649
+ * OpenShell middleware's OCSF import, that observer's assertion; anything else carrying the name is a warning.
650
+ */
651
+ function checkOpenshellPolicy(rep, events) {
652
+ const change = (e) => {
653
+ const d = get(e, "data");
654
+ return get(e, "type") === "ext" && get(e, "source") === "signer" && isObj(d)
655
+ && get(d, "name") === "openshell.policy" && typeof get(d, "change") === "string"
656
+ && /^[a-z0-9_.-]{1,64}$/u.test(d.change) && typeof get(d, "observer") === "string" && d.observer !== ""
657
+ ? d.change : null;
658
+ };
659
+ const good = events.filter((e) => change(e)), bad = events.filter((e) => !change(e));
660
+ const n = new Map();
661
+ for (const e of good)
662
+ n.set(change(e), (n.get(change(e)) ?? 0) + 1);
663
+ const shown = [...n].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).slice(0, 8).map(([c, k]) => `${c}: ${k}`);
664
+ rep.check("openshell policy", bad.length === 0, "OpenShell changed the sandbox policy or configuration "
665
+ + `${good.length} time(s)` + (shown.length ? ` (${shown.join(", ")})` : "") + ", as OpenShell reported it", bad.map((e) => `seq ${str(get(e, "seq"))}: ext.openshell.policy is not an OpenShell policy change the signer `
666
+ + "recorded").slice(0, 20), true);
667
+ }
668
+ /**
669
+ * The ext names this verifier interprets (the VERSIONS rows' "ext" in tracekit/bundle_v2.py): name -> its check, whose
670
+ * lines are its own: every other line of the report reads the same without them (docs/format-v2.md §9).
671
+ */
672
+ const EXT_CHECKS = new Map([["amended", checkAmended],
673
+ ["openshell.policy", checkOpenshellPolicy]]);
674
+ const MAX_UNREAD = 20; // `not interpreted` lines, one per ext name; the rest on one line
675
+ /** The ext names an event carries: its `ext` field's keys, and an `ext` record's name. */
676
+ const extNames = (e) => [...Object.keys(isObj(get(e, "ext")) ? e.ext : {}),
677
+ ...get(e, "type") === "ext" ? [at(at(e, "data"), "name")] : []];
678
+ /**
679
+ * A line per ext name the records carry: its EXT_CHECKS check, or a `not interpreted` warning. The records were checked
680
+ * like every other (signature, chain, inclusion); `full`: the manifest's advisory verifier_version.
681
+ */
682
+ function checkExt(rep, events, full) {
683
+ const carry = new Map(); // name -> the events carrying it
684
+ for (const e of events)
685
+ for (const n of new Set(extNames(e).map((x) => cut(str(x), 64)))) {
686
+ if (!carry.has(n))
687
+ carry.set(n, []);
688
+ carry.get(n).push(e);
689
+ }
690
+ const later = typeof full === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(full) && newer(full, VERSION)
691
+ ? `; tracekit >= ${full} checks it` : "";
692
+ let shown = 0, more = 0;
693
+ for (const [n, es] of [...carry].sort(byKey)) {
694
+ const interpret = EXT_CHECKS.get(n);
695
+ if (interpret)
696
+ interpret(rep, es);
697
+ else if (shown < MAX_UNREAD) {
698
+ shown++;
699
+ rep.check("not interpreted", false, `${es.length} record(s) carry ext.${n}: their signatures, chain and `
700
+ + `inclusion are checked above; what ext.${n} means is not checked${later}`, [], true);
701
+ }
702
+ else
703
+ more++; // a line each would bury the report
704
+ }
705
+ if (more)
706
+ rep.check("not interpreted", false, `${more} more ext name(s), on records whose signatures, chain and inclusion `
707
+ + `are checked above; what they mean is not checked${later}`, [], true);
520
708
  }
521
709
  /** Approvals by people and by agents, each on a line of its own. An agent's approval fails the line unless its run
522
710
  * holds the approval.request and policy.decision it answered, the signer called the request agent-eligible
@@ -603,6 +791,142 @@ const CONTAINMENT = ["incident.opened", "incident.updated", "run.held", "run.qua
603
791
  const FIRES = ["policy.decision", "approval.consumed", "capture.gap", "trace.tamper", "refusal.summary",
604
792
  "reconcile.unexplained_effect"];
605
793
  const RECOMPUTED = ["sequence", "limit", "denied_calls", "provenance", "executed_against_policy", "class_mismatch"];
794
+ /** [analyzes, replay_of, investigation] of a run's run.registered (nulls when it is not one). */
795
+ function about(rs) {
796
+ const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : null;
797
+ return ["analyzes", "replay_of", "investigation"].map((k) => truthy(d) ? get(d, k) : null);
798
+ }
799
+ /** Whether every run but one of the same tenant is a findings or replay run of that one. */
800
+ function aboutOne(runs) {
801
+ const all = [...runs.values()], main = all.filter((rs) => about(rs)[0] === null && about(rs)[1] === null);
802
+ if (main.length !== 1)
803
+ return false;
804
+ const head = main[0][0].event;
805
+ return all.every((rs) => rs === main[0] || same(get(rs[0].event, "tenant"), get(head, "tenant"))
806
+ && about(rs).slice(0, 2).some((x) => same(x, get(head, "run_id"))));
807
+ }
808
+ /** ["scheme:subject", signer_isolation] of a run's run.registered (nulls when it is not one). */
809
+ function author(rs) {
810
+ const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : {};
811
+ const i = get(d, "identity");
812
+ return [truthy(i) ? `${str(at(i, "scheme"))}:${str(at(i, "subject"))}` : null, get(d, "signer_isolation")];
813
+ }
814
+ /**
815
+ * Signed findings (format 1.4, docs/format-v2.md §11): each `review` with `cites`, in a findings run, cites records of
816
+ * the analysed run that are in the bundle with those hashes (else `reviews` fails), and is labelled by its author (the
817
+ * findings run's registered identity), positive-only: independent only when the trust config's `analyzers` pins it;
818
+ * self-asserted (a warning) when it is the analysed run's identity or either run's signer_isolation is same-user; else
819
+ * unpinned. Without its analysed run: not counted, author not checked (`reviews counted`). No cites: imported.
820
+ */
821
+ /** What is wrong with a signed finding's verdict `v` (VERDICT; its asserted model, families and window), or null. */
822
+ function verdictProblem(v) {
823
+ if (!isObj(v) || typeof v.rule !== "string" || !/^TK-[A-Z][0-9]{3}$/.test(v.rule)
824
+ || !["kind", "severity"].every((k) => VERDICT[k].includes(get(v, k))))
825
+ return "its verdict has no kind, rule and severity of a signed finding";
826
+ const a = get(v, "asserted"), f = get(v, "families"), w = get(v, "window");
827
+ const name = (x) => typeof x === "string" && /^[A-Za-z0-9._:/@+-]{1,128}$/.test(x);
828
+ const seconds = (x) => typeof x === "number" && x >= 0;
829
+ const checks = [
830
+ ["asserted", isObj(a) && name(get(a, "model")) && name(get(a, "version"))
831
+ && typeof get(a, "prompt_hash") === "string" && /^sha256:[0-9a-f]{64}$/.test(str(get(a, "prompt_hash")))],
832
+ ["families", isObj(f) && (get(f, "reviewer") === null || FAMILIES.includes(str(get(f, "reviewer"))))
833
+ && Array.isArray(get(f, "agent")) && list(get(f, "agent")).every((x) => typeof x === "string" && FAMILIES.includes(x))],
834
+ ["window", isObj(w) && seconds(get(w, "grace_s")) && seconds(get(w, "skew_s"))],
835
+ ];
836
+ const bad = [...Object.keys(VERDICT).filter((k) => has(v, k) && !VERDICT[k].includes(v[k])),
837
+ ...checks.filter(([k, ok]) => has(v, k) && !ok).map(([k]) => k)];
838
+ return bad.length ? `its verdict's ${bad.join(", ")} is not what a signed finding holds` : null;
839
+ }
840
+ function checkReviews(rep, runs, pinned) {
841
+ const byId = new Map([...runs.values()].map((rs) => [get(rs[0].event, "run_id"), rs]));
842
+ const labels = [], problems = [], selfs = [], uncounted = [];
843
+ const hashesOf = new Map(); // analysed run id -> {run_seq: record hash}, built once
844
+ let total = 0;
845
+ for (const rs of runs.values()) {
846
+ const reviews = rs.map((r) => r.event).filter((e) => get(e, "type") === "review");
847
+ const signed = reviews.filter((e) => has(at(e, "data"), "cites"));
848
+ total += reviews.length;
849
+ extend(labels, Array(reviews.length - signed.length).fill("imported"));
850
+ if (!signed.length)
851
+ continue;
852
+ for (const e of signed) {
853
+ const why = verdictProblem(get(at(e, "data"), "verdict"));
854
+ if (why)
855
+ problems.push(`seq ${e.seq}: ${why}`);
856
+ }
857
+ const rid = cut(str(get(rs[0].event, "run_id")), 128), analyzes = about(rs)[0];
858
+ if (analyzes === null) {
859
+ extend(problems, signed.map((e) => `seq ${e.seq}: a signed finding outside a findings run`));
860
+ continue;
861
+ }
862
+ const target = byId.get(analyzes);
863
+ if (!target) {
864
+ extend(labels, Array(signed.length).fill("author not checked"));
865
+ uncounted.push(`findings run ${rid}: run ${cut(str(analyzes), 128)} is not in the bundle`);
866
+ continue;
867
+ }
868
+ if (!hashesOf.has(analyzes))
869
+ hashesOf.set(analyzes, new Map(target.map((r) => [get(r.event, "run_seq"), r.hash])));
870
+ const hashes = hashesOf.get(analyzes);
871
+ for (const e of signed)
872
+ for (const c of list(at(e.data, "cites")))
873
+ if (hashes.get(at(c, "run_seq")) !== at(c, "hash"))
874
+ problems.push(`seq ${e.seq}: cites run_seq ${str(c.run_seq)} of run ${cut(str(analyzes), 128)}, `
875
+ + (hashes.has(c.run_seq) ? "whose hash differs" : "which it does not have"));
876
+ const [mine, isolation] = author(rs), [theirs, theirIsolation] = author(target);
877
+ let label;
878
+ if (mine === theirs || [isolation, theirIsolation].includes("same-user")) {
879
+ label = "self-asserted";
880
+ selfs.push(`findings run ${rid}: ` + (mine === theirs
881
+ ? `its author ${cut(str(mine), 256)} is the analysed run's identity`
882
+ : "the signer runs as the same user as a client (signer_isolation same-user)"));
883
+ }
884
+ else
885
+ label = pinned.includes(mine) ? "independent" : `unpinned author ${cut(str(mine), 256)}`;
886
+ extend(labels, Array(signed.length).fill(label));
887
+ }
888
+ if (total) {
889
+ rep.check("reviews", !problems.length && !selfs.length, `${total} review(s): `
890
+ + count(labels).map(([k, n]) => `${n} ${k}`).join(", "), (problems.length ? problems : selfs).slice(0, 20), !problems.length);
891
+ const verdicts = [...runs.values()].flatMap((rs) => rs.map((r) => r.event)) // signed findings only
892
+ .filter((e) => get(e, "type") === "review" && has(at(e, "data"), "cites")).map((e) => get(e.data, "verdict"));
893
+ const sev = count(verdicts.filter((v) => isObj(v) && get(v, "kind") === "finding")
894
+ .map((v) => cut(str(get(v, "severity")), 16)));
895
+ const n = sev.reduce((a, [, k]) => a + k, 0), cov = verdicts.filter((v) => isObj(v) && get(v, "kind") === "coverage").length;
896
+ rep.findings = `${n} finding(s)` + (n ? " (" + SEVERITIES.flatMap((s) => sev.filter(([k]) => k === s))
897
+ .map(([k, c]) => `${c} ${k}`).join(", ") + ")" : "") + (cov ? ` and ${cov} coverage review(s)` : "") + "; "
898
+ + count(labels).map(([k, c]) => `${c} ${k}`).join(", ") + " (an opinion of the run, not its integrity)";
899
+ }
900
+ if (uncounted.length)
901
+ rep.check("reviews counted", false, `${labels.filter((l) => l === "author not checked").length} signed finding(s) not `
902
+ + "counted: export the analysed run with `tracekit export --v2 --run <run> --with-findings` to check their citations", uncounted.slice(0, 20), true);
903
+ }
904
+ /** The `constraints` line, as verify/v2.py's _constraints: live constraints added, and the calls they held, each an
905
+ * ask after one in its run. */
906
+ function checkConstraints(rep, runs) {
907
+ let added = 0, held = 0;
908
+ const problems = [];
909
+ for (const rs of runs.values()) {
910
+ let seen = false;
911
+ for (const r of rs) {
912
+ const e = r.event;
913
+ if (get(e, "type") === "constraint.added") {
914
+ added++;
915
+ seen = true;
916
+ }
917
+ else if (get(e, "type") === "policy.decision" && Array.isArray(get(at(e, "data"), "rule_ids"))
918
+ && get(e.data, "rule_ids").includes("TK-CONSTRAINT")) {
919
+ held++;
920
+ const decision = get(e.data, "decision");
921
+ if (!seen || decision !== "ask")
922
+ problems.push(`seq ${e.seq}: a ${cut(str(decision), 16)} held by TK-CONSTRAINT `
923
+ + (!seen ? "before any constraint.added in its run" : "is no ask"));
924
+ }
925
+ }
926
+ }
927
+ if (added || held)
928
+ rep.check("constraints", !problems.length, `${added} live constraint(s) added; ${held} call(s) held by them`, problems.slice(0, 20));
929
+ }
606
930
  /** The `incidents` line, as verify/v2.py's _incidents: cited records, incidents recomputed from their run under the
607
931
  * bundled compiled policy (`policies`, by policy_hash), responses honoured. */
608
932
  function checkIncidents(rep, runs, policies, floats) {
@@ -671,7 +995,7 @@ function checkIncidents(rep, runs, policies, floats) {
671
995
  }
672
996
  }
673
997
  }
674
- problems.push(...honoured(events, pointers));
998
+ extend(problems, honoured(events, pointers));
675
999
  }
676
1000
  const n = opened.size + [...pointers].filter((p) => !opened.has(p)).length;
677
1001
  rep.check("incidents", !problems.length, `${n} incident(s): ${checked} checked against the bundle, ${n - checked} `
@@ -679,7 +1003,7 @@ function checkIncidents(rep, runs, policies, floats) {
679
1003
  if (unbundled.size)
680
1004
  rep.check("incident policies", false, `${unbundled.size} cited policy(ies) not in the bundle: their incidents are `
681
1005
  + "signer-asserted, not recomputed (export the run with the signer's config, which bundles the policies its "
682
- + "records cite)", [...unbundled].sort().slice(0, 20), true);
1006
+ + "records cite)", [...unbundled].sort(byCodePoint).slice(0, 20), true);
683
1007
  }
684
1008
  /** The problems of one run's records with the holds and quarantines they record (verify/v2.py's _honoured). */
685
1009
  function honoured(events, pointers) {
@@ -692,7 +1016,7 @@ function honoured(events, pointers) {
692
1016
  for (const e of events) {
693
1017
  const t = e.type, d = e.data, where = `seq ${e.seq}`, on = quarantined || held.join(" and ");
694
1018
  if (t === "policy.decision" && due.size && !quarantined) { // an enforced response is written before the next call
695
- problems.push(...unfollowed());
1019
+ extend(problems, unfollowed());
696
1020
  due.clear();
697
1021
  }
698
1022
  if (t === "incident.opened" && d.scope.kind === "run" && d.scope.id === str(e.run_id).slice(0, 256)
@@ -736,7 +1060,7 @@ function honoured(events, pointers) {
736
1060
  + "approved by a person for it");
737
1061
  }
738
1062
  if (!quarantined)
739
- problems.push(...unfollowed());
1063
+ extend(problems, unfollowed());
740
1064
  return problems;
741
1065
  }
742
1066
  /** §4: null when `r` is a v2 record signed by one of `keys` (SPKI DER) with an allowed algorithm that is the key's own,
@@ -782,7 +1106,10 @@ export async function verifyRecord(r, keys, algs) {
782
1106
  async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size, runs, included, checkRecord, floats) {
783
1107
  const problems = [], tsalt = b64decode(at(rs, "tenant_salt"));
784
1108
  const lo = at(rs, "from"), hi = at(rs, "to"), regOrigin = await registryOrigin(origin, tsalt);
785
- const span = `registry ${str(lo)}..${str(hi)}`;
1109
+ // a bound as the reference prints it: 2.0 when its token had a fraction or exponent
1110
+ // lean: integral floats below 1e16 only; Python's repr of others (1e+16, 1e-05) differs from JavaScript's
1111
+ const bound = (k, v) => floats.get(rs)?.has(k) && Number.isInteger(v) && Math.abs(v) < 1e16 ? `${v}.0` : str(v);
1112
+ const span = `registry ${bound("from", lo)}..${bound("to", hi)}`;
786
1113
  if (![lo, hi].every((n) => Number.isSafeInteger(n)) || floats.get(rs)?.has("from") || floats.get(rs)?.has("to")) {
787
1114
  rep.check("run-set", false, `INCOMPLETE, ${span}`, ["the registry range is not two integers"]);
788
1115
  return [[], -1, []];
@@ -798,7 +1125,7 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
798
1125
  for (const n of [...new Set([num(lo), num(hi)])].filter((n) => n !== 0).sort((a, b) => a - b)) {
799
1126
  let got;
800
1127
  try {
801
- const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()]);
1128
+ const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()], trust.logs.length > 0);
802
1129
  got = note.size;
803
1130
  roots.set(n, note.root);
804
1131
  regCosigs.push(note.cosigs);
@@ -877,7 +1204,7 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
877
1204
  problems.push(`${untargeted} runs in the bundle, but only the selected run may be in no leaf of the range`);
878
1205
  if (truthy(closed) && size > num(at(at(closed, "data"), "final_seq")) + 1)
879
1206
  problems.push(`records after log.closed at seq ${str(at(closed, "seq"))}`);
880
- const counted = [...gaps].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, n]) => `${k} ${n}`).join(", ");
1207
+ const counted = [...gaps].sort(byKey).map(([k, n]) => `${k} ${n}`).join(", ");
881
1208
  const named = tenant === null ? "" : ` of tenant ${repr(cut(tenant, 64))}`;
882
1209
  const open = [...registered.keys()].filter((k) => !finals.has(k)).length;
883
1210
  rep.check("run-set", !problems.length, !problems.length ? `COMPLETE, ${span}${named} (${registered.size} runs registered, `
@@ -888,6 +1215,43 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
888
1215
  : `records after tree size ${size} are unproven (no log.closed in the range)`, [], true);
889
1216
  return [retired, lo === 0 ? top : -1, regCosigs];
890
1217
  }
1218
+ /** A run's part: the run's file name, the part's number (tracekit/bundle_v2.py PART). */
1219
+ const PART = /^(runs\/.+)\.part-([0-9]+)\.jsonl$/;
1220
+ /** {run file: its entries in order} of a bundle's `names`: the run's file, runs/<id>.jsonl, or its parts,
1221
+ * runs/<id>.part-0001.jsonl, ..., whose records read in that order are the run's (format-v2.md §9). */
1222
+ export function runFiles(names) {
1223
+ const out = new Map();
1224
+ for (const n of [...names].filter((x) => x.startsWith("runs/")).sort()) {
1225
+ const m = PART.exec(n), run = m ? `${m[1]}.jsonl` : n;
1226
+ if (!out.has(run))
1227
+ out.set(run, []);
1228
+ out.get(run).push(n);
1229
+ }
1230
+ return out;
1231
+ }
1232
+ /** What is wrong with the parts of `run` (runFiles): `names`, its files in order, and `pieces`, their records. A run in
1233
+ * one file has none; one in parts has part-0001 to part-n and no other file, each holding records, each one's first
1234
+ * record the successor of the last of the part before it. */
1235
+ function parted(run, names, pieces) {
1236
+ if (names.length === 1 && names[0] === run)
1237
+ return [];
1238
+ const stem = run.slice(0, -".jsonl".length), digits = names.filter((n) => n !== run).map((n) => [n, PART.exec(n)[2]]);
1239
+ const numbered = new Set(digits.filter(([, d]) => d.length === 4 && d !== "0000").map(([, d]) => Number(d)));
1240
+ const gaps = [];
1241
+ for (let i = 1; i < Math.max(0, ...numbered); i++)
1242
+ if (!numbered.has(i))
1243
+ gaps.push(i);
1244
+ const held = names.map((n, i) => [n, pieces[i]]).filter(([, p]) => p.length);
1245
+ return [
1246
+ ...names.includes(run) ? [`${run} and ${names[1]}: one run in one file and in parts`] : [],
1247
+ ...digits.filter(([, d]) => d.length !== 4 || d === "0000").map(([n]) => `${n}: not a part's name (part-0001, part-0002, ...)`),
1248
+ ...gaps.length ? [`${stem}.part-${String(gaps[0]).padStart(4, "0")}.jsonl is missing`
1249
+ + (gaps.length > 1 ? `, and ${gaps.length - 1} more part(s)` : "")] : [],
1250
+ ...names.filter((_, i) => !pieces[i].length).map((n) => `${n} holds no records`),
1251
+ ...held.slice(1).flatMap(([b, y], i) => same(get(at(y[0], "event"), "run_prev_hash"), at(held[i][1].at(-1), "hash"))
1252
+ ? [] : [`${b} does not continue ${held[i][0]}`]),
1253
+ ];
1254
+ }
891
1255
  const runName = async (tenant, runId) => `runs/${(await sha256hex(bytes(`${str(tenant)}/${str(runId)}`))).slice(0, 32)}.jsonl`;
892
1256
  const LEVELS = ["dev", "local", "witnessed"];
893
1257
  function level(cosigs, witnesses, trust) {
@@ -912,19 +1276,22 @@ async function assurance(origin, cosigs, witnesses, trust, algs, selfApproved, r
912
1276
  capped = null;
913
1277
  const cosigned = [...cosigs].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)
914
1278
  .map(([k, ts]) => `${k.split("+")[0]} (${witnesses.get(k)}) at ${fmt(ts)}`).join(", ");
915
- return `${lvl}; records ${[...algs].sort().join("+")}; checkpoint ${hybrid ? "Ed25519 + SLH-DSA-SHA2-128s" : "Ed25519 only"} `
1279
+ return `${lvl}; records ${[...algs].sort(byCodePoint).join("+")}; checkpoint ${hybrid ? "Ed25519 + SLH-DSA-SHA2-128s" : "Ed25519 only"} `
916
1280
  + `(${origin})` + (cosigned ? `; cosigned ed25519 by ${cosigned}` : "; no witness cosignature")
917
1281
  + (times.length ? `; earliest independent anchor ${fmt(times.reduce((a, b) => b < a ? b : a))}` : "")
918
1282
  + (capped ? `; capped by the run-set's registry notes (${capped})` : "") + (selfApproved ? "; approvals: self" : "");
919
1283
  }
920
- /** The report as text, as the reference CLI prints it; control characters in bundle-derived strings are dropped. */
1284
+ /** `x` without control characters and the bidirectional and zero-width format characters that would reorder or hide
1285
+ * the text around them, as the report prints bundle-derived strings. */
1286
+ export const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "");
1287
+ /** The report as text, as the reference CLI prints it; `clean` drops characters from bundle-derived strings. */
921
1288
  export function formatReport(rep) {
922
- const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
923
1289
  let s = "";
924
1290
  for (const c of rep.checks) {
925
1291
  s += `[${c.status.toUpperCase()}] ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : "") + "\n";
926
1292
  for (const p of c.problems)
927
1293
  s += ` ${clean(p)}\n`;
928
1294
  }
929
- return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n`;
1295
+ return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n`
1296
+ + (rep.findings ? `Findings: ${clean(rep.findings)}.\n` : "");
930
1297
  }