@sanning/proof 0.18.0 → 0.19.1

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/src/run.ts ADDED
@@ -0,0 +1,1598 @@
1
+ // The run reading: the enclave attestation line and the run-account line.
2
+ //
3
+ // DRAFT. `specs/enclave-attestation.md` and `specs/run-records.md` define both
4
+ // lines; this module is their provider-neutral half, the twin of
5
+ // `src/sanning_proof/run.py`. It changes no verdict, no exception and no exit
6
+ // code: a drop without an opening record reads exactly as it did before, and a
7
+ // drop with one gets two lines beside the verdict.
8
+ //
9
+ // No new cryptography. The kernel has already verified every envelope and every
10
+ // stamp; this module reads the records it verified and adds SHA-256 and
11
+ // comparisons. The token, the certificate chain and the result signature are an
12
+ // ADAPTER's, a separate package. `readRuns` imports nothing: a caller passes the
13
+ // adapters it loaded (see `run-adapters.ts`, Node-only), and without one every
14
+ // opening record reads `not_checked` with `no_adapter`.
15
+
16
+ import { bytesToHex, hexToBytes, sha256Hex, utf8 } from "./crypto.js";
17
+ import { verifyEvidenceBundle } from "./evidence.js";
18
+ import { sideContentEntry, sideContentKeys, type SideContentMap } from "./side-content.js";
19
+ import {
20
+ DuplicateKeyError,
21
+ exceedsTextDepth,
22
+ jcs,
23
+ jcsParse,
24
+ jcsParseWithinLimits,
25
+ MAX_READ_DEPTH,
26
+ renderKey,
27
+ } from "./verifier.js";
28
+
29
+ export const ATTESTATION_VERSION = "sanning.enclave-attestation/v1";
30
+ export const BINDING_VERSION = "sanning.run-binding/v1";
31
+ export const POLICY_VERSION = "sanning.enclave-policy/v1";
32
+ export const MANIFEST_VERSION = "sanning.run-manifest/v1";
33
+ export const RUN_OPENED = "runner.run_opened";
34
+ export const RUN_CLOSED = "runner.run_closed";
35
+ const RUNNER_PREFIX = "runner.";
36
+ export const PROVIDER_GCP = "gcp-confidential-space";
37
+ export const PROVIDER_FLARE = "flare-fcc";
38
+
39
+ /**
40
+ * The fixed table: provider id to the package that answers for it. A provider
41
+ * id read from a pack is looked up HERE and nowhere else, so a pack naming `os`
42
+ * or `../x` loads nothing.
43
+ */
44
+ export const ADAPTER_MODULES: Readonly<Record<string, string>> = Object.freeze({
45
+ [PROVIDER_GCP]: "@sanning/enclave-gcp",
46
+ [PROVIDER_FLARE]: "@sanning/enclave-flare",
47
+ });
48
+
49
+ /** Printed after an enclave line whose token was checked. Never a state. */
50
+ export const REVOCATION_SENTENCE =
51
+ "enclave attestation: revocation not checked " +
52
+ "(the pack carries no certificate revocation lists)";
53
+
54
+ export const ENCLAVE_STATES = ["confirmed", "differs", "not_checked"] as const;
55
+ export const RUN_ACCOUNT_STATES = ["closed", "differs", "not_closed"] as const;
56
+
57
+ /** Why an enclave line reads `not_checked`. */
58
+ export const ENCLAVE_NOT_CHECKED_KINDS = [
59
+ "no_adapter",
60
+ "no_attestation",
61
+ "unknown_version",
62
+ "no_policy",
63
+ "policy_unreadable",
64
+ "adapter_failed",
65
+ "no_opening_record",
66
+ ] as const;
67
+ /** The provider-neutral checks that make a line `differs`, in line order. */
68
+ export const ENCLAVE_DIFFERS_KINDS = [
69
+ "attestation_malformed",
70
+ "subject_key_not_signer",
71
+ "manifest_not_content",
72
+ "binding_other_run",
73
+ "binding_other_manifest",
74
+ "binding_other_keys",
75
+ "signed_at_outside_tolerance",
76
+ "manifest_content_mismatch",
77
+ "manifest_unreadable",
78
+ "case_hash_differs",
79
+ "agent_record_under_runner_key",
80
+ ] as const;
81
+ /** The kinds an adapter may answer with; any other reads `adapter_failed`. */
82
+ export const ENCLAVE_NOTE_KINDS = ["no_issue_time", "manifest_withheld", "stamp_unwitnessed"] as const;
83
+ /**
84
+ * Every kind and note the kernel itself names. An adapter declares its own
85
+ * failure kinds (its `kinds`, section 9); none of them may be one of these, so
86
+ * an adapter can never speak for a neutral check.
87
+ */
88
+ export const NEUTRAL_KINDS: ReadonlySet<string> = new Set<string>([
89
+ ...ENCLAVE_NOT_CHECKED_KINDS,
90
+ ...ENCLAVE_DIFFERS_KINDS,
91
+ ...ENCLAVE_NOTE_KINDS,
92
+ ]);
93
+ /** The form of a kind an adapter declares: snake_case, one word on the line. */
94
+ const KIND_FORM = /^[a-z][a-z0-9_]{0,63}$/;
95
+ export const ACCOUNT_FINDING_KINDS = [
96
+ "record_not_listed",
97
+ "listed_record_absent",
98
+ "more_than_one_closing_record",
99
+ "closing_malformed",
100
+ ] as const;
101
+ /**
102
+ * Why the online line (`flare-fcc` only, section 8) reads `not_checked`:
103
+ * nothing was decided. `adapter_failed` is the kernel's.
104
+ */
105
+ export const ONLINE_NOT_CHECKED_KINDS = [
106
+ "adapter_failed",
107
+ "evidence_not_confirmed",
108
+ "no_contracts",
109
+ "manifest_withheld",
110
+ "manifest_unreadable",
111
+ "chain_unavailable",
112
+ "not_final",
113
+ ] as const;
114
+ /** Why the online line reads `differs`: the chain answered, and it does not match. */
115
+ export const ONLINE_DIFFERS_KINDS = [
116
+ "instruction_count",
117
+ "instruction_other_extension",
118
+ "instruction_other_machine",
119
+ "instruction_other_operation",
120
+ "instruction_other_message",
121
+ "transaction_failed",
122
+ "log_malformed",
123
+ "run_opened_differs",
124
+ "run_opened_count",
125
+ "machine_not_production",
126
+ "machine_other_extension",
127
+ "code_not_supported",
128
+ "call_reverted",
129
+ ] as const;
130
+ export const ACCOUNT_NOTE_KINDS = [
131
+ "agent_key_unknown",
132
+ "runner_record_under_agent_key",
133
+ "runner_record_bytes_withheld",
134
+ "payload_hashes_absent",
135
+ "unacknowledged_record_absent",
136
+ ] as const;
137
+
138
+ const SAFE = Number.MAX_SAFE_INTEGER;
139
+ const U32 = 2 ** 32 - 1;
140
+ const U8 = 255;
141
+
142
+ const LOWER_HEX64 = /^[0-9a-f]{64}$/;
143
+ const HEX_0X = /^0x(?:[0-9a-fA-F]{2})*$/;
144
+ const LOWER_0X = /^0x(?:[0-9a-f]{2})*$/;
145
+ const B64URL_32 = /^[A-Za-z0-9_-]{43}$/;
146
+ /**
147
+ * A plane's origin (section 4): http or https, a lowercase host (a DNS name, an
148
+ * IPv4 address, or an IPv6 address in brackets), and an optional port from 1
149
+ * to 65535 with no leading zero, never the scheme's default (443, 80). Nothing
150
+ * after it: no path, no trailing slash.
151
+ */
152
+ const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
153
+ const ORIGIN = new RegExp(
154
+ `^https?://(?:${LABEL}(?:\\.${LABEL})*|\\[([0-9a-f:.]+)\\])` +
155
+ "(?::(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$",
156
+ );
157
+
158
+ /** A plane origin in its one form (section 4): `isOrigin` in Python. */
159
+ function isOrigin(value: string): boolean {
160
+ const m = ORIGIN.exec(value);
161
+ if (m === null) return false;
162
+ if (m[1] !== undefined && !isIpv6Text(m[1])) return false;
163
+ return !value.endsWith(value.startsWith("https://") ? ":443" : ":80");
164
+ }
165
+
166
+ const isHex = (c: string): boolean => c !== "" && "0123456789abcdef".includes(c);
167
+ const isDigit = (c: string): boolean => c !== "" && c >= "0" && c <= "9";
168
+
169
+ /**
170
+ * Whether `s`, the text between a host's brackets, is an IPv6 address as the
171
+ * WHATWG URL Standard's IPv6 parser accepts one (RFC 4291 text form), in
172
+ * lowercase. A step for step port of the parser, the twin of Python's
173
+ * `is_ipv6_text` and the runner's `run.IsPlaneOrigin`.
174
+ */
175
+ export function isIpv6Text(s: string): boolean {
176
+ const n = s.length;
177
+ const at = (k: number): string => (k < n ? s[k]! : "");
178
+ let i = 0;
179
+ let piece = 0;
180
+ let compress: number | null = null;
181
+ if (at(i) === ":") {
182
+ if (at(i + 1) !== ":") return false;
183
+ i += 2;
184
+ piece += 1;
185
+ compress = piece;
186
+ }
187
+ while (i < n) {
188
+ if (piece === 8) return false;
189
+ if (s[i] === ":") {
190
+ if (compress !== null) return false;
191
+ i += 1;
192
+ piece += 1;
193
+ compress = piece;
194
+ continue;
195
+ }
196
+ let length = 0;
197
+ while (length < 4 && isHex(at(i))) {
198
+ i += 1;
199
+ length += 1;
200
+ }
201
+ if (at(i) === ".") {
202
+ if (length === 0) return false;
203
+ i -= length;
204
+ if (piece > 6) return false;
205
+ let seen = 0;
206
+ while (i < n) {
207
+ if (seen > 0) {
208
+ if (s[i] === "." && seen < 4) i += 1;
209
+ else return false;
210
+ }
211
+ if (!isDigit(at(i))) return false;
212
+ let part: number | null = null;
213
+ while (isDigit(at(i))) {
214
+ const d = Number(s[i]);
215
+ if (part === null) part = d;
216
+ else if (part === 0) return false;
217
+ else part = part * 10 + d;
218
+ if (part > 255) return false;
219
+ i += 1;
220
+ }
221
+ seen += 1;
222
+ if (seen === 2 || seen === 4) piece += 1;
223
+ }
224
+ if (seen !== 4) return false;
225
+ break;
226
+ }
227
+ if (at(i) === ":") {
228
+ i += 1;
229
+ if (i === n) return false;
230
+ } else if (i < n) {
231
+ return false;
232
+ }
233
+ piece += 1;
234
+ }
235
+ return compress !== null || piece === 8;
236
+ }
237
+
238
+ const INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/;
239
+
240
+ // ---- The strict reader ------------------------------------------------------
241
+
242
+ /** The object does not read under the one spelling. The message is the reason. */
243
+ export class AttestationRefused extends Error {}
244
+
245
+ /** `attestation_version` is a string this reader does not know. */
246
+ export class UnknownAttestationVersion extends AttestationRefused {}
247
+
248
+ type Node =
249
+ | { kind: "obj"; members: readonly (readonly [string, Node])[] }
250
+ | { kind: "any" }
251
+ | { kind: "str" | "nstr" | "lhex64" | "hex" | "origin" }
252
+ | { kind: "const"; value: string }
253
+ | { kind: "hexn" | "lhexn"; bytes: number }
254
+ | { kind: "int"; max: number };
255
+
256
+ const obj = (members: readonly (readonly [string, Node])[]): Node => ({ kind: "obj", members });
257
+ const STR: Node = { kind: "str" };
258
+ const NSTR: Node = { kind: "nstr" };
259
+ const LHEX64: Node = { kind: "lhex64" };
260
+ const HEX: Node = { kind: "hex" };
261
+ const ORIGIN_NODE: Node = { kind: "origin" };
262
+ const hexn = (bytes: number): Node => ({ kind: "hexn", bytes });
263
+ const lhexn = (bytes: number): Node => ({ kind: "lhexn", bytes });
264
+ const int = (max: number): Node => ({ kind: "int", max });
265
+ const konst = (value: string): Node => ({ kind: "const", value });
266
+
267
+ const SUBJECT = [
268
+ ["runner_public_key", LHEX64],
269
+ ["agent_public_key", LHEX64],
270
+ ["manifest_hash", LHEX64],
271
+ ["run_id", NSTR],
272
+ ] as const;
273
+
274
+ const ATTESTATION = [
275
+ ["attestation_version", konst(ATTESTATION_VERSION)],
276
+ ["subject", obj(SUBJECT)],
277
+ ["provider", NSTR],
278
+ ["evidence", { kind: "any" } as Node],
279
+ ] as const;
280
+
281
+ function bindingSchema(request: readonly (readonly [string, Node])[]): (readonly [string, Node])[] {
282
+ return [
283
+ ["binding_version", konst(BINDING_VERSION)],
284
+ ["run_id", NSTR],
285
+ ["manifest_hash", LHEX64],
286
+ ["runner_public_key", LHEX64],
287
+ ["agent_public_key", LHEX64],
288
+ ["credential_public_key", LHEX64],
289
+ ["case_hash", LHEX64],
290
+ ["plane", ORIGIN_NODE],
291
+ ["request", obj(request)],
292
+ ];
293
+ }
294
+
295
+ const GCP_BINDING = bindingSchema([["provider", konst(PROVIDER_GCP)]]);
296
+ const FLARE_BINDING = bindingSchema([
297
+ ["provider", konst(PROVIDER_FLARE)],
298
+ ["chain_id", int(SAFE)],
299
+ ["instruction_id", lhexn(32)],
300
+ ["tee_id", lhexn(20)],
301
+ ["opener", lhexn(20)],
302
+ ]);
303
+ const GCP_EVIDENCE = [
304
+ ["binding", obj(GCP_BINDING)],
305
+ ["token", NSTR],
306
+ ] as const;
307
+ const PUBLIC_KEY = [
308
+ ["x", hexn(32)],
309
+ ["y", hexn(32)],
310
+ ] as const;
311
+ const TEE_INFO = [
312
+ ["challenge", hexn(32)],
313
+ ["publicKey", obj(PUBLIC_KEY)],
314
+ ["initialSigningPolicyId", int(U32)],
315
+ ["initialSigningPolicyHash", hexn(32)],
316
+ ["lastSigningPolicyId", int(U32)],
317
+ ["lastSigningPolicyHash", hexn(32)],
318
+ ["chainId", int(SAFE)],
319
+ [
320
+ "state",
321
+ obj([
322
+ ["systemState", HEX],
323
+ ["systemStateVersion", hexn(32)],
324
+ ["state", HEX],
325
+ ["stateVersion", hexn(32)],
326
+ ]),
327
+ ],
328
+ ["teeTimestamp", int(SAFE)],
329
+ ["machinePathListNonce", int(SAFE)],
330
+ ["machinePathListHash", hexn(32)],
331
+ ] as const;
332
+ const MACHINE_DATA = [
333
+ ["extensionId", hexn(32)],
334
+ ["initialOwner", hexn(20)],
335
+ ["codeHash", hexn(32)],
336
+ ["platform", hexn(32)],
337
+ ["publicKey", obj(PUBLIC_KEY)],
338
+ ["governanceHash", hexn(32)],
339
+ ] as const;
340
+ const INFO = [
341
+ ["teeInfo", obj(TEE_INFO)],
342
+ ["machineData", obj(MACHINE_DATA)],
343
+ ["dataSignature", HEX],
344
+ ["attestation", STR],
345
+ ] as const;
346
+ const RESULT = [
347
+ ["id", hexn(32)],
348
+ ["submissionTag", STR],
349
+ ["status", int(U8)],
350
+ ["log", STR],
351
+ ["opType", hexn(32)],
352
+ ["opCommand", hexn(32)],
353
+ ["additionalResultStatus", HEX],
354
+ ["version", STR],
355
+ ["data", HEX],
356
+ ] as const;
357
+ const FLARE_EVIDENCE = [
358
+ ["result", obj(RESULT)],
359
+ ["signature", HEX],
360
+ ["info", obj(INFO)],
361
+ ] as const;
362
+
363
+ const EVIDENCE: Readonly<Record<string, readonly (readonly [string, Node])[]>> = {
364
+ [PROVIDER_GCP]: GCP_EVIDENCE,
365
+ [PROVIDER_FLARE]: FLARE_EVIDENCE,
366
+ };
367
+
368
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
369
+ return typeof v === "object" && v !== null && !Array.isArray(v);
370
+ }
371
+
372
+ function has(o: Record<string, unknown>, k: string): boolean {
373
+ return Object.prototype.hasOwnProperty.call(o, k);
374
+ }
375
+
376
+ /** A JSON number whose value is a whole number within 2^53 - 1. */
377
+ function wholeNumber(v: unknown): number | null {
378
+ return typeof v === "number" && Number.isSafeInteger(v) ? v + 0 : null;
379
+ }
380
+
381
+ function walk(value: unknown, node: Node, path: string): void {
382
+ switch (node.kind) {
383
+ case "any":
384
+ if (!isPlainObject(value)) throw new AttestationRefused(`${path} must be an object`);
385
+ return;
386
+ case "obj": {
387
+ if (!isPlainObject(value)) throw new AttestationRefused(`${path} must be an object`);
388
+ const allowed = new Set(node.members.map(([name]) => name));
389
+ const unknown = Object.keys(value)
390
+ .filter((k) => !allowed.has(k))
391
+ .sort();
392
+ if (unknown.length > 0) {
393
+ throw new AttestationRefused(`${path}: unknown member ${renderKey(unknown[0]!)}`);
394
+ }
395
+ for (const [name, sub] of node.members) {
396
+ if (!has(value, name)) throw new AttestationRefused(`${path}.${name} is required`);
397
+ walk(value[name], sub, `${path}.${name}`);
398
+ }
399
+ return;
400
+ }
401
+ case "int": {
402
+ const n = wholeNumber(value);
403
+ if (n === null) throw new AttestationRefused(`${path} must be a whole number`);
404
+ if (n < 0 || n > node.max) throw new AttestationRefused(`${path} is out of range`);
405
+ return;
406
+ }
407
+ }
408
+ if (typeof value !== "string") throw new AttestationRefused(`${path} must be a string`);
409
+ switch (node.kind) {
410
+ case "str":
411
+ return;
412
+ case "nstr":
413
+ if (value === "") throw new AttestationRefused(`${path} must be a non-empty string`);
414
+ return;
415
+ case "const":
416
+ if (value !== node.value) throw new AttestationRefused(`${path} must be ${renderKey(node.value)}`);
417
+ return;
418
+ case "lhex64":
419
+ if (!LOWER_HEX64.test(value)) {
420
+ throw new AttestationRefused(`${path} must be 64 lowercase hex characters`);
421
+ }
422
+ return;
423
+ case "hex":
424
+ case "hexn":
425
+ if (!HEX_0X.test(value)) throw new AttestationRefused(`${path} must be 0x-prefixed hex`);
426
+ if (node.kind === "hexn" && value.length !== 2 + 2 * node.bytes) {
427
+ throw new AttestationRefused(`${path} must be ${node.bytes} bytes`);
428
+ }
429
+ return;
430
+ case "origin":
431
+ if (!isOrigin(value)) {
432
+ throw new AttestationRefused(`${path} must be an origin (scheme://host[:port])`);
433
+ }
434
+ return;
435
+ case "lhexn":
436
+ if (!LOWER_0X.test(value) || value.length !== 2 + 2 * node.bytes) {
437
+ throw new AttestationRefused(
438
+ `${path} must be lowercase 0x-prefixed hex of ${node.bytes} bytes`,
439
+ );
440
+ }
441
+ return;
442
+ }
443
+ }
444
+
445
+ /** Whether a parsed value holds a number past 2^53 - 1 (or not finite). Iterative. */
446
+ function hasUnsafeInteger(value: unknown): boolean {
447
+ const stack: unknown[] = [value];
448
+ while (stack.length > 0) {
449
+ const node = stack.pop();
450
+ if (typeof node === "number") {
451
+ if (!Number.isFinite(node)) return true;
452
+ if (Number.isInteger(node) && !Number.isSafeInteger(node)) return true;
453
+ } else if (Array.isArray(node)) {
454
+ stack.push(...node);
455
+ } else if (typeof node === "object" && node !== null) {
456
+ stack.push(...Object.values(node));
457
+ }
458
+ }
459
+ return false;
460
+ }
461
+
462
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
463
+ if (a.length !== b.length) return false;
464
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
465
+ return true;
466
+ }
467
+
468
+ /**
469
+ * Read bytes that must be `JCS(value)`: strict UTF-8, valid JSON, no repeated
470
+ * name, no integer beyond 2^53 - 1, and canonical. Bytes nested deeper than
471
+ * every kernel canonicalizes cannot be shown to be JCS, decided on the bytes
472
+ * before any parser runs (section 2.1).
473
+ */
474
+ function oneSpelling(raw: Uint8Array, what: string): unknown {
475
+ if (exceedsTextDepth(raw, MAX_READ_DEPTH)) throw new AttestationRefused(`${what} is not JCS`);
476
+ let value: unknown;
477
+ try {
478
+ value = jcsParse(raw);
479
+ } catch (err) {
480
+ const reason = err instanceof DuplicateKeyError ? err.message : "not JSON";
481
+ throw new AttestationRefused(`${what}: ${reason}`);
482
+ }
483
+ if (hasUnsafeInteger(value)) {
484
+ throw new AttestationRefused(`${what} holds an integer beyond 2^53 - 1`);
485
+ }
486
+ let canonical: Uint8Array | null = null;
487
+ try {
488
+ canonical = utf8(jcs(value));
489
+ } catch {
490
+ canonical = null;
491
+ }
492
+ if (canonical === null || !bytesEqual(canonical, raw)) {
493
+ throw new AttestationRefused(`${what} is not JCS`);
494
+ }
495
+ return value;
496
+ }
497
+
498
+ /** `sanning.run-binding/v1`, read strictly from the evidence. */
499
+ export interface Binding {
500
+ runId: string;
501
+ manifestHash: string;
502
+ runnerPublicKey: string;
503
+ agentPublicKey: string;
504
+ credentialPublicKey: string;
505
+ caseHash: string;
506
+ /**
507
+ * The plane's origin the runner claimed the run from (section 4). Carried,
508
+ * not compared: the plane refuses a claim whose binding names another plane.
509
+ */
510
+ plane: string;
511
+ request: Readonly<Record<string, unknown>>;
512
+ }
513
+
514
+ /** `sanning.enclave-attestation/v1`, read strictly. */
515
+ export interface Attestation {
516
+ runnerPublicKey: string;
517
+ agentPublicKey: string;
518
+ manifestHash: string;
519
+ runId: string;
520
+ provider: string;
521
+ evidence: Readonly<Record<string, unknown>>;
522
+ /** The binding the evidence carries, for a registered provider; else null. */
523
+ binding: Binding | null;
524
+ }
525
+
526
+ function readBinding(value: unknown, schema: (readonly [string, Node])[], path: string): Binding {
527
+ walk(value, obj(schema), path);
528
+ const v = value as Record<string, unknown>;
529
+ return {
530
+ runId: v.run_id as string,
531
+ manifestHash: v.manifest_hash as string,
532
+ runnerPublicKey: v.runner_public_key as string,
533
+ agentPublicKey: v.agent_public_key as string,
534
+ credentialPublicKey: v.credential_public_key as string,
535
+ caseHash: v.case_hash as string,
536
+ plane: v.plane as string,
537
+ request: v.request as Record<string, unknown>,
538
+ };
539
+ }
540
+
541
+ /** A binding as JCS text, so two bindings compare as the bytes they stand for. */
542
+ function bindingText(b: Binding): string {
543
+ return jcs({
544
+ run_id: b.runId,
545
+ manifest_hash: b.manifestHash,
546
+ runner_public_key: b.runnerPublicKey,
547
+ agent_public_key: b.agentPublicKey,
548
+ credential_public_key: b.credentialPublicKey,
549
+ case_hash: b.caseHash,
550
+ plane: b.plane,
551
+ request: b.request,
552
+ });
553
+ }
554
+
555
+ function isBinding(v: unknown): v is Binding {
556
+ if (!isPlainObject(v)) return false;
557
+ for (const k of ["runId", "manifestHash", "runnerPublicKey", "agentPublicKey", "credentialPublicKey", "caseHash", "plane"]) {
558
+ if (typeof v[k] !== "string") return false;
559
+ }
560
+ return isPlainObject(v.request);
561
+ }
562
+
563
+ /**
564
+ * Read the `enclave_attestation` member of an opening record strictly. Throws
565
+ * `UnknownAttestationVersion` for a version string this reader does not know,
566
+ * and `AttestationRefused` for anything else that is not the one spelling.
567
+ */
568
+ export function readAttestation(value: unknown): Attestation {
569
+ const path = "enclave_attestation";
570
+ if (!isPlainObject(value)) throw new AttestationRefused(`${path} must be an object`);
571
+ if (!has(value, "attestation_version")) {
572
+ throw new AttestationRefused(`${path}.attestation_version is required`);
573
+ }
574
+ const version = value.attestation_version;
575
+ if (typeof version !== "string") {
576
+ throw new AttestationRefused(`${path}.attestation_version must be a string`);
577
+ }
578
+ if (version !== ATTESTATION_VERSION) {
579
+ throw new UnknownAttestationVersion(
580
+ `${path}.attestation_version is not a version this reader knows`,
581
+ );
582
+ }
583
+ walk(value, obj(ATTESTATION), path);
584
+ const provider = value.provider as string;
585
+ const evidence = value.evidence as Record<string, unknown>;
586
+ const subject = value.subject as Record<string, string>;
587
+ let binding: Binding | null = null;
588
+ const schema = has(EVIDENCE, provider) ? EVIDENCE[provider] : undefined;
589
+ if (schema !== undefined) {
590
+ const evPath = `${path}.evidence`;
591
+ walk(evidence, obj(schema), evPath);
592
+ if (provider === PROVIDER_GCP) {
593
+ binding = readBinding(evidence.binding, GCP_BINDING, `${evPath}.binding`);
594
+ } else {
595
+ const dataPath = `${evPath}.result.data`;
596
+ const data = ((evidence.result as Record<string, unknown>).data as string).slice(2);
597
+ binding = readBinding(oneSpelling(hexToBytes(data), dataPath), FLARE_BINDING, dataPath);
598
+ }
599
+ }
600
+ return {
601
+ runnerPublicKey: subject.runner_public_key!,
602
+ agentPublicKey: subject.agent_public_key!,
603
+ manifestHash: subject.manifest_hash!,
604
+ runId: subject.run_id!,
605
+ provider,
606
+ evidence,
607
+ binding,
608
+ };
609
+ }
610
+
611
+ /** `readAttestation` over bytes that must be `JCS(object)`. */
612
+ export function readAttestationJson(raw: Uint8Array): Attestation {
613
+ return readAttestation(oneSpelling(raw, "enclave_attestation"));
614
+ }
615
+
616
+ // ---- Adapters -------------------------------------------------------------------
617
+
618
+ /**
619
+ * What an adapter answers (checks 2 to 6 and 8). `binding` is the binding the
620
+ * adapter verified its evidence commits to: `confirmed` without one is outside
621
+ * the contract and reads `adapter_failed`, because the kernel runs checks 7
622
+ * and 11 on that binding. Each kind is one the adapter declares in `kinds`.
623
+ */
624
+ export interface AdapterResult {
625
+ state: (typeof ENCLAVE_STATES)[number];
626
+ kinds?: readonly string[];
627
+ /** The verified token's issue time, seconds since the epoch, or null. */
628
+ issuedAt?: number | null;
629
+ imageId?: string | null;
630
+ hwModel?: string | null;
631
+ tokenChecked?: boolean;
632
+ binding?: Binding | null;
633
+ }
634
+
635
+ /**
636
+ * An adapter: the provider it answers for, the failure kinds it declares, its
637
+ * check, and optionally the online line.
638
+ */
639
+ export interface EnclaveAdapter {
640
+ provider: string;
641
+ kinds: readonly string[];
642
+ check(
643
+ attestation: Attestation,
644
+ options: { policy: Readonly<Record<string, unknown>>; at: string | null },
645
+ ): AdapterResult | Promise<AdapterResult>;
646
+ online?(
647
+ attestation: Attestation,
648
+ options: {
649
+ policy: Readonly<Record<string, unknown>>;
650
+ at: string | null;
651
+ manifest: Uint8Array | null;
652
+ rpc: string;
653
+ },
654
+ ): OnlineLine | Promise<OnlineLine>;
655
+ }
656
+
657
+ /**
658
+ * The online line (section 8): what the chain says of a `flare-fcc` opening,
659
+ * read by the adapter at the offline line's evaluation time. `confirmed`
660
+ * carries no kind; `not_checked` and `differs` carry one. It moves nothing.
661
+ */
662
+ export interface OnlineLine {
663
+ state: (typeof ENCLAVE_STATES)[number];
664
+ kinds: string[];
665
+ }
666
+
667
+ // ---- The reading ----------------------------------------------------------------
668
+
669
+ export interface EnclaveLine {
670
+ state: (typeof ENCLAVE_STATES)[number];
671
+ kinds: string[];
672
+ notes: string[];
673
+ reason: string | null;
674
+ provider: string | null;
675
+ evaluatedAt: string | null;
676
+ evaluation: "stamp" | "token_issue_time" | null;
677
+ tokenChecked: boolean;
678
+ }
679
+
680
+ export interface AccountEntry {
681
+ kind: string;
682
+ eventId: string | null;
683
+ }
684
+
685
+ export interface RunAccountLine {
686
+ state: (typeof RUN_ACCOUNT_STATES)[number];
687
+ findings: AccountEntry[];
688
+ notes: AccountEntry[];
689
+ closingEventId: string | null;
690
+ reason: string | null;
691
+ }
692
+
693
+ export interface RunReading {
694
+ /** The pack by the reader's name for it; two packs can share one (A43). */
695
+ packRef: string;
696
+ /** The pack's position among the packs read, as the case reading's `pack_index`. */
697
+ packIndex: number;
698
+ eventId: string;
699
+ runnerPublicKey: string;
700
+ agentPublicKey: string | null;
701
+ enclave: EnclaveLine;
702
+ account: RunAccountLine;
703
+ /** The online line, where the reader asked for one and the adapter reads the chain; else null. */
704
+ online: OnlineLine | null;
705
+ }
706
+
707
+ export interface RunsReading {
708
+ runs: RunReading[];
709
+ }
710
+
711
+ /** One pack, as `readCase` takes it. */
712
+ export interface RunPack {
713
+ ref: string;
714
+ bundle: unknown;
715
+ content?: SideContentMap | null;
716
+ }
717
+
718
+ interface Held {
719
+ packRef: string;
720
+ packIndex: number;
721
+ eventId: string;
722
+ /** The envelope's key in its one lowercase form: two spellings, one key. */
723
+ publicKey: string;
724
+ payloadHash: string | null;
725
+ signedAt: unknown;
726
+ verified: boolean;
727
+ bytesSupplied: boolean;
728
+ raw: Uint8Array | null;
729
+ record: Record<string, unknown> | null;
730
+ content: Uint8Array | null;
731
+ /**
732
+ * The interval end of a stamp the kernel verified as including this record,
733
+ * and whether the reader's lookup confirmed that stamp's witness on the
734
+ * public record. Only a confirmed witness makes the interval end more than
735
+ * the word of whoever sealed the pack.
736
+ */
737
+ stampEnd: string | null;
738
+ stampWitnessed: boolean;
739
+ }
740
+
741
+ const KEY_HEX = /^[0-9a-fA-F]{64}$/;
742
+
743
+ /** A key as a key: 64 hex digits of either case in their lowercase form; else as it is. */
744
+ function asKey(value: unknown): string {
745
+ if (typeof value !== "string") return "";
746
+ return KEY_HEX.test(value) ? value.toLowerCase() : value;
747
+ }
748
+
749
+ function line(
750
+ state: EnclaveLine["state"],
751
+ kinds: string[],
752
+ extra: Partial<EnclaveLine> = {},
753
+ ): EnclaveLine {
754
+ return {
755
+ state,
756
+ kinds,
757
+ notes: [],
758
+ reason: null,
759
+ provider: null,
760
+ evaluatedAt: null,
761
+ evaluation: null,
762
+ tokenChecked: false,
763
+ ...extra,
764
+ };
765
+ }
766
+
767
+ function eventType(h: Held): string | null {
768
+ const t = h.record?.event_type;
769
+ return typeof t === "string" ? t : null;
770
+ }
771
+
772
+ // The disclosed bytes the kernel bound to this record's content_hash, by its
773
+ // own precedence: in the body first, then beside it. Called only for a record
774
+ // whose content the kernel bound, so the hex reads.
775
+ function disclosed(
776
+ ev: Record<string, unknown>,
777
+ eventId: string,
778
+ content: SideContentMap | null | undefined,
779
+ ): Uint8Array | null {
780
+ if (typeof ev.content === "string") return hexToBytes(ev.content);
781
+ const entry = sideContentEntry(content, eventId);
782
+ if (!entry.offered) return null;
783
+ if (entry.value instanceof Uint8Array) return entry.value;
784
+ return hexToBytes(entry.value as string);
785
+ }
786
+
787
+ async function heldRecords(
788
+ pack: RunPack,
789
+ packIndex: number,
790
+ witnessedRefs: ReadonlySet<string>,
791
+ ): Promise<Held[]> {
792
+ const result = await verifyEvidenceBundle(pack.bundle, {
793
+ content: (pack.content ?? undefined) as
794
+ | Record<string, Uint8Array | string>
795
+ | ReadonlyMap<string, Uint8Array | string>
796
+ | undefined,
797
+ });
798
+ const bundle = pack.bundle;
799
+ const body = isPlainObject(bundle) ? bundle.body : undefined;
800
+ const events = isPlainObject(body) ? body.events : undefined;
801
+ // One result per body event, in order; a pack whose wrapper does not hold
802
+ // has none, so none of its records is read.
803
+ if (!Array.isArray(events) || events.length !== result.events.length) return [];
804
+ const stamps = new Map<string, NonNullable<typeof result.stamps>[number]>();
805
+ for (const s of result.stamps ?? []) if (s.ok && s.stampRef) stamps.set(s.stampRef, s);
806
+ const out: Held[] = [];
807
+ for (let i = 0; i < events.length; i++) {
808
+ const ev = events[i];
809
+ const er = result.events[i]!;
810
+ if (!isPlainObject(ev)) continue;
811
+ const envelope = ev.envelope;
812
+ if (!isPlainObject(envelope)) continue;
813
+ // Only a verified record is ever read, and its event_id is a UUID.
814
+ const eventId = typeof envelope.event_id === "string" ? envelope.event_id : "";
815
+ const rawHex = ev.event_bytes;
816
+ const bytesSupplied = typeof rawHex === "string" && rawHex.length > 0;
817
+ // A verified envelope whose record bytes are present is one whose bytes
818
+ // bound to its payload_hash: the kernel fails it otherwise.
819
+ const verified = er.envelopeOk;
820
+ let raw: Uint8Array | null = null;
821
+ let record: Record<string, unknown> | null = null;
822
+ if (verified) {
823
+ try {
824
+ raw = hexToBytes(rawHex as string);
825
+ const parsed = jcsParse(raw);
826
+ record = isPlainObject(parsed) ? parsed : null;
827
+ } catch {
828
+ raw = null;
829
+ record = null;
830
+ }
831
+ }
832
+ let stampEnd: string | null = null;
833
+ let witnessed = false;
834
+ const incl = ev.inclusion;
835
+ // The stamp the kernel verified as INCLUDING this record: its inclusion,
836
+ // and nothing else about the event. Bytes handed in beside the pack can
837
+ // fail an event; they must not move the time.
838
+ if (er.inclusionOk && isPlainObject(incl) && typeof incl.stamp_ref === "string") {
839
+ const st = stamps.get(incl.stamp_ref);
840
+ if (st !== undefined) {
841
+ stampEnd = st.intervalEnd;
842
+ // Witnessed means looked up and confirmed, by the caller, on the
843
+ // public record. A stamp that merely names a witness is signed by
844
+ // nobody but the pack's wrapper key, which whoever sealed the pack
845
+ // holds, so its interval end is theirs to choose.
846
+ witnessed = witnessedRefs.has(st.stampRef);
847
+ }
848
+ }
849
+ const payloadHash = envelope.payload_hash;
850
+ out.push({
851
+ packRef: pack.ref,
852
+ packIndex,
853
+ eventId,
854
+ publicKey: asKey(envelope.public_key),
855
+ payloadHash: typeof payloadHash === "string" ? payloadHash : null,
856
+ signedAt: envelope.signed_at,
857
+ verified,
858
+ bytesSupplied,
859
+ raw,
860
+ record,
861
+ content: er.contentOk === true ? disclosed(ev, eventId, pack.content) : null,
862
+ stampEnd,
863
+ stampWitnessed: witnessed,
864
+ });
865
+ }
866
+ return out;
867
+ }
868
+
869
+ // ---- Times ----------------------------------------------------------------------
870
+
871
+ function daysFromCivil(y: number, m: number, d: number): number {
872
+ y -= m <= 2 ? 1 : 0;
873
+ const era = Math.floor((y >= 0 ? y : y - 399) / 400);
874
+ const yoe = y - era * 400;
875
+ const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
876
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
877
+ return era * 146097 + doe - 719468;
878
+ }
879
+
880
+ function instant(value: unknown): [number, boolean] | null {
881
+ if (typeof value !== "string") return null;
882
+ const m = INSTANT.exec(value);
883
+ if (m === null) return null;
884
+ const [y, mo, d, h, mi, s] = [1, 2, 3, 4, 5, 6].map((i) => Number(m[i]));
885
+ const seconds = daysFromCivil(y!, mo!, d!) * 86400 + h! * 3600 + mi! * 60 + s!;
886
+ const frac = m[7];
887
+ return [seconds, frac !== undefined && /[1-9]/.test(frac)];
888
+ }
889
+
890
+ /** Check 9: `|signed_at - issued_at| <= tolerance`, exactly. */
891
+ function within(signedAt: unknown, issuedAt: number, tolerance: number): boolean {
892
+ const t = instant(signedAt);
893
+ if (t === null) return false;
894
+ const [seconds, fraction] = t;
895
+ const hi = issuedAt + tolerance;
896
+ return seconds >= issuedAt - tolerance && (seconds < hi || (seconds === hi && !fraction));
897
+ }
898
+
899
+ function rfc3339(seconds: number): string {
900
+ const days = Math.floor(seconds / 86400);
901
+ let rem = seconds - days * 86400;
902
+ const z = days + 719468;
903
+ const era = Math.floor((z >= 0 ? z : z - 146096) / 146097);
904
+ const doe = z - era * 146097;
905
+ const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
906
+ let y = yoe + era * 400;
907
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
908
+ const mp = Math.floor((5 * doy + 2) / 153);
909
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
910
+ const m = mp + (mp < 10 ? 3 : -9);
911
+ y += m <= 2 ? 1 : 0;
912
+ const h = Math.floor(rem / 3600);
913
+ rem -= h * 3600;
914
+ const mi = Math.floor(rem / 60);
915
+ const s = rem - mi * 60;
916
+ const p = (n: number, w = 2) => String(n).padStart(w, "0");
917
+ return `${p(y, 4)}-${p(m)}-${p(d)}T${p(h)}:${p(mi)}:${p(s)}Z`;
918
+ }
919
+
920
+ // ---- The policy -----------------------------------------------------------------
921
+
922
+ function providerPolicy(
923
+ policy: unknown,
924
+ provider: string,
925
+ ): ["ok", Record<string, unknown>] | ["none" | "unreadable", null] {
926
+ if (policy === null || policy === undefined) return ["none", null];
927
+ if (
928
+ !isPlainObject(policy) ||
929
+ policy.policy_version !== POLICY_VERSION ||
930
+ !isPlainObject(policy.providers)
931
+ ) {
932
+ return ["unreadable", null];
933
+ }
934
+ // No entry is no_policy; an entry that is there and not an object, null
935
+ // included, is one this reader cannot read.
936
+ if (!has(policy.providers, provider)) return ["none", null];
937
+ const entry = policy.providers[provider];
938
+ if (!isPlainObject(entry)) return ["unreadable", null];
939
+ const tol = wholeNumber(entry.signed_at_tolerance_seconds);
940
+ if (tol === null || tol < 0) return ["unreadable", null];
941
+ return ["ok", entry];
942
+ }
943
+
944
+ // ---- The enclave line ------------------------------------------------------------
945
+
946
+ function base64UrlStrict32(s: string): Uint8Array | null {
947
+ if (!B64URL_32.test(s)) return null;
948
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=";
949
+ const bin = atob(b64);
950
+ const out = new Uint8Array(bin.length);
951
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
952
+ let back = btoa(String.fromCharCode(...out));
953
+ back = back.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
954
+ return back === s && out.length === 32 ? out : null;
955
+ }
956
+
957
+ async function manifestCaseHash(content: Uint8Array): Promise<string | null> {
958
+ let m: unknown;
959
+ try {
960
+ m = jcsParseWithinLimits(content);
961
+ } catch {
962
+ return null;
963
+ }
964
+ if (!isPlainObject(m) || m.manifest_version !== MANIFEST_VERSION) return null;
965
+ const caseId = m.case_id;
966
+ const salt = m.case_salt;
967
+ if (typeof caseId !== "string" || caseId === "" || typeof salt !== "string") return null;
968
+ const saltBytes = base64UrlStrict32(salt);
969
+ if (saltBytes === null) return null;
970
+ const id = utf8(caseId);
971
+ const joined = new Uint8Array(saltBytes.length + id.length);
972
+ joined.set(saltBytes, 0);
973
+ joined.set(id, saltBytes.length);
974
+ return sha256Hex(joined);
975
+ }
976
+
977
+ /**
978
+ * The time the token is evaluated at (7): the interval end of a stamp that
979
+ * holds the opening record and whose witness the reader's lookup confirmed;
980
+ * otherwise null, the token's own issue time.
981
+ */
982
+ /**
983
+ * @internal For the kernel's own tests: the enclave line of one opening record,
984
+ * verified as the caller says, under a provider table the caller names. It is
985
+ * how a test reaches a provider outside `ADAPTER_MODULES`; `readRuns` always
986
+ * reads under `ADAPTER_MODULES`.
987
+ */
988
+ export async function enclaveLineForTest(
989
+ record: Record<string, unknown>,
990
+ publicKey: string,
991
+ content: Uint8Array | null,
992
+ policy: unknown,
993
+ adapters: ReadonlyMap<string, EnclaveAdapter>,
994
+ table: Readonly<Record<string, string>>,
995
+ ): Promise<EnclaveLine> {
996
+ const opening: Held = {
997
+ packRef: "p",
998
+ packIndex: 0,
999
+ eventId: "e",
1000
+ publicKey,
1001
+ payloadHash: null,
1002
+ signedAt: "2026-10-13T00:00:00Z",
1003
+ verified: true,
1004
+ bytesSupplied: true,
1005
+ raw: utf8(jcs(record)),
1006
+ record,
1007
+ content,
1008
+ stampEnd: null,
1009
+ stampWitnessed: false,
1010
+ };
1011
+ const [l] = await enclaveLine(opening, [opening], policy, adapters, table);
1012
+ return l;
1013
+ }
1014
+
1015
+ function evaluationTime(opening: Held): string | null {
1016
+ return opening.stampWitnessed ? opening.stampEnd : null;
1017
+ }
1018
+
1019
+ function tryRead(opening: Held): Attestation | null {
1020
+ if (opening.record === null) return null;
1021
+ const metadata = opening.record.metadata;
1022
+ if (!isPlainObject(metadata) || !has(metadata, "enclave_attestation")) return null;
1023
+ try {
1024
+ oneSpelling(opening.raw ?? new Uint8Array(), "the opening record");
1025
+ return readAttestation(metadata.enclave_attestation);
1026
+ } catch (err) {
1027
+ if (err instanceof AttestationRefused) return null;
1028
+ throw err;
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * The failure kinds an adapter declares as its `kinds`: an array of snake_case
1034
+ * strings, at least one, none of them a kind or note the kernel names
1035
+ * (`NEUTRAL_KINDS`). `null` where the declaration is not of that form, an
1036
+ * empty one included, and then the adapter is not loaded.
1037
+ */
1038
+ export function declaredKinds(adapter: unknown): ReadonlySet<string> | null {
1039
+ const kinds = isPlainObjectLike(adapter) ? (adapter as { kinds?: unknown }).kinds : undefined;
1040
+ if (!Array.isArray(kinds) || kinds.length === 0) return null;
1041
+ for (const k of kinds) {
1042
+ if (typeof k !== "string" || !KIND_FORM.test(k) || NEUTRAL_KINDS.has(k)) return null;
1043
+ }
1044
+ return new Set(kinds as string[]);
1045
+ }
1046
+
1047
+ function isPlainObjectLike(v: unknown): boolean {
1048
+ return (typeof v === "object" || typeof v === "function") && v !== null;
1049
+ }
1050
+
1051
+ async function enclaveLine(
1052
+ opening: Held,
1053
+ held: Held[],
1054
+ policy: unknown,
1055
+ adapters: ReadonlyMap<string, EnclaveAdapter>,
1056
+ table: Readonly<Record<string, string>> = ADAPTER_MODULES,
1057
+ ): Promise<[EnclaveLine, Attestation | null]> {
1058
+ if (adapters.size === 0) return [line("not_checked", ["no_adapter"]), tryRead(opening)];
1059
+ const record = opening.record!;
1060
+ const metadata = record.metadata;
1061
+ if (!isPlainObject(metadata) || !has(metadata, "enclave_attestation")) {
1062
+ return [line("not_checked", ["no_attestation"]), null];
1063
+ }
1064
+ let att: Attestation;
1065
+ try {
1066
+ oneSpelling(opening.raw ?? new Uint8Array(), "the opening record");
1067
+ att = readAttestation(metadata.enclave_attestation);
1068
+ } catch (err) {
1069
+ if (err instanceof UnknownAttestationVersion) {
1070
+ return [line("not_checked", ["unknown_version"], { reason: err.message }), null];
1071
+ }
1072
+ if (err instanceof AttestationRefused) {
1073
+ return [line("differs", ["attestation_malformed"], { reason: err.message }), null];
1074
+ }
1075
+ throw err;
1076
+ }
1077
+ const provider = att.provider;
1078
+ const adapter = has(table, provider) ? adapters.get(provider) : undefined;
1079
+ if (adapter === undefined) return [line("not_checked", ["no_adapter"], { provider }), att];
1080
+ const [status, entry] = providerPolicy(policy, provider);
1081
+ if (status !== "ok") {
1082
+ return [line("not_checked", [status === "none" ? "no_policy" : "policy_unreadable"], { provider }), att];
1083
+ }
1084
+
1085
+ const at = evaluationTime(opening);
1086
+ let answer: AdapterResult;
1087
+ try {
1088
+ const declared = declaredKinds(adapter);
1089
+ if (declared === null) throw new TypeError("the adapter declares no kinds");
1090
+ answer = await adapter.check(att, { policy: entry, at });
1091
+ const answerKinds: unknown = answer?.kinds ?? [];
1092
+ const answerBinding: unknown = answer?.binding ?? null;
1093
+ const answerIssuedAt: unknown = answer?.issuedAt ?? null;
1094
+ if (
1095
+ !isPlainObject(answer) ||
1096
+ !(ENCLAVE_STATES as readonly string[]).includes(answer.state) ||
1097
+ !Array.isArray(answerKinds) ||
1098
+ answerKinds.some((k) => typeof k !== "string" || !declared.has(k)) ||
1099
+ !(answerBinding === null || isBinding(answerBinding)) ||
1100
+ !(answerIssuedAt === null || Number.isSafeInteger(answerIssuedAt))
1101
+ ) {
1102
+ throw new TypeError("not an AdapterResult");
1103
+ }
1104
+ // A differs answer names the checks that failed, and only a differs
1105
+ // answer names any.
1106
+ if ((answer.state === "differs") !== (answerKinds.length > 0)) {
1107
+ throw new TypeError("kinds that do not fit the state");
1108
+ }
1109
+ // A not_checked line carries one kind (section 6), and a not_checked
1110
+ // answer names none, so it has no kind for the line to carry.
1111
+ if (answer.state === "not_checked") {
1112
+ throw new TypeError("not_checked, with no kind for the line");
1113
+ }
1114
+ // Confirmed means the adapter verified a binding: without one, checks 7
1115
+ // and 11 would have nothing to run on.
1116
+ if (answer.state === "confirmed" && answerBinding === null) {
1117
+ throw new TypeError("confirmed without the binding it verified");
1118
+ }
1119
+ // Where the kernel read the binding itself (a registered provider), the
1120
+ // adapter verified that binding and no other.
1121
+ if (answerBinding !== null && att.binding !== null && bindingText(answerBinding as Binding) !== bindingText(att.binding)) {
1122
+ throw new TypeError("a binding other than the one the evidence carries");
1123
+ }
1124
+ } catch {
1125
+ return [line("not_checked", ["adapter_failed"], { provider }), att];
1126
+ }
1127
+
1128
+ const kinds: string[] = [];
1129
+ const notes: string[] = [];
1130
+ // Check 1.
1131
+ if (att.runnerPublicKey !== opening.publicKey) kinds.push("subject_key_not_signer");
1132
+ const event = record.event;
1133
+ const contentHash = isPlainObject(event) ? event.content_hash : undefined;
1134
+ if (contentHash !== att.manifestHash) kinds.push("manifest_not_content");
1135
+ // Check 7: on the binding the adapter verified, or, where the adapter did
1136
+ // not confirm, on the one the kernel read from the evidence.
1137
+ const b = answer.binding ?? att.binding;
1138
+ if (b !== null) {
1139
+ if (b.runId !== att.runId) kinds.push("binding_other_run");
1140
+ if (b.manifestHash !== att.manifestHash) kinds.push("binding_other_manifest");
1141
+ if (b.runnerPublicKey !== att.runnerPublicKey || b.agentPublicKey !== att.agentPublicKey) {
1142
+ kinds.push("binding_other_keys");
1143
+ }
1144
+ }
1145
+ kinds.push(...(answer.kinds ?? []));
1146
+ // Check 9.
1147
+ const issuedAt = answer.issuedAt ?? null;
1148
+ if (issuedAt !== null) {
1149
+ const tolerance = wholeNumber(entry.signed_at_tolerance_seconds) ?? 0;
1150
+ if (!within(opening.signedAt, issuedAt, tolerance)) kinds.push("signed_at_outside_tolerance");
1151
+ } else if (answer.state === "confirmed") {
1152
+ // A provider whose evidence has no issue time: check 9 did not run.
1153
+ notes.push("no_issue_time");
1154
+ }
1155
+ // Check 11.
1156
+ if (opening.content === null) {
1157
+ notes.push("manifest_withheld");
1158
+ } else if ((await sha256Hex(opening.content)) !== att.manifestHash) {
1159
+ kinds.push("manifest_content_mismatch");
1160
+ } else {
1161
+ const caseHash = await manifestCaseHash(opening.content);
1162
+ if (caseHash === null) kinds.push("manifest_unreadable");
1163
+ else if (b !== null && caseHash !== b.caseHash) kinds.push("case_hash_differs");
1164
+ }
1165
+ // Check 10.
1166
+ for (const h of held) {
1167
+ if (h === opening || !h.verified || h.record === null) continue;
1168
+ const runnerType = (eventType(h) ?? "").startsWith(RUNNER_PREFIX);
1169
+ if (h.publicKey === att.runnerPublicKey && !runnerType) {
1170
+ if (!kinds.includes("agent_record_under_runner_key")) kinds.push("agent_record_under_runner_key");
1171
+ }
1172
+ }
1173
+ if (opening.stampEnd !== null && !opening.stampWitnessed) notes.push("stamp_unwitnessed");
1174
+
1175
+ let evaluation: EnclaveLine["evaluation"] = null;
1176
+ let evaluatedAt: string | null = null;
1177
+ const tokenChecked = answer.tokenChecked === true;
1178
+ if (tokenChecked) {
1179
+ if (at !== null) {
1180
+ evaluation = "stamp";
1181
+ evaluatedAt = at;
1182
+ } else {
1183
+ evaluation = "token_issue_time";
1184
+ if (issuedAt !== null) evaluatedAt = rfc3339(issuedAt);
1185
+ }
1186
+ }
1187
+ return [
1188
+ line(kinds.length > 0 ? "differs" : answer.state, kinds, {
1189
+ notes,
1190
+ provider,
1191
+ evaluatedAt,
1192
+ evaluation,
1193
+ tokenChecked,
1194
+ }),
1195
+ att,
1196
+ ];
1197
+ }
1198
+
1199
+ // ---- The run account -----------------------------------------------------------------
1200
+
1201
+ const CLOSING_KEYS = new Set([
1202
+ "outcome",
1203
+ "payload_hashes",
1204
+ "record_count",
1205
+ "record_ids",
1206
+ "unacknowledged_payload_hashes",
1207
+ "unacknowledged_record_ids",
1208
+ ]);
1209
+
1210
+ class ClosingRefused extends Error {}
1211
+
1212
+ function idList(value: unknown, path: string): string[] {
1213
+ if (!Array.isArray(value)) throw new ClosingRefused(`${path} must be a list`);
1214
+ for (const v of value) {
1215
+ if (typeof v !== "string" || v === "") throw new ClosingRefused(`${path} must hold non-empty strings`);
1216
+ }
1217
+ if (new Set(value).size !== value.length) throw new ClosingRefused(`${path} names one record twice`);
1218
+ return value as string[];
1219
+ }
1220
+
1221
+ function hashList(value: unknown, path: string, n: number): string[] {
1222
+ if (!Array.isArray(value)) throw new ClosingRefused(`${path} must be a list`);
1223
+ for (const v of value) {
1224
+ if (typeof v !== "string" || !LOWER_HEX64.test(v)) {
1225
+ throw new ClosingRefused(`${path} must hold 64 lowercase hex characters each`);
1226
+ }
1227
+ }
1228
+ if (value.length !== n) throw new ClosingRefused(`${path} is not one hash per id`);
1229
+ return value as string[];
1230
+ }
1231
+
1232
+ function readClosing(record: Record<string, unknown>): [string[], string[] | null, string[], string[]] {
1233
+ const path = "metadata.closing";
1234
+ const metadata = record.metadata;
1235
+ const closing = isPlainObject(metadata) ? metadata.closing : undefined;
1236
+ if (!isPlainObject(closing)) throw new ClosingRefused(`${path} must be an object`);
1237
+ const unknown = Object.keys(closing)
1238
+ .filter((k) => !CLOSING_KEYS.has(k))
1239
+ .sort();
1240
+ if (unknown.length > 0) throw new ClosingRefused(`${path}: unknown member ${renderKey(unknown[0]!)}`);
1241
+ for (const k of ["record_count", "record_ids", "outcome"]) {
1242
+ if (!has(closing, k)) throw new ClosingRefused(`${path}.${k} is required`);
1243
+ }
1244
+ const ids = idList(closing.record_ids, `${path}.record_ids`);
1245
+ const count = wholeNumber(closing.record_count);
1246
+ if (count === null || count !== ids.length) {
1247
+ throw new ClosingRefused(`${path}.record_count is not the number of record_ids`);
1248
+ }
1249
+ if (!isPlainObject(closing.outcome)) throw new ClosingRefused(`${path}.outcome must be an object`);
1250
+ const hashes = has(closing, "payload_hashes")
1251
+ ? hashList(closing.payload_hashes, `${path}.payload_hashes`, ids.length)
1252
+ : null;
1253
+ const hasUids = has(closing, "unacknowledged_record_ids");
1254
+ const hasUhashes = has(closing, "unacknowledged_payload_hashes");
1255
+ if (hasUids !== hasUhashes) {
1256
+ throw new ClosingRefused(
1257
+ `${path}: unacknowledged_record_ids and unacknowledged_payload_hashes travel together`,
1258
+ );
1259
+ }
1260
+ let uids: string[] = [];
1261
+ let uhashes: string[] = [];
1262
+ if (hasUids) {
1263
+ uids = idList(closing.unacknowledged_record_ids, `${path}.unacknowledged_record_ids`);
1264
+ uhashes = hashList(
1265
+ closing.unacknowledged_payload_hashes,
1266
+ `${path}.unacknowledged_payload_hashes`,
1267
+ uids.length,
1268
+ );
1269
+ }
1270
+ const idSet = new Set(ids);
1271
+ if (uids.some((u) => idSet.has(u))) {
1272
+ throw new ClosingRefused(`${path}: a record is both acknowledged and not`);
1273
+ }
1274
+ return [ids, hashes, uids, uhashes];
1275
+ }
1276
+
1277
+ function entry(kind: string, eventId: string | null = null): AccountEntry {
1278
+ return { kind, eventId };
1279
+ }
1280
+
1281
+ function runAccount(
1282
+ runnerKey: string,
1283
+ agentKey: string | null,
1284
+ held: Held[],
1285
+ ignored: Set<Held>,
1286
+ ): RunAccountLine {
1287
+ const notes: AccountEntry[] = [];
1288
+ if (agentKey === null) notes.push(entry("agent_key_unknown"));
1289
+ const keys = new Set<string>([runnerKey]);
1290
+ if (agentKey !== null) keys.add(agentKey);
1291
+ for (const h of ignored) {
1292
+ if (agentKey !== null && h.publicKey === agentKey) {
1293
+ notes.push(entry("runner_record_under_agent_key", h.eventId));
1294
+ }
1295
+ }
1296
+ const members = held.filter((h) => h.verified && keys.has(h.publicKey) && !ignored.has(h));
1297
+ for (const h of members) {
1298
+ if (h.publicKey === runnerKey && !h.bytesSupplied) {
1299
+ notes.push(entry("runner_record_bytes_withheld", h.eventId));
1300
+ }
1301
+ }
1302
+ const closings = members.filter((h) => h.publicKey === runnerKey && eventType(h) === RUN_CLOSED);
1303
+ const base = { findings: [] as AccountEntry[], closingEventId: null as string | null, reason: null as string | null };
1304
+ if (closings.length === 0) return { ...base, state: "not_closed", notes };
1305
+ if (closings.length > 1) {
1306
+ return {
1307
+ ...base,
1308
+ state: "differs",
1309
+ findings: closings.map((h) => entry("more_than_one_closing_record", h.eventId)),
1310
+ notes,
1311
+ };
1312
+ }
1313
+ const closing = closings[0]!;
1314
+ let ids: string[], hashes: string[] | null, uids: string[], uhashes: string[];
1315
+ try {
1316
+ [ids, hashes, uids, uhashes] = readClosing(closing.record ?? {});
1317
+ } catch (err) {
1318
+ if (!(err instanceof ClosingRefused)) throw err;
1319
+ return {
1320
+ state: "differs",
1321
+ findings: [entry("closing_malformed", closing.eventId)],
1322
+ notes,
1323
+ closingEventId: closing.eventId,
1324
+ reason: err.message,
1325
+ };
1326
+ }
1327
+ if (hashes === null) notes.push(entry("payload_hashes_absent", closing.eventId));
1328
+ const listed = new Map<string, string | null>(ids.map((id, i) => [id, hashes ? hashes[i]! : null]));
1329
+ const unacked = new Map<string, string>(uids.map((id, i) => [id, uhashes[i]!]));
1330
+ const findings: AccountEntry[] = [];
1331
+ const others = members.filter((h) => h !== closing);
1332
+ for (const h of others) {
1333
+ if (listed.has(h.eventId)) {
1334
+ const want = listed.get(h.eventId)!;
1335
+ if (want === null || want === h.payloadHash) continue;
1336
+ } else if (unacked.has(h.eventId) && unacked.get(h.eventId) === h.payloadHash) {
1337
+ continue;
1338
+ }
1339
+ findings.push(entry("record_not_listed", h.eventId));
1340
+ }
1341
+ // Presence is presence: a listed record the account ignores (a runner.*
1342
+ // record under the agent key) is still in the drop.
1343
+ const pool = held.filter(
1344
+ (h) => h.verified && h !== closing && (agentKey === null || keys.has(h.publicKey)),
1345
+ );
1346
+ for (const [eventId, want] of listed) {
1347
+ if (!pool.some((h) => h.eventId === eventId && (want === null || h.payloadHash === want))) {
1348
+ findings.push(entry("listed_record_absent", eventId));
1349
+ }
1350
+ }
1351
+ for (const [eventId, want] of unacked) {
1352
+ if (!pool.some((h) => h.eventId === eventId && h.payloadHash === want)) {
1353
+ notes.push(entry("unacknowledged_record_absent", eventId));
1354
+ }
1355
+ }
1356
+ return {
1357
+ state: findings.length > 0 ? "differs" : "closed",
1358
+ findings,
1359
+ notes,
1360
+ closingEventId: closing.eventId,
1361
+ reason: null,
1362
+ };
1363
+ }
1364
+
1365
+ // ---- Putting it together -----------------------------------------------------------------
1366
+
1367
+ /**
1368
+ * Read every run in a drop: one reading per opening record, and one per closing
1369
+ * record whose runner key opened nothing readable. `adapters` maps a provider
1370
+ * id to a loaded adapter; only ids in `ADAPTER_MODULES` are ever consulted, and
1371
+ * an empty map is a reader with no adapter. Never throws on a pack.
1372
+ */
1373
+ async function onlineLine(
1374
+ opening: Held,
1375
+ att: Attestation | null,
1376
+ policy: unknown,
1377
+ adapters: ReadonlyMap<string, EnclaveAdapter>,
1378
+ rpc: string | null,
1379
+ ): Promise<OnlineLine | null> {
1380
+ if (rpc === null || att === null || !has(ADAPTER_MODULES, att.provider)) return null;
1381
+ const adapter = adapters.get(att.provider);
1382
+ if (adapter === undefined || typeof adapter.online !== "function") return null;
1383
+ const [status, entry] = providerPolicy(policy, att.provider);
1384
+ if (status !== "ok") return null;
1385
+ const content = opening.content;
1386
+ const manifest =
1387
+ content !== null && (await sha256Hex(content)) === att.manifestHash ? content : null;
1388
+ try {
1389
+ const answer: unknown = await adapter.online(att, { policy: entry, at: evaluationTime(opening), manifest, rpc });
1390
+ if (!isPlainObject(answer) || !Array.isArray(answer.kinds)) throw new TypeError("not an OnlineLine");
1391
+ const kinds = answer.kinds as unknown[];
1392
+ const allowed: readonly string[] | undefined =
1393
+ answer.state === "confirmed"
1394
+ ? []
1395
+ : answer.state === "not_checked"
1396
+ ? ONLINE_NOT_CHECKED_KINDS
1397
+ : answer.state === "differs"
1398
+ ? ONLINE_DIFFERS_KINDS
1399
+ : undefined;
1400
+ if (
1401
+ allowed === undefined ||
1402
+ kinds.length !== (answer.state === "confirmed" ? 0 : 1) ||
1403
+ kinds.some((k) => typeof k !== "string" || !allowed.includes(k))
1404
+ ) {
1405
+ throw new TypeError("not an OnlineLine");
1406
+ }
1407
+ return { state: answer.state as OnlineLine["state"], kinds: kinds as string[] };
1408
+ } catch {
1409
+ return { state: "not_checked", kinds: ["adapter_failed"] };
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * What makes a pack the same pack: SHA-256 of the JCS of its wrapper (the bytes
1415
+ * the wrapper's signature covers, and the signature) together with its side
1416
+ * content, each entry as the bytes the kernel binds to `content_hash`. Never
1417
+ * the reader's name for it. `null` where either part cannot be put in that
1418
+ * form: a pack with no identity is never the same as another.
1419
+ */
1420
+ async function packIdentity(pack: RunPack): Promise<string | null> {
1421
+ try {
1422
+ const side: [string, string][] = [];
1423
+ for (const id of sideContentKeys(pack.content).sort()) {
1424
+ const value = sideContentEntry(pack.content, id).value;
1425
+ if (value instanceof Uint8Array) {
1426
+ side.push([id, "b:" + bytesToHex(value)]);
1427
+ } else if (typeof value === "string") {
1428
+ try {
1429
+ side.push([id, "b:" + bytesToHex(hexToBytes(value))]);
1430
+ } catch {
1431
+ side.push([id, "s:" + value]);
1432
+ }
1433
+ } else {
1434
+ return null;
1435
+ }
1436
+ }
1437
+ return await sha256Hex(utf8(jcs({ bundle: pack.bundle, content: side })));
1438
+ } catch {
1439
+ return null;
1440
+ }
1441
+ }
1442
+
1443
+ /** A verified `runner.*` record under one of `keys`. */
1444
+ function runnerRecordUnder(h: Held, keys: ReadonlySet<string>): boolean {
1445
+ return h.verified && keys.has(h.publicKey) && (eventType(h) ?? "").startsWith(RUNNER_PREFIX);
1446
+ }
1447
+
1448
+ export async function readRuns(
1449
+ packs: readonly RunPack[],
1450
+ options: {
1451
+ policy?: unknown;
1452
+ adapters?: ReadonlyMap<string, EnclaveAdapter> | null;
1453
+ /** A chain endpoint for the online line, handed to an adapter that reads one. */
1454
+ rpc?: string | null;
1455
+ /**
1456
+ * For each pack by position, the `stamp_ref` of every stamp whose witness
1457
+ * the caller looked up and the public record confirmed (the on-chain
1458
+ * half's `confirm`). A token is evaluated at a stamp's interval end only
1459
+ * there, and at its own issue time otherwise. Absent: no lookup.
1460
+ */
1461
+ witnessed?: readonly (readonly string[] | null | undefined)[] | null;
1462
+ } = {},
1463
+ ): Promise<RunsReading> {
1464
+ const adapters = options.adapters ?? new Map<string, EnclaveAdapter>();
1465
+ const held: Held[] = [];
1466
+ // A pack handed in again with the same bytes is read once, at its first
1467
+ // position, and the lookups made for every copy count for it
1468
+ // (specs/run-records.md section 3).
1469
+ const lookups: Set<string>[] = [];
1470
+ const first = new Map<string, number>();
1471
+ const keep: number[] = [];
1472
+ for (let i = 0; i < packs.length; i++) {
1473
+ const confirmed = new Set<string>();
1474
+ for (const r of options.witnessed?.[i] ?? []) if (typeof r === "string") confirmed.add(r);
1475
+ const identity = await packIdentity(packs[i]!);
1476
+ if (identity !== null && first.has(identity)) {
1477
+ for (const r of confirmed) lookups[first.get(identity)!]!.add(r);
1478
+ continue;
1479
+ }
1480
+ if (identity !== null) first.set(identity, keep.length);
1481
+ keep.push(i);
1482
+ lookups.push(confirmed);
1483
+ }
1484
+ for (let k = 0; k < keep.length; k++) {
1485
+ held.push(...(await heldRecords(packs[keep[k]!]!, keep[k]!, lookups[k]!)));
1486
+ }
1487
+
1488
+ const openings = held.filter((h) => eventType(h) === RUN_OPENED && h.verified);
1489
+ const read = new Map<Held, Attestation | null>(openings.map((h) => [h, tryRead(h)]));
1490
+ // A key's runner role is proven by an attested opening: one whose
1491
+ // attestation reads and names, as the runner key, the key that signed it.
1492
+ // That opening's word on its agent key is taken; any other record's claim
1493
+ // that a proven runner key is an agent key is only that record's word
1494
+ // (proof#238). The agent keys are decided before any opening is admitted,
1495
+ // and a key that is an agent key and no proven runner key opens no run:
1496
+ // its runner.run_opened record is a runner.* record under an agent key.
1497
+ const attested = openings.filter((h) => {
1498
+ const a = read.get(h);
1499
+ return a !== null && a !== undefined && a.runnerPublicKey === h.publicKey;
1500
+ });
1501
+ const proven = new Set(attested.map((h) => h.publicKey));
1502
+ const agentKeys = new Set<string>();
1503
+ for (const h of attested) {
1504
+ const k = read.get(h)!.agentPublicKey;
1505
+ if (!proven.has(k)) agentKeys.add(k);
1506
+ }
1507
+ // A runner.* record under a run's agent key is not the runner's.
1508
+ const ignored = new Set(held.filter((h) => runnerRecordUnder(h, agentKeys)));
1509
+
1510
+ const runs: RunReading[] = [];
1511
+ const runnerKeys = new Set<string>();
1512
+ for (const opening of openings) {
1513
+ if (ignored.has(opening)) continue;
1514
+ const [enclave, att] = await enclaveLine(opening, held, options.policy, adapters);
1515
+ const agentKey = att !== null ? att.agentPublicKey : null;
1516
+ runnerKeys.add(opening.publicKey);
1517
+ // Where the run's agent key is also a proven runner key, this run still
1518
+ // ignores, and notes, the runner.* records under it.
1519
+ const own = new Set<string>(agentKey !== null && agentKey !== opening.publicKey ? [agentKey] : []);
1520
+ const runIgnored = new Set([...ignored, ...held.filter((h) => !ignored.has(h) && runnerRecordUnder(h, own))]);
1521
+ runs.push({
1522
+ packRef: opening.packRef,
1523
+ packIndex: opening.packIndex,
1524
+ eventId: opening.eventId,
1525
+ runnerPublicKey: opening.publicKey,
1526
+ agentPublicKey: agentKey,
1527
+ enclave,
1528
+ account: runAccount(opening.publicKey, agentKey, held, runIgnored),
1529
+ online: await onlineLine(opening, att, options.policy, adapters, options.rpc ?? null),
1530
+ });
1531
+ }
1532
+ for (const h of held) {
1533
+ if (h.verified && eventType(h) === RUN_CLOSED && !ignored.has(h) && !runnerKeys.has(h.publicKey)) {
1534
+ runnerKeys.add(h.publicKey);
1535
+ runs.push({
1536
+ packRef: h.packRef,
1537
+ packIndex: h.packIndex,
1538
+ eventId: h.eventId,
1539
+ runnerPublicKey: h.publicKey,
1540
+ agentPublicKey: null,
1541
+ enclave: line("not_checked", ["no_opening_record"]),
1542
+ account: runAccount(h.publicKey, null, held, ignored),
1543
+ online: null,
1544
+ });
1545
+ }
1546
+ }
1547
+ return { runs };
1548
+ }
1549
+
1550
+ // ---- What a command prints ------------------------------------------------------
1551
+ //
1552
+ // DRAFT wording: the line's words wait for the glossary. The twin of the
1553
+ // renderers at the foot of `run.py`; the case commands print them beside the
1554
+ // witness line.
1555
+
1556
+ function runRef(r: RunReading): string {
1557
+ const verb = r.enclave.kinds.includes("no_opening_record") ? "closed" : "opened";
1558
+ return `the run ${verb} by record ${r.eventId} in ${r.packRef}`;
1559
+ }
1560
+
1561
+ /** The enclave line, the revocation sentence where a token was checked, and the online line. */
1562
+ export function renderEnclaveLines(r: RunReading): string[] {
1563
+ const e = r.enclave;
1564
+ let s = `enclave attestation: ${e.state}`;
1565
+ if (e.kinds.length > 0) s += ` (${e.kinds.join(", ")})`;
1566
+ s += `, ${runRef(r)}`;
1567
+ if (e.provider !== null) s += `, provider ${e.provider}`;
1568
+ if (e.evaluation === "stamp") {
1569
+ s += `; evaluated at ${e.evaluatedAt}, the interval end of a stamp that holds the opening record, its witness confirmed on the public record`;
1570
+ } else if (e.evaluation === "token_issue_time") {
1571
+ const at = e.evaluatedAt !== null ? ` (${e.evaluatedAt})` : "";
1572
+ s += `; evaluated at the token's own issue time${at}: no stamp whose witness was confirmed says when the opening record was witnessed`;
1573
+ }
1574
+ if (e.notes.length > 0) s += `; note: ${e.notes.join(", ")}`;
1575
+ if (e.reason !== null) s += `; ${e.reason}`;
1576
+ const lines = [s];
1577
+ if (e.tokenChecked) lines.push(REVOCATION_SENTENCE);
1578
+ if (r.online !== null) {
1579
+ let o = `opening on Flare: ${r.online.state}`;
1580
+ if (r.online.kinds.length > 0) o += ` (${r.online.kinds.join(", ")})`;
1581
+ lines.push(o);
1582
+ }
1583
+ return lines;
1584
+ }
1585
+
1586
+ const entries = (xs: readonly AccountEntry[]): string =>
1587
+ xs.map((x) => (x.eventId !== null ? `${x.kind} ${x.eventId}` : x.kind)).join(", ");
1588
+
1589
+ /** The run-account line (specs/run-records.md). */
1590
+ export function renderAccountLine(r: RunReading): string {
1591
+ const a = r.account;
1592
+ let s = `run account: ${a.state}`;
1593
+ if (a.findings.length > 0) s += ` (${entries(a.findings)})`;
1594
+ s += `, ${runRef(r)}`;
1595
+ if (a.notes.length > 0) s += `; note: ${entries(a.notes)}`;
1596
+ if (a.reason !== null) s += `; ${a.reason}`;
1597
+ return s;
1598
+ }