@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.
- package/dist/case-file.js +6 -2
- package/dist/case.d.ts +51 -7
- package/dist/case.js +81 -16
- package/dist/cli.d.ts +31 -8
- package/dist/cli.js +256 -25
- package/dist/compose.js +6 -1
- package/dist/evidence.d.ts +36 -0
- package/dist/evidence.js +104 -3
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +9 -2
- package/dist/message-parts.d.ts +12 -6
- package/dist/message-parts.js +18 -10
- package/dist/verifier.d.ts +54 -2
- package/dist/verifier.js +195 -3
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/package.json +1 -1
- package/src/case-file.ts +5 -2
- package/src/case.ts +101 -15
- package/src/cli.ts +297 -27
- package/src/compose.ts +8 -1
- package/src/evidence.ts +115 -2
- package/src/file-match.ts +461 -0
- package/src/index.ts +43 -0
- package/src/message-parts.ts +18 -10
- package/src/verifier.ts +190 -3
- package/src/witness-cache.ts +306 -0
- package/vectors/README.md +43 -3
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/stamp-on-chain.json +18 -3
- package/vectors/witness-cache.json +1304 -0
|
@@ -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.
|
|
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
|
|
731
|
-
* printed in the scope sentence so a clean verdict
|
|
732
|
-
* `pack_undeclared` is a note only where the
|
|
733
|
-
* where it holds one, it is the exception
|
|
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
|
-
|
|
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
|
|
2002
|
-
* never head a case reading: they are
|
|
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
|
|
2007
|
-
"
|
|
2008
|
-
"
|
|
2009
|
-
"
|
|
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
|
-
|
|
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
|
}
|