@sanning/proof 0.13.0 → 0.14.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.
@@ -0,0 +1,264 @@
1
+ // The witnessed-time cache: what a pack remembers about its witness, unverified
2
+ // (evidence-bundle.md 5.6.6, v1.25, A30, proof#24).
3
+ //
4
+ // The TypeScript half of `src/sanning_proof/witness_cache.py`. Both kernels are
5
+ // held to one table, `ts/vectors/witness-cache.json`, so neither can pass by
6
+ // agreeing with the other.
7
+ //
8
+ // WHAT THE CACHE IS. A file beside the pack's `bundle.json`,
9
+ // `witness-cache.json`, and never a member of the bundle. Its one list today,
10
+ // `witnessed`, gives for each stamp the block height and block time of the
11
+ // witness transaction, as the builder of the pack fetched them. Nobody signs
12
+ // it, and anyone holding the pack can edit it. EVERY MEMBER OF THE FILE IS
13
+ // UNVERIFIED BY CONSTRUCTION, and the name carries that warning to every field
14
+ // added to it later.
15
+ //
16
+ // Because it is a separate file, a reader before v1.25 never opens it: it lists
17
+ // it as a file outside every pack and reads the pack exactly as it would
18
+ // without it. A `cache` member written INSIDE `bundle.json` is an unknown
19
+ // wrapper field, counted in the signed scope by every reader, this one
20
+ // included.
21
+ //
22
+ // WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO.
23
+ //
24
+ // - `readWitnessCache` reads the file's bytes against the signed bundle beside
25
+ // it. It is pure, and it is never an input to the bundle verdict, the pack
26
+ // grade or the case reading: neither `verifyEvidenceBundle` nor `readCase` is
27
+ // ever handed the file.
28
+ // - `checkWitnessCache` looks each cached time up on the public record, where a
29
+ // caller asked for a lookup. A lookup that answers with a different block is
30
+ // a finding, reported on the witness line as `differs`. A lookup that does
31
+ // not settle leaves the cached value labelled not checked.
32
+ // - Nothing here returns a time a reader may rely on. The only time this module
33
+ // ever reports as the public record's is one a gateway returned, and a
34
+ // gateway is named, never trusted (6).
35
+ //
36
+ // A file that is absent, that is not JSON under the reader's rule, whose
37
+ // `cache_version` is not `WITNESS_CACHE_VERSION`, whose `witnessed` is not a
38
+ // list, or whose entry is not well formed, reads as no cache for what it could
39
+ // not say. It is never a finding and never moves anything.
40
+ import { jsonDepthExceeds } from "./case-file.js";
41
+ import { jcs, jcsParse } from "./verifier.js";
42
+ /**
43
+ * The last second a block time can name and still be printed the same way by
44
+ * both kernels: 9999-12-31T23:59:59Z. A larger value is a cache entry this
45
+ * reader cannot print, and reads as none.
46
+ */
47
+ export const MAX_BLOCK_TIME = 253402300799;
48
+ /** The file's name, beside `bundle.json` in the pack's folder (5.6.1). */
49
+ export const WITNESS_CACHE_NAME = "witness-cache.json";
50
+ /**
51
+ * The dispatch key: a file whose `cache_version` is absent or any other value
52
+ * reads as no cache.
53
+ */
54
+ export const WITNESS_CACHE_VERSION = "sanning.witness-cache/v1";
55
+ /**
56
+ * How deep `[` and `{` may nest in the file before it is refused unread: the
57
+ * limit `bundle.json` and `contents.json` are read under.
58
+ */
59
+ export const MAX_WITNESS_CACHE_DEPTH = 512;
60
+ // The bodies that carry `stamps[]`, the only witnesses the cache speaks for.
61
+ const STAMP_BODIES = new Set(["sanning.stamp.trace/v1", "sanning.stamp.evidence/v2"]);
62
+ const STAMP_REF = /^[0-9a-f]{64}$/;
63
+ // The one witness kind a time is looked up for, and the only ids the lookup
64
+ // sends: the same rule the byte lookup applies (evidence-bundle 5.1.1, v1.6).
65
+ const ADAPTER_KIND = "arweave";
66
+ const ARWEAVE_ID = /^[A-Za-z0-9_-]{43}$/;
67
+ // A GraphQL answer to one query for one transaction is a few hundred bytes. A
68
+ // deeper answer is not one, and refusing it before any parser recurses keeps
69
+ // the two kernels on one reading of it.
70
+ const MAX_ANSWER_DEPTH = 16;
71
+ function isObject(v) {
72
+ return v !== null && typeof v === "object" && !Array.isArray(v);
73
+ }
74
+ // A member the object itself holds, never one inherited from its prototype.
75
+ function own(o, key) {
76
+ return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
77
+ }
78
+ function blockNumber(value, upper) {
79
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
80
+ return null;
81
+ if (value < 0 || value > upper)
82
+ return null;
83
+ return value;
84
+ }
85
+ // `stamp_ref` to `[kind, id]` for every stamp in the signed body that names a
86
+ // witness. A `stamp_ref` spelled on two stamps names neither.
87
+ function stampWitnesses(bundle) {
88
+ const out = new Map();
89
+ if (typeof bundle.body_type !== "string" || !STAMP_BODIES.has(bundle.body_type))
90
+ return out;
91
+ const body = bundle.body;
92
+ const stamps = isObject(body) ? body.stamps : undefined;
93
+ if (!Array.isArray(stamps))
94
+ return out;
95
+ const seen = new Set();
96
+ for (const stamp of stamps) {
97
+ if (!isObject(stamp))
98
+ continue;
99
+ const ref = stamp.stamp_ref;
100
+ if (typeof ref !== "string" || !STAMP_REF.test(ref))
101
+ continue;
102
+ if (seen.has(ref)) {
103
+ out.delete(ref);
104
+ continue;
105
+ }
106
+ seen.add(ref);
107
+ const anchor = stamp.anchor_ref;
108
+ if (!isObject(anchor))
109
+ continue;
110
+ if (typeof anchor.kind === "string" && typeof anchor.id === "string") {
111
+ out.set(ref, [anchor.kind, anchor.id]);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ // The file's JSON, read under the rule `contents.json` is read under (5.6.2):
117
+ // strict UTF-8, no byte-order mark, no `NaN` or `Infinity`, no member named
118
+ // twice, and nothing nested past `MAX_WITNESS_CACHE_DEPTH`. `undefined` for
119
+ // anything else.
120
+ function readFile(raw) {
121
+ if (!(raw instanceof Uint8Array))
122
+ return undefined;
123
+ if (jsonDepthExceeds(raw, MAX_WITNESS_CACHE_DEPTH))
124
+ return undefined;
125
+ try {
126
+ return jcsParse(raw);
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ /**
133
+ * The cached witness times the `witness-cache.json` bytes `raw` give for the
134
+ * signed `bundle` beside them, in the order the file lists them. `raw` is
135
+ * `null` where the pack has no such file. Pure, and never throws. The file
136
+ * reads only where it is a JSON object whose `cache_version` is
137
+ * `WITNESS_CACHE_VERSION` and whose `witnessed` is a list. An entry reads only
138
+ * where it is an object whose `stamp_ref` is 64 lowercase hex characters naming
139
+ * exactly one stamp of the signed body that has an `anchor_ref`, whose
140
+ * `block_height` is a non-negative safe integer, and whose `block_time` is a
141
+ * whole number of seconds since 1970-01-01T00:00:00Z from 0 to
142
+ * `MAX_BLOCK_TIME`. A `stamp_ref` two entries name reads for neither.
143
+ * Everything else reads as no cache for the stamp it would have spoken for.
144
+ */
145
+ export function readWitnessCache(bundle, raw) {
146
+ if (!isObject(bundle))
147
+ return [];
148
+ const cache = readFile(raw);
149
+ if (!isObject(cache))
150
+ return [];
151
+ if (own(cache, "cache_version") !== WITNESS_CACHE_VERSION)
152
+ return [];
153
+ const entries = own(cache, "witnessed");
154
+ if (!Array.isArray(entries))
155
+ return [];
156
+ const witnesses = stampWitnesses(bundle);
157
+ const read = [];
158
+ const named = new Map();
159
+ for (const entry of entries) {
160
+ if (!isObject(entry))
161
+ continue;
162
+ const ref = own(entry, "stamp_ref");
163
+ if (typeof ref !== "string" || !witnesses.has(ref))
164
+ continue;
165
+ named.set(ref, (named.get(ref) ?? 0) + 1);
166
+ const height = blockNumber(own(entry, "block_height"), Number.MAX_SAFE_INTEGER);
167
+ const time = blockNumber(own(entry, "block_time"), MAX_BLOCK_TIME);
168
+ if (height === null || time === null)
169
+ continue;
170
+ const [kind, id] = witnesses.get(ref);
171
+ read.push({ stampRef: ref, blockHeight: height, blockTime: time, witnessKind: kind, witnessId: id });
172
+ }
173
+ return read.filter((c) => named.get(c.stampRef) === 1);
174
+ }
175
+ /** A block time as RFC 3339 UTC to the second, the way both kernels print it: `2026-09-08T12:10:07Z`. */
176
+ export function formatBlockTime(seconds) {
177
+ return new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
178
+ }
179
+ /** `block 1996701 at 2026-09-08T12:10:07Z`. */
180
+ export function renderWitnessTime(blockHeight, blockTime) {
181
+ return `block ${blockHeight} at ${formatBlockTime(blockTime)}`;
182
+ }
183
+ /**
184
+ * The request body the lookup sends: one GraphQL query for one transaction's
185
+ * block, as JCS, so both kernels send the same bytes.
186
+ */
187
+ export function witnessTimeQuery(witnessId) {
188
+ return jcs({ query: `query{transaction(id:"${witnessId}"){block{height timestamp}}}` });
189
+ }
190
+ function readAnswer(raw) {
191
+ if (jsonDepthExceeds(raw, MAX_ANSWER_DEPTH))
192
+ return null;
193
+ let node;
194
+ try {
195
+ node = jcsParse(raw);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ for (const key of ["data", "transaction", "block"]) {
201
+ if (!isObject(node))
202
+ return null;
203
+ node = node[key];
204
+ }
205
+ if (!isObject(node))
206
+ return null;
207
+ const height = blockNumber(node.height, Number.MAX_SAFE_INTEGER);
208
+ const time = blockNumber(node.timestamp, Number.MAX_SAFE_INTEGER);
209
+ if (height === null || time === null)
210
+ return null;
211
+ return [height, time];
212
+ }
213
+ async function ask(cached, gateway, fetchImpl) {
214
+ const unreachable = { gateway, outcome: "unreachable", blockHeight: null, blockTime: null };
215
+ const url = `${gateway.replace(/\/+$/, "")}/graphql`;
216
+ let raw;
217
+ try {
218
+ const resp = await fetchImpl(url, {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json" },
221
+ body: witnessTimeQuery(cached.witnessId),
222
+ });
223
+ if (!resp.ok)
224
+ return unreachable;
225
+ raw = new Uint8Array(await resp.arrayBuffer());
226
+ }
227
+ catch {
228
+ return unreachable;
229
+ }
230
+ const block = readAnswer(raw);
231
+ if (block === null)
232
+ return unreachable;
233
+ const [height, time] = block;
234
+ const same = height === cached.blockHeight && time === cached.blockTime;
235
+ return { gateway, outcome: same ? "matches" : "differs", blockHeight: height, blockTime: time };
236
+ }
237
+ /**
238
+ * Look each cached time up on every gateway, in order. With no gateways or no
239
+ * `fetchImpl`, nothing is looked up and every time is `not_checked`. A witness
240
+ * that is not `arweave`, or whose id is not a 43-character base64url
241
+ * transaction id, is never looked up.
242
+ */
243
+ export async function checkWitnessCache(cached, gateways, fetchImpl) {
244
+ const out = [];
245
+ for (const c of cached) {
246
+ if (gateways.length === 0 ||
247
+ fetchImpl === undefined ||
248
+ c.witnessKind !== ADAPTER_KIND ||
249
+ !ARWEAVE_ID.test(c.witnessId)) {
250
+ out.push({ cached: c, outcome: "not_checked", answers: [] });
251
+ continue;
252
+ }
253
+ const answers = [];
254
+ for (const gw of gateways)
255
+ answers.push(await ask(c, gw, fetchImpl));
256
+ const outcome = answers.some((a) => a.outcome === "differs")
257
+ ? "differs"
258
+ : answers.some((a) => a.outcome === "matches")
259
+ ? "matches"
260
+ : "not_checked";
261
+ out.push({ cached: c, outcome, answers });
262
+ }
263
+ return out;
264
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/case-file.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * network. How the files were listed and hashed is the caller's.
26
26
  */
27
27
 
28
- import { jcsParse } from "./verifier.js";
28
+ import { DuplicateKeyError, jcsParse } from "./verifier.js";
29
29
 
30
30
  /** The index's dispatch key (5.6.2). An absent or unknown value makes the index unreadable. */
31
31
  export const CASE_FILE_VERSION = "sanning.case-file/v1";
@@ -237,7 +237,10 @@ function readIndex(raw: Uint8Array): ReadIndex {
237
237
  let value: unknown;
238
238
  try {
239
239
  value = jcsParse(raw);
240
- } catch {
240
+ } catch (e) {
241
+ // Named, because an index that lists one pack twice under one member name
242
+ // reads as whichever copy came last (I-JSON, proof#144).
243
+ if (e instanceof DuplicateKeyError) throw new Unreadable(e.message);
241
244
  throw new Unreadable("not readable as JSON");
242
245
  }
243
246
  if (!isObject(value)) throw new Unreadable("not a JSON object");
package/src/case.ts CHANGED
@@ -260,7 +260,7 @@ import {
260
260
  readHandoffs,
261
261
  recordIsMalformed,
262
262
  } from "./handoff.js";
263
- import { verifyEvidenceBundle } from "./evidence.js";
263
+ import { coSignaturesPresent, verifyEvidenceBundle } from "./evidence.js";
264
264
  import type { EvidenceBundleResult } from "./evidence.js";
265
265
  import {
266
266
  type PackGrade,
@@ -271,6 +271,7 @@ import {
271
271
  gradePack,
272
272
  } from "./grading.js";
273
273
  import type { SideContentMap } from "./side-content.js";
274
+ import { renderWitnessTime } from "./witness-cache.js";
274
275
 
275
276
  // ---- The vocabulary ---------------------------------------------------------
276
277
 
@@ -727,13 +728,25 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
727
728
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
728
729
 
729
730
  /**
730
- * The two NOTEs (5.5.3 v1.20): findings on the reading with no contribution,
731
- * printed in the scope sentence so a clean verdict never hides either.
732
- * `pack_undeclared` is a note only where the reading holds no hand-off record;
733
- * where it holds one, it is the exception of that name.
731
+ * The three NOTEs (5.5.3 v1.20, extended proof#148): findings on the reading
732
+ * with no contribution, printed in the scope sentence so a clean verdict
733
+ * never hides any of them. `pack_undeclared` is a note only where the
734
+ * reading holds no hand-off record; where it holds one, it is the exception
735
+ * of that name.
734
736
  */
735
737
  export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
736
- export const CASE_NOTE_KINDS = [PACK_UNDECLARED, CONTENT_WITHHELD_BY_DECLARATION] as const;
738
+ /**
739
+ * A pack's wrapper carries a present `co_signatures` (evidence-bundle.md §2,
740
+ * decided by Phil 2026-09-24 on proof#148). The field verifies nothing and
741
+ * sits outside the wrapper's signed scope, so the case reading names it
742
+ * rather than presenting it as evidence, and it contributes no verdict.
743
+ */
744
+ export const CO_SIGNATURES_PRESENT = "co_signatures_present";
745
+ export const CASE_NOTE_KINDS = [
746
+ PACK_UNDECLARED,
747
+ CONTENT_WITHHELD_BY_DECLARATION,
748
+ CO_SIGNATURES_PRESENT,
749
+ ] as const;
737
750
  export type CaseNoteKind = (typeof CASE_NOTE_KINDS)[number];
738
751
 
739
752
  /**
@@ -1330,6 +1343,16 @@ export async function readCase(
1330
1343
  }
1331
1344
  holderKeys.add(holder);
1332
1345
 
1346
+ if (coSignaturesPresent(pack.bundle)) {
1347
+ // A NOTE, with no contribution (5.5.3 v1.20, proof#148): the wrapper's
1348
+ // co_signatures verifies nothing and sits outside its signed scope, so
1349
+ // the case reading names it rather than moving anything on its account.
1350
+ caseNotes.push({
1351
+ position,
1352
+ note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref },
1353
+ });
1354
+ }
1355
+
1333
1356
  const bodyType = bodyTypeOf(pack.bundle);
1334
1357
  const records = packRecords(pack.bundle, pack.content);
1335
1358
  // The per-record gradings below are joined to `records` BY INDEX, so a
@@ -1939,6 +1962,11 @@ export function renderCaseNote(note: CaseNote): string {
1939
1962
  );
1940
1963
  case PACK_UNDECLARED:
1941
1964
  return `${note.packRef} does not say what it holds`;
1965
+ case CO_SIGNATURES_PRESENT:
1966
+ return (
1967
+ `${note.packRef} carries unverified co-signatures in its wrapper; ` +
1968
+ "they are not part of the evidence"
1969
+ );
1942
1970
  default:
1943
1971
  return (note as CaseNote).kind;
1944
1972
  }
@@ -1998,17 +2026,18 @@ export const VERDICT_DISPLAY_TITLES: Readonly<Record<string, string>> = {
1998
2026
 
1999
2027
  /**
2000
2028
  * What a reader may rely on, for each of the six words a case reading reaches
2001
- * (5.5.6 v1.20, A26), the same for every drop. `pending` and `not_found`
2002
- * never head a case reading: they are witness states.
2029
+ * (5.5.6 v1.20, A26; the `verified` text at v1.28, A35), the same for every
2030
+ * drop. `pending` and `not_found` never head a case reading: they are
2031
+ * witness states. `verified` is the base sentence, printed alone where the
2032
+ * drop holds no hand-off record; a caller reads `caseReliance` for
2033
+ * `verified`, never this mapping directly.
2003
2034
  */
2004
2035
  export const CASE_RELIANCE: Readonly<Record<CaseVerdict, string>> = {
2005
2036
  verified:
2006
- "Every pack in this drop is genuine and unaltered and agrees with its " +
2007
- "own signed declaration. Every hand-off with both sides here is " +
2008
- "intact. Every other record is one its holder signed as a step. " +
2009
- "Nothing here could not be checked. This was read offline, on this " +
2010
- "device. It does not say these packs are everything the parties " +
2011
- "recorded.",
2037
+ "Every pack in this drop is genuine, unaltered, and matches its own " +
2038
+ "signed declaration. Every record in it was signed by its holder's " +
2039
+ "key. This was read offline, on this device. It does not say this " +
2040
+ "drop holds everything that was recorded.",
2012
2041
  partial:
2013
2042
  "Nothing here was proven wrong, and a check could not be completed: a " +
2014
2043
  "pack names no witness, or a pack in a hand-off reading does not say " +
@@ -2031,6 +2060,37 @@ export const CASE_RELIANCE: Readonly<Record<CaseVerdict, string>> = {
2031
2060
  "exception before relying on any of it.",
2032
2061
  };
2033
2062
 
2063
+ /**
2064
+ * The clause a `verified` drop that holds a hand-off record gains, inserted
2065
+ * after `CASE_RELIANCE`'s first sentence (5.5.6, A35). Never printed alone,
2066
+ * and never where `handoffRecords` is zero: most drops hold one agent's
2067
+ * pack and no hand-off to say anything about.
2068
+ */
2069
+ export const CASE_RELIANCE_HANDOFF_CLAUSE =
2070
+ "Every hand-off with both sides in this drop is intact.";
2071
+
2072
+ /**
2073
+ * What a reader may rely on for one case reading (5.5.6 v1.28, A35), byte
2074
+ * for byte the text `case_reliance`'s Python sibling returns.
2075
+ *
2076
+ * For every word but `verified` this is `CASE_RELIANCE` unchanged. For
2077
+ * `verified`, the hand-off clause is inserted after the first sentence
2078
+ * where `handoffRecords` is not zero, and left out where it is zero: the
2079
+ * base sentence never claims a hand-off the drop does not hold.
2080
+ */
2081
+ export function caseReliance(verdict: CaseVerdict, handoffRecords: number): string {
2082
+ if (verdict === "verified" && handoffRecords > 0) {
2083
+ return (
2084
+ "Every pack in this drop is genuine, unaltered, and matches its own " +
2085
+ "signed declaration. Every hand-off with both sides in this drop is " +
2086
+ "intact. Every record in it was signed by its holder's key. This " +
2087
+ "was read offline, on this device. It does not say this drop holds " +
2088
+ "everything that was recorded."
2089
+ );
2090
+ }
2091
+ return CASE_RELIANCE[verdict];
2092
+ }
2093
+
2034
2094
  /** The three witness states, and how each is printed (5.5.6, D7). No other. */
2035
2095
  export const WITNESS_STATES = {
2036
2096
  confirmed: "Confirmed on the public record",
@@ -2050,6 +2110,18 @@ export interface WitnessLine {
2050
2110
  state: WitnessState;
2051
2111
  reasons: string[];
2052
2112
  services: string[];
2113
+ /**
2114
+ * Witness times a pack caches that no lookup settled, as `[blockHeight,
2115
+ * blockTime]`, distinct and in time order (5.6.6, v1.25, A30). Printed labelled
2116
+ * not checked, and never as a time a reader may rely on. Absent reads as none.
2117
+ */
2118
+ cached?: [number, number][];
2119
+ /**
2120
+ * Witness times the public record returned where they matched a pack's
2121
+ * cache, distinct and in time order. The gateway's answer, named with the
2122
+ * service, never the cache's value. Absent reads as none.
2123
+ */
2124
+ recorded?: [number, number][];
2053
2125
  }
2054
2126
 
2055
2127
  /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
@@ -2060,9 +2132,23 @@ export const WITNESS_NOT_LOOKED_UP: Readonly<WitnessLine> = Object.freeze({
2060
2132
  });
2061
2133
 
2062
2134
  /** The witness line as both kernels print it. */
2135
+ /** How a witness time a pack caches is printed where nothing checked it (5.5.6, 5.6.6, v1.25, A30). */
2136
+ export const WITNESS_TIME_CACHED = "the pack dates the witness, not checked";
2137
+ /** How a witness time the public record returned is printed where it matched the cache. */
2138
+ export const WITNESS_TIME_RECORDED = "the public record dates the witness";
2139
+
2140
+ function witnessTimes(times: readonly [number, number][]): string {
2141
+ return times.map(([h, t]) => renderWitnessTime(h, t)).join(", ");
2142
+ }
2143
+
2063
2144
  export function renderWitnessLine(line: WitnessLine): string {
2064
2145
  let text = `Witness: ${WITNESS_STATES[line.state]}`;
2065
- if (line.reasons.length > 0) text += `: ${line.reasons.join("; ")}`;
2146
+ const parts = [...line.reasons];
2147
+ if (line.cached && line.cached.length > 0) parts.push(`${WITNESS_TIME_CACHED}: ${witnessTimes(line.cached)}`);
2148
+ if (line.recorded && line.recorded.length > 0) {
2149
+ parts.push(`${WITNESS_TIME_RECORDED}: ${witnessTimes(line.recorded)}`);
2150
+ }
2151
+ if (parts.length > 0) text += `: ${parts.join("; ")}`;
2066
2152
  if (line.services.length > 0) text += ` (answered: ${line.services.join(", ")})`;
2067
2153
  return text;
2068
2154
  }