@sanning/proof 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/src/claims.ts
ADDED
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
// Six claims, three verdicts, and the person layer (`evidence-bundle.md` section 3).
|
|
2
|
+
//
|
|
3
|
+
// Every detailed kind a reader emits (a bundle problem, a pack grade cause, a declaration
|
|
4
|
+
// problem, a case exception, a record-layer reason) maps here, and only here, to one claim and
|
|
5
|
+
// one state. A verdict is read off the claims and nothing else:
|
|
6
|
+
//
|
|
7
|
+
// - `failed`: a claim is `disproven`. `reason_kind` is `nonconforming` when the only
|
|
8
|
+
// disproven claim is `follows_rules`, otherwise `changed`.
|
|
9
|
+
// - `incomplete`: nothing is disproven and a claim is `not_proven`.
|
|
10
|
+
// - `verified`: every claim in scope is `proven`.
|
|
11
|
+
//
|
|
12
|
+
// The person layer (`renderPerson`) reads the machine layer this module builds, never a
|
|
13
|
+
// kernel object, so a surface holding only `--json` renders the same words. The Python twin
|
|
14
|
+
// is `src/sanning_proof/claims.py`; both are held to one output by `cross-kernel/`.
|
|
15
|
+
|
|
16
|
+
import type { EnvelopeFault } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export const CLAIMS = [
|
|
19
|
+
"signed",
|
|
20
|
+
"unchanged",
|
|
21
|
+
"witnessed",
|
|
22
|
+
"complete",
|
|
23
|
+
"agreed",
|
|
24
|
+
"follows_rules",
|
|
25
|
+
] as const;
|
|
26
|
+
export type Claim = (typeof CLAIMS)[number];
|
|
27
|
+
|
|
28
|
+
export const CLAIM_LABELS: Readonly<Record<Claim, string>> = Object.freeze({
|
|
29
|
+
signed: "Signed",
|
|
30
|
+
unchanged: "Unchanged",
|
|
31
|
+
witnessed: "Witnessed",
|
|
32
|
+
complete: "Complete",
|
|
33
|
+
agreed: "Agreed",
|
|
34
|
+
follows_rules: "Follows the rules",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The question each claim answers, as a page prints it beside the label. */
|
|
38
|
+
export const CLAIM_QUESTIONS: Readonly<Record<Claim, string>> = Object.freeze({
|
|
39
|
+
signed: "Did the key the pack or record names sign it?",
|
|
40
|
+
unchanged: "Do the bytes agree with what was signed?",
|
|
41
|
+
witnessed: "Does a stamp cover each record?",
|
|
42
|
+
complete: "Does the pack hold everything its own signed declaration and its stamp say it holds?",
|
|
43
|
+
agreed: "Do the two sides of each hand-off agree?",
|
|
44
|
+
follows_rules: "Does each record follow the vocabulary it names?",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// `not_in_scope` is never printed.
|
|
48
|
+
export const CLAIM_STATES = ["proven", "disproven", "not_proven", "not_in_scope"] as const;
|
|
49
|
+
export type ClaimState = (typeof CLAIM_STATES)[number];
|
|
50
|
+
export const NOT_PROVEN_REASONS = ["missing", "unreadable", "not_checked"] as const;
|
|
51
|
+
export type NotProvenReason = (typeof NOT_PROVEN_REASONS)[number];
|
|
52
|
+
/** Why a claim is `not_in_scope`: nothing in the pack or case is about it. */
|
|
53
|
+
export const NOT_IN_SCOPE_REASONS = [
|
|
54
|
+
"no_declaration",
|
|
55
|
+
"no_declaration_and_no_handoff",
|
|
56
|
+
"no_handoff",
|
|
57
|
+
"no_agent_records",
|
|
58
|
+
"no_agent_or_handoff_records",
|
|
59
|
+
] as const;
|
|
60
|
+
export type NotInScopeReason = (typeof NOT_IN_SCOPE_REASONS)[number];
|
|
61
|
+
|
|
62
|
+
export const VERDICTS = ["verified", "failed", "incomplete"] as const;
|
|
63
|
+
export type Verdict = (typeof VERDICTS)[number];
|
|
64
|
+
export const REASON_KINDS = ["changed", "nonconforming"] as const;
|
|
65
|
+
export type ReasonKind = (typeof REASON_KINDS)[number];
|
|
66
|
+
|
|
67
|
+
// One display title and one colour per verdict, on every surface.
|
|
68
|
+
export const VERDICT_TITLES: Readonly<Record<Verdict, string>> = Object.freeze({
|
|
69
|
+
verified: "Verified",
|
|
70
|
+
failed: "Failed",
|
|
71
|
+
incomplete: "Incomplete",
|
|
72
|
+
});
|
|
73
|
+
export const VERDICT_COLOURS: Readonly<Record<Verdict, string>> = Object.freeze({
|
|
74
|
+
verified: "green",
|
|
75
|
+
failed: "red",
|
|
76
|
+
incomplete: "neutral",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** The headline the person layer prints, by verdict and reason kind (`status:reason_kind`). */
|
|
80
|
+
export const PERSON_TITLES: Readonly<Record<string, string>> = Object.freeze({
|
|
81
|
+
verified: "Verified",
|
|
82
|
+
"failed:changed": "Failed: do not rely on this evidence.",
|
|
83
|
+
"failed:nonconforming": "Failed: a record does not follow the rules.",
|
|
84
|
+
incomplete: "Incomplete: not everything could be checked.",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** The same headline as a word and a sentence, for a surface that sets them apart. */
|
|
88
|
+
export const PERSON_TITLE_PARTS: Readonly<Record<string, { word: string; sentence: string | null }>> = Object.freeze({
|
|
89
|
+
verified: { word: "Verified", sentence: null },
|
|
90
|
+
"failed:changed": { word: "Failed", sentence: "Do not rely on this evidence." },
|
|
91
|
+
"failed:nonconforming": { word: "Failed", sentence: "A record does not follow the rules." },
|
|
92
|
+
incomplete: { word: "Incomplete", sentence: "Not everything could be checked." },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const SANDBOX_PERSON_LINE = "these records are sandbox records, not production evidence.";
|
|
96
|
+
|
|
97
|
+
const D = "disproven";
|
|
98
|
+
const N = "not_proven";
|
|
99
|
+
|
|
100
|
+
type Mapping = readonly [Claim, ClaimState, NotProvenReason | null];
|
|
101
|
+
|
|
102
|
+
/** kind -> [claim, state, reason]. The reason is set only on `not_proven`. */
|
|
103
|
+
export const KIND_CLAIMS: Readonly<Record<string, Mapping>> = Object.freeze({
|
|
104
|
+
// Case exceptions (section 5.5.3).
|
|
105
|
+
pack_does_not_verify: ["signed", D, null],
|
|
106
|
+
record_envelope_does_not_verify: ["signed", D, null],
|
|
107
|
+
pair_failed: ["agreed", D, null],
|
|
108
|
+
record_does_not_verify_in_its_pack: ["unchanged", D, null],
|
|
109
|
+
record_content_mismatch: ["unchanged", D, null],
|
|
110
|
+
pack_declaration_deviates: ["complete", D, null],
|
|
111
|
+
record_nonconforming: ["follows_rules", D, null],
|
|
112
|
+
record_malformed: ["agreed", N, "unreadable"],
|
|
113
|
+
record_bytes_withheld: ["unchanged", N, "missing"],
|
|
114
|
+
record_bytes_unreadable: ["unchanged", N, "unreadable"],
|
|
115
|
+
record_content_dependent: ["unchanged", N, "not_checked"],
|
|
116
|
+
pack_bundle_content_dependent: ["unchanged", N, "not_checked"],
|
|
117
|
+
pack_declaration_content_dependent: ["unchanged", N, "not_checked"],
|
|
118
|
+
counterpart_absent: ["agreed", N, "missing"],
|
|
119
|
+
counterpart_absent_from_counterparty_records: ["agreed", N, "missing"],
|
|
120
|
+
counterpart_absent_signer_unknown: ["agreed", N, "missing"],
|
|
121
|
+
counterpart_ambiguous: ["agreed", N, "not_checked"],
|
|
122
|
+
counterpart_malformed: ["agreed", N, "unreadable"],
|
|
123
|
+
counterpart_nonconforming: ["agreed", N, "unreadable"],
|
|
124
|
+
record_unclaimed_by_its_pack: ["agreed", N, "not_checked"],
|
|
125
|
+
pack_declaration_unreadable: ["complete", N, "unreadable"],
|
|
126
|
+
pack_undeclared: ["complete", N, "not_checked"],
|
|
127
|
+
pack_body_type_unknown: ["complete", N, "not_checked"],
|
|
128
|
+
pack_holds_no_records: ["complete", N, "missing"],
|
|
129
|
+
pack_key_unreadable: ["signed", N, "unreadable"],
|
|
130
|
+
record_profile_quarantined: ["follows_rules", N, "not_checked"],
|
|
131
|
+
case_holds_no_packs: ["complete", N, "missing"],
|
|
132
|
+
// Pack grade causes (section 5.4.5).
|
|
133
|
+
records_omitted: ["complete", N, "missing"],
|
|
134
|
+
records_surplus: ["complete", N, "not_checked"],
|
|
135
|
+
leaf_count_unreadable: ["complete", N, "unreadable"],
|
|
136
|
+
no_disclosure: ["unchanged", N, "missing"],
|
|
137
|
+
missing_disclosure: ["unchanged", N, "missing"],
|
|
138
|
+
unreadable_disclosure: ["unchanged", N, "unreadable"],
|
|
139
|
+
unaccounted_disclosure: ["complete", N, "not_checked"],
|
|
140
|
+
declaration_deviation: ["complete", D, null],
|
|
141
|
+
declaration_nonconforming: ["follows_rules", D, null],
|
|
142
|
+
declaration_content_dependent: ["unchanged", N, "not_checked"],
|
|
143
|
+
agent_record_nonconforming: ["follows_rules", D, null],
|
|
144
|
+
// `agent_record_incomplete` maps through its record-layer reason (RECORD_REASON_CLAIMS).
|
|
145
|
+
// Bundle problems (bundleProblems below) and the pack's own declaration.
|
|
146
|
+
pack_unreadable: ["signed", N, "unreadable"],
|
|
147
|
+
pack_version_unknown: ["follows_rules", N, "not_checked"],
|
|
148
|
+
pack_body_unreadable: ["complete", N, "unreadable"],
|
|
149
|
+
pack_body_hash_mismatch: ["unchanged", D, null],
|
|
150
|
+
generated_at_nonconforming: ["follows_rules", D, null],
|
|
151
|
+
record_bytes_unbound: ["unchanged", D, null],
|
|
152
|
+
record_bytes_nonconforming: ["follows_rules", D, null],
|
|
153
|
+
record_envelope_nonconforming: ["follows_rules", D, null],
|
|
154
|
+
record_envelope_unreadable: ["signed", N, "unreadable"],
|
|
155
|
+
record_key_unreadable: ["signed", N, "unreadable"],
|
|
156
|
+
record_envelope_version_unknown: ["follows_rules", N, "not_checked"],
|
|
157
|
+
record_not_covered: ["witnessed", D, null],
|
|
158
|
+
stamp_signature_failed: ["signed", D, null],
|
|
159
|
+
stamp_does_not_verify: ["unchanged", D, null],
|
|
160
|
+
stamp_unwitnessed: ["witnessed", N, "missing"],
|
|
161
|
+
records_unstamped: ["witnessed", N, "missing"],
|
|
162
|
+
stamp_absent: ["witnessed", N, "missing"],
|
|
163
|
+
anchor_ref_nonconforming: ["follows_rules", D, null],
|
|
164
|
+
witness_differs: ["witnessed", D, null],
|
|
165
|
+
witness_time_differs: ["witnessed", D, null],
|
|
166
|
+
export_does_not_verify: ["unchanged", D, null],
|
|
167
|
+
export_source_nonconforming: ["follows_rules", D, null],
|
|
168
|
+
attestation_signature_failed: ["signed", D, null],
|
|
169
|
+
attestation_nonconforming: ["follows_rules", D, null],
|
|
170
|
+
export_not_checked: ["complete", N, "not_checked"],
|
|
171
|
+
bundle_does_not_verify: ["unchanged", D, null],
|
|
172
|
+
bundle_not_checked: ["complete", N, "not_checked"],
|
|
173
|
+
records_not_bound: ["unchanged", N, "not_checked"],
|
|
174
|
+
declaration_not_checked: ["complete", N, "not_checked"],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Every reason the record layer can give (agent-events.md section 10.1), to one claim and
|
|
178
|
+
// state. The two reasons that read `incomplete` are not proven; every other reason reads
|
|
179
|
+
// `failed` and disproves `follows_rules`.
|
|
180
|
+
const RECORD_FAILED_REASONS = [
|
|
181
|
+
"event_type_not_in_set",
|
|
182
|
+
"event_schema_misplaced",
|
|
183
|
+
"event_schema_missing",
|
|
184
|
+
"event_schema_invalid",
|
|
185
|
+
"unknown_field",
|
|
186
|
+
"missing_field",
|
|
187
|
+
"bad_value",
|
|
188
|
+
"hidden_field_present",
|
|
189
|
+
"revealed_field_absent",
|
|
190
|
+
"revealed_field_unlisted",
|
|
191
|
+
"capture_not_at_head",
|
|
192
|
+
"file_parts_exceed_parts",
|
|
193
|
+
"unchecked_held_parts_missing",
|
|
194
|
+
"unchecked_held_parts_mismatch",
|
|
195
|
+
"unchecked_held_parts_exceed_file_parts",
|
|
196
|
+
"part_index_out_of_range",
|
|
197
|
+
"closing_count_mismatch",
|
|
198
|
+
"artifact_reason_mismatch",
|
|
199
|
+
"pair_type_mismatch",
|
|
200
|
+
"pair_run_mismatch",
|
|
201
|
+
"pair_call_mismatch",
|
|
202
|
+
"pair_field_mismatch",
|
|
203
|
+
];
|
|
204
|
+
export const RECORD_REASON_CLAIMS: Readonly<Record<string, Mapping>> = Object.freeze({
|
|
205
|
+
...Object.fromEntries(
|
|
206
|
+
RECORD_FAILED_REASONS.map((r) => [r, ["follows_rules", D, null] as Mapping]),
|
|
207
|
+
),
|
|
208
|
+
event_schema_unknown: ["follows_rules", N, "not_checked"],
|
|
209
|
+
listed_record_absent: ["complete", N, "missing"],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/** One plain line per record-layer rule. */
|
|
213
|
+
export const RECORD_REASON_LINES: Readonly<Record<string, string>> = Object.freeze({
|
|
214
|
+
event_type_not_in_set: "Its type is not one its vocabulary defines.",
|
|
215
|
+
event_schema_misplaced: "It names its vocabulary in the wrong place.",
|
|
216
|
+
event_schema_missing: "It does not name the vocabulary it follows.",
|
|
217
|
+
event_schema_invalid: "The vocabulary version it names is not written as a version.",
|
|
218
|
+
event_schema_unknown: "It uses a newer vocabulary version than this verifier knows.",
|
|
219
|
+
unknown_field: "It carries a field its vocabulary does not define.",
|
|
220
|
+
missing_field: "It is missing a field its vocabulary requires.",
|
|
221
|
+
bad_value: "A field holds a value its vocabulary does not allow.",
|
|
222
|
+
hidden_field_present: "It carries a field it says is hidden.",
|
|
223
|
+
revealed_field_absent: "It says a field is revealed and does not carry it.",
|
|
224
|
+
revealed_field_unlisted: "It carries a revealed field it does not list.",
|
|
225
|
+
capture_not_at_head:
|
|
226
|
+
"It says what its adapter captures, and it is not the first record of its run.",
|
|
227
|
+
file_parts_exceed_parts: "It counts more file parts than parts.",
|
|
228
|
+
unchecked_held_parts_missing: "It does not say how many held parts were not checked.",
|
|
229
|
+
unchecked_held_parts_mismatch:
|
|
230
|
+
"Its count of held parts that were not checked does not match its list.",
|
|
231
|
+
unchecked_held_parts_exceed_file_parts: "It counts more unchecked held parts than file parts.",
|
|
232
|
+
part_index_out_of_range: "It names a part the message does not have.",
|
|
233
|
+
closing_count_mismatch: "Its closing counts do not match the records it lists.",
|
|
234
|
+
artifact_reason_mismatch: "Its file reason does not match its file role.",
|
|
235
|
+
pair_type_mismatch: "The record it names as its start is another kind of operation.",
|
|
236
|
+
pair_run_mismatch: "The record it names as its start belongs to another run.",
|
|
237
|
+
pair_call_mismatch: "The record it names as its start belongs to another call.",
|
|
238
|
+
pair_field_mismatch: "It and its start disagree on a field they must share.",
|
|
239
|
+
listed_record_absent: "The run's closing record lists records that are not in the pack.",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/** Every record-layer finding is a note (agent-events.md section 10.1). One plain sentence each. */
|
|
243
|
+
export const NOTE_FINDINGS: Readonly<Record<string, string>> = Object.freeze({
|
|
244
|
+
not_an_agent_record: "the record is not an agent record",
|
|
245
|
+
plumbing: "the record carries fields about the recording software, not the agent",
|
|
246
|
+
content_scrubbed: "the record's content was removed before it was signed",
|
|
247
|
+
start_absent: "the record that started this operation is not held",
|
|
248
|
+
start_ambiguous: "more than one record could be the start of this operation",
|
|
249
|
+
start_not_captured: "end only; start not observed by this adapter",
|
|
250
|
+
start_undeclared: "the record ends an operation and does not name its start",
|
|
251
|
+
unended: "the operation this record starts has no end held",
|
|
252
|
+
ended_more_than_once: "the operation this record starts is ended more than once",
|
|
253
|
+
completed_with_open_operation: "the run closed as completed while an operation in it was open",
|
|
254
|
+
handoff_parts_mismatch: "the two records of this message count its parts differently",
|
|
255
|
+
data_mode_mixed: "the run's records were sent to Sanning in more than one data mode",
|
|
256
|
+
seq_duplicate: "two records in the run hold the same position",
|
|
257
|
+
ran_after_refusal: "the tool ran after its approval refused it",
|
|
258
|
+
seq_gap:
|
|
259
|
+
"the run may be incomplete: no verified record is held at the position before this record",
|
|
260
|
+
closing_not_held: "the run may be incomplete: the promised closing record is not held",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
function n(count: number, one: string, many?: string): string {
|
|
264
|
+
return `${count} ${count === 1 ? one : (many ?? `${one}s`)}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Python's `str()` of a value a template reads, so both kernels fill a gap alike.
|
|
268
|
+
function pyStr(v: unknown): string {
|
|
269
|
+
if (v === null || v === undefined) return "None";
|
|
270
|
+
if (v === true) return "True";
|
|
271
|
+
if (v === false) return "False";
|
|
272
|
+
return String(v);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const A_PACK_CHANGED = "Do not rely on this pack. Ask the sender how it changed after it was signed.";
|
|
276
|
+
const A_RECORD_CHANGED =
|
|
277
|
+
"Do not rely on this record. Ask the sender how it changed after it was signed.";
|
|
278
|
+
const A_NEW_COPY = "Ask the sender for a new copy of the pack.";
|
|
279
|
+
const A_REPORT = "Ask the sender to report the record to whoever makes their recording software.";
|
|
280
|
+
const A_UPDATE = "Update the verifier and run it again.";
|
|
281
|
+
const A_LOGS = "Ask the sender for the logs/ folder.";
|
|
282
|
+
const A_DECLARATION = "Do not rely on this pack. Ask the sender why it differs from its declaration.";
|
|
283
|
+
const A_OWN_LOGS = "Ask the sender for the event bytes from their own logs.";
|
|
284
|
+
const A_COUNTERPART = "Ask the holder of key {key} for their pack.";
|
|
285
|
+
const A_WAIT = "Wait for the stamp, then run it again.";
|
|
286
|
+
const A_WITNESSING = "Wait for the stamp to reach the public record, then run it again.";
|
|
287
|
+
const A_STAMP = "Ask the sender for the pack with its stamp.";
|
|
288
|
+
const A_FIX = "Ask the sender to fix the software that made the pack, then send it again.";
|
|
289
|
+
const A_EXPORT_CHANGED =
|
|
290
|
+
"Do not rely on this export. Ask the issuer how it changed after it was signed.";
|
|
291
|
+
const A_EXPORT_FIX =
|
|
292
|
+
"Ask the issuer to fix the software that made the export, then send it again.";
|
|
293
|
+
const A_HANDOFF = "Do not rely on this hand-off. Ask both sides which message was sent.";
|
|
294
|
+
const A_WITNESS = "Do not rely on this pack. Ask the sender which stamp the public record holds.";
|
|
295
|
+
|
|
296
|
+
// Bytes handed in beside a pack that disagree with what was signed: a disproof that
|
|
297
|
+
// rests on bytes nobody signed reads with these words, whatever its kind.
|
|
298
|
+
const L_OUTSIDE =
|
|
299
|
+
"The bytes handed in beside the pack do not match what was signed. Anyone could have supplied them.";
|
|
300
|
+
const L_NO_STAMP = "No stamp covers {them}.";
|
|
301
|
+
const L_UNALTERED = " Nothing was proven altered.";
|
|
302
|
+
|
|
303
|
+
// kind -> [line, next action]. A line is a template over the problem's own values:
|
|
304
|
+
// `{count}`, `{of}`, `{presented}`, `{key}`; the `{records}` placeholder is the
|
|
305
|
+
// number of records the problems of that kind are about, counted.
|
|
306
|
+
const WORDS: Readonly<Record<string, readonly [string, string]>> = Object.freeze({
|
|
307
|
+
pack_does_not_verify: ["The pack's signature does not match its contents.", A_PACK_CHANGED],
|
|
308
|
+
record_envelope_does_not_verify: ["A record's signature does not verify.", A_RECORD_CHANGED],
|
|
309
|
+
pair_failed: ["The two sides of a hand-off do not agree.", A_HANDOFF],
|
|
310
|
+
record_does_not_verify_in_its_pack: ["A record does not match the pack it is in.", A_RECORD_CHANGED],
|
|
311
|
+
record_content_mismatch: [
|
|
312
|
+
"The event bytes do not match what the record signed.",
|
|
313
|
+
"Do not rely on this record. Ask the sender how the bytes changed.",
|
|
314
|
+
],
|
|
315
|
+
pack_declaration_deviates: [
|
|
316
|
+
"The pack does not hold what its own signed declaration says.",
|
|
317
|
+
A_DECLARATION,
|
|
318
|
+
],
|
|
319
|
+
record_nonconforming: [
|
|
320
|
+
"A hand-off record does not follow the rules of its vocabulary. Nothing was proven altered.",
|
|
321
|
+
A_REPORT,
|
|
322
|
+
],
|
|
323
|
+
record_malformed: ["A record that claims to be a hand-off cannot be read as one.", A_REPORT],
|
|
324
|
+
record_bytes_withheld: [
|
|
325
|
+
"Its record bytes are not in the pack.",
|
|
326
|
+
"Ask the sender for the pack with its record bytes.",
|
|
327
|
+
],
|
|
328
|
+
record_bytes_unreadable: ["Its record bytes cannot be read.", A_NEW_COPY],
|
|
329
|
+
record_content_dependent: [L_OUTSIDE, A_OWN_LOGS],
|
|
330
|
+
pack_bundle_content_dependent: [L_OUTSIDE, A_OWN_LOGS],
|
|
331
|
+
pack_declaration_content_dependent: [L_OUTSIDE, A_OWN_LOGS],
|
|
332
|
+
counterpart_absent: ["The other side of this hand-off is not in the case.", A_COUNTERPART],
|
|
333
|
+
counterpart_absent_from_counterparty_records: [
|
|
334
|
+
"The other side of this hand-off is not in the case.",
|
|
335
|
+
A_COUNTERPART,
|
|
336
|
+
],
|
|
337
|
+
counterpart_absent_signer_unknown: [
|
|
338
|
+
"The other side of this hand-off is not in the case.",
|
|
339
|
+
A_COUNTERPART,
|
|
340
|
+
],
|
|
341
|
+
counterpart_ambiguous: [
|
|
342
|
+
"More than one record could be the other side.",
|
|
343
|
+
"Ask the sender which record is the other side.",
|
|
344
|
+
],
|
|
345
|
+
counterpart_malformed: [
|
|
346
|
+
"The other side cannot be read.",
|
|
347
|
+
"Ask the holder of the other side for a new copy.",
|
|
348
|
+
],
|
|
349
|
+
counterpart_nonconforming: [
|
|
350
|
+
"The other side cannot be read.",
|
|
351
|
+
"Ask the holder of the other side for a new copy.",
|
|
352
|
+
],
|
|
353
|
+
record_unclaimed_by_its_pack: [
|
|
354
|
+
"The pack declares this record and names it in no hand-off.",
|
|
355
|
+
"Ask the sender whether it was meant to be handed over.",
|
|
356
|
+
],
|
|
357
|
+
pack_declaration_unreadable: ["The pack's declaration cannot be read.", A_NEW_COPY],
|
|
358
|
+
pack_undeclared: [
|
|
359
|
+
"The pack does not say what it holds.",
|
|
360
|
+
"Ask the sender for a pack made with a declaration.",
|
|
361
|
+
],
|
|
362
|
+
pack_body_type_unknown: ["This verifier does not read this kind of pack.", A_UPDATE],
|
|
363
|
+
pack_holds_no_records: [
|
|
364
|
+
"The pack holds no records this verifier can read.",
|
|
365
|
+
"Ask the sender for the pack again.",
|
|
366
|
+
],
|
|
367
|
+
pack_key_unreadable: ["The key that signed the pack cannot be read.", A_NEW_COPY],
|
|
368
|
+
record_profile_quarantined: [
|
|
369
|
+
"The record uses a format this verifier does not read yet.",
|
|
370
|
+
A_UPDATE,
|
|
371
|
+
],
|
|
372
|
+
case_holds_no_packs: ["The case holds no packs.", "Add the packs and run it again."],
|
|
373
|
+
records_omitted: [
|
|
374
|
+
"The stamp covers {of_records} and the pack holds {presented}.",
|
|
375
|
+
"Ask the sender for the missing records.",
|
|
376
|
+
],
|
|
377
|
+
records_surplus: [
|
|
378
|
+
"The pack holds more records than its stamp covers.",
|
|
379
|
+
"Ask the sender which records the stamp covers.",
|
|
380
|
+
],
|
|
381
|
+
leaf_count_unreadable: ["The stamp's record count cannot be read.", A_NEW_COPY],
|
|
382
|
+
no_disclosure: ["No event bytes are in the pack.", A_LOGS],
|
|
383
|
+
missing_disclosure: ["The event bytes of {count} of {of_records} are not in the pack.", A_LOGS],
|
|
384
|
+
unreadable_disclosure: [
|
|
385
|
+
"The event bytes of a record cannot be read.",
|
|
386
|
+
"Ask the sender for a new copy of the logs/ folder.",
|
|
387
|
+
],
|
|
388
|
+
unaccounted_disclosure: [
|
|
389
|
+
"The pack holds event bytes that no record names.",
|
|
390
|
+
"Ask the sender what the extra files are.",
|
|
391
|
+
],
|
|
392
|
+
declaration_deviation: [
|
|
393
|
+
"The pack does not hold what its own signed declaration says.",
|
|
394
|
+
A_DECLARATION,
|
|
395
|
+
],
|
|
396
|
+
declaration_content_dependent: [L_OUTSIDE, A_OWN_LOGS],
|
|
397
|
+
declaration_nonconforming: [
|
|
398
|
+
"The pack's signed declaration does not follow its format." + L_UNALTERED,
|
|
399
|
+
A_FIX,
|
|
400
|
+
],
|
|
401
|
+
agent_record_nonconforming: [
|
|
402
|
+
"{records_do} not follow the rules of {their} vocabulary. Nothing was proven altered.",
|
|
403
|
+
A_REPORT,
|
|
404
|
+
],
|
|
405
|
+
pack_unreadable: ["The pack cannot be read, so its signature could not be checked.", A_NEW_COPY],
|
|
406
|
+
pack_version_unknown: ["This verifier does not know this pack's version.", A_UPDATE],
|
|
407
|
+
pack_body_unreadable: ["The pack's contents cannot be read.", A_NEW_COPY],
|
|
408
|
+
pack_body_hash_mismatch: ["The pack's contents do not match the hash it signed.", A_PACK_CHANGED],
|
|
409
|
+
generated_at_nonconforming: [
|
|
410
|
+
"The pack's signed wrapper gives its time in a form that does not follow the format." + L_UNALTERED,
|
|
411
|
+
A_FIX,
|
|
412
|
+
],
|
|
413
|
+
record_bytes_unbound: [
|
|
414
|
+
"A record's bytes do not match what its signature covers.",
|
|
415
|
+
A_RECORD_CHANGED,
|
|
416
|
+
],
|
|
417
|
+
record_envelope_nonconforming: [
|
|
418
|
+
"A record's signed envelope does not follow the envelope format." + L_UNALTERED,
|
|
419
|
+
A_FIX,
|
|
420
|
+
],
|
|
421
|
+
record_envelope_unreadable: ["A record's envelope cannot be read.", A_NEW_COPY],
|
|
422
|
+
record_key_unreadable: ["The key that signed a record cannot be read.", A_NEW_COPY],
|
|
423
|
+
record_envelope_version_unknown: ["This verifier does not know the version of a record's envelope.", A_UPDATE],
|
|
424
|
+
record_bytes_nonconforming: ["A record's signed bytes do not follow the record format." + L_UNALTERED, A_FIX],
|
|
425
|
+
record_not_covered: ["A record is not covered by the stamp it names.", A_RECORD_CHANGED],
|
|
426
|
+
stamp_signature_failed: ["A stamp's signature does not verify.", A_PACK_CHANGED],
|
|
427
|
+
stamp_does_not_verify: ["A stamp does not match the records it covers.", A_PACK_CHANGED],
|
|
428
|
+
stamp_unwitnessed: ["The stamp that covers {them} names no public record.", A_WITNESSING],
|
|
429
|
+
records_unstamped: [L_NO_STAMP, A_WAIT],
|
|
430
|
+
stamp_absent: ["The pack does not hold the stamp that covers {them}.", A_STAMP],
|
|
431
|
+
anchor_ref_nonconforming: [
|
|
432
|
+
"A stamp names its public record in a form that does not follow the format." + L_UNALTERED,
|
|
433
|
+
A_FIX,
|
|
434
|
+
],
|
|
435
|
+
witness_differs: ["The public record holds different bytes for a stamp.", A_WITNESS],
|
|
436
|
+
witness_time_differs: ["The public record dates a stamp differently from the pack.", A_WITNESS],
|
|
437
|
+
export_does_not_verify: ["The export does not match the evidence it carries.", A_EXPORT_CHANGED],
|
|
438
|
+
export_source_nonconforming: [
|
|
439
|
+
"The source bundle the export signed cannot be read as a pack." + L_UNALTERED,
|
|
440
|
+
A_EXPORT_FIX,
|
|
441
|
+
],
|
|
442
|
+
attestation_signature_failed: ["An attestation's signature does not verify.", A_EXPORT_CHANGED],
|
|
443
|
+
attestation_nonconforming: [
|
|
444
|
+
"An attestation's signature verifies and a field in it does not follow the format." +
|
|
445
|
+
L_UNALTERED,
|
|
446
|
+
A_EXPORT_FIX,
|
|
447
|
+
],
|
|
448
|
+
export_not_checked: [
|
|
449
|
+
"Part of the evidence this export carries could not be checked.",
|
|
450
|
+
"Ask the issuer for the export with its source bundle.",
|
|
451
|
+
],
|
|
452
|
+
bundle_does_not_verify: ["The pack does not verify.", A_PACK_CHANGED],
|
|
453
|
+
bundle_not_checked: ["Part of the pack could not be checked.", A_NEW_COPY],
|
|
454
|
+
records_not_bound: [
|
|
455
|
+
"The event bytes of a record were not checked against what it signed.",
|
|
456
|
+
A_LOGS,
|
|
457
|
+
],
|
|
458
|
+
declaration_not_checked: [
|
|
459
|
+
"The pack says its chains are complete and in order, and this verifier did not check that.",
|
|
460
|
+
A_NEW_COPY,
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// The words of an `agent_record_incomplete` cause, by its record-layer reason.
|
|
465
|
+
/**
|
|
466
|
+
* Why a record's envelope does not verify (`ENVELOPE_FAULTS`), to the kind that says so.
|
|
467
|
+
* Read from the envelope layer's own classification, never from its prose.
|
|
468
|
+
*/
|
|
469
|
+
export const ENVELOPE_FAULT_KINDS: Readonly<Record<EnvelopeFault, string>> = Object.freeze({
|
|
470
|
+
not_an_envelope: "record_envelope_unreadable",
|
|
471
|
+
key_unreadable: "record_key_unreadable",
|
|
472
|
+
signature_failed: "record_envelope_does_not_verify",
|
|
473
|
+
payload_unbound: "record_bytes_unbound",
|
|
474
|
+
spec_version_unknown: "record_envelope_version_unknown",
|
|
475
|
+
nonconforming: "record_envelope_nonconforming",
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// The words of a `pair_failed` problem, by the hand-off check that failed
|
|
479
|
+
// (envelope-spec.md section 4.1).
|
|
480
|
+
const PAIR_WORDS: Readonly<Record<string, readonly [string, string]>> = Object.freeze({
|
|
481
|
+
message_hash: ["The two sides of a hand-off signed different messages.", A_HANDOFF],
|
|
482
|
+
sequence: ["The two sides of a hand-off give it different sequence numbers.", A_HANDOFF],
|
|
483
|
+
self_handoff: [
|
|
484
|
+
"One key signed both sides of a hand-off.",
|
|
485
|
+
"Do not rely on this hand-off. Ask the sender who the other side was.",
|
|
486
|
+
],
|
|
487
|
+
counterparty_named: [
|
|
488
|
+
"A side of a hand-off does not name the key that signed the other side.",
|
|
489
|
+
"Do not rely on this hand-off. Ask both sides who they handed it to.",
|
|
490
|
+
],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const INCOMPLETE_WORDS: Readonly<Record<string, readonly [string, string]>> = Object.freeze({
|
|
494
|
+
event_schema_unknown: ["{records_use} a newer vocabulary version than this verifier knows.", A_UPDATE],
|
|
495
|
+
listed_record_absent: [
|
|
496
|
+
"The run's closing record lists {count_records} that {count_are} not in the pack.",
|
|
497
|
+
"Ask the sender for the run's other records.",
|
|
498
|
+
],
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* What a problem is about: `[pack, record]` by position. `[p, null]` is a pack, `[null, null]`
|
|
503
|
+
* the case, and `[p, null, eventId]` a record pack `p` names and does not hold.
|
|
504
|
+
*/
|
|
505
|
+
export type ProblemItem = readonly [number | null, number | null] | readonly [number | null, null, string];
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* One detailed kind, mapped to its claim and state. The optional values are what the
|
|
509
|
+
* person line reads; an absent one is `undefined`.
|
|
510
|
+
*/
|
|
511
|
+
export interface Problem {
|
|
512
|
+
readonly kind: string;
|
|
513
|
+
readonly claim: Claim;
|
|
514
|
+
readonly state: ClaimState;
|
|
515
|
+
readonly reason: NotProvenReason | null;
|
|
516
|
+
readonly items: readonly ProblemItem[];
|
|
517
|
+
readonly count?: number;
|
|
518
|
+
readonly of?: number;
|
|
519
|
+
readonly presented?: number;
|
|
520
|
+
readonly key?: string;
|
|
521
|
+
readonly rule?: string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** A problem as the machine layer carries it. */
|
|
525
|
+
export interface ProblemJson {
|
|
526
|
+
kind: string;
|
|
527
|
+
claim: Claim;
|
|
528
|
+
state: ClaimState;
|
|
529
|
+
items: { pack: number | null; record: number | null; event_id?: string }[];
|
|
530
|
+
reason?: NotProvenReason;
|
|
531
|
+
count?: number;
|
|
532
|
+
of?: number;
|
|
533
|
+
presented?: number;
|
|
534
|
+
key?: string;
|
|
535
|
+
rule?: string;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const PROBLEM_VALUES = ["count", "of", "presented", "key", "rule"] as const;
|
|
539
|
+
|
|
540
|
+
export function problemToJson(p: Problem): ProblemJson {
|
|
541
|
+
const out: ProblemJson = {
|
|
542
|
+
kind: p.kind,
|
|
543
|
+
claim: p.claim,
|
|
544
|
+
state: p.state,
|
|
545
|
+
items: p.items.map((i) => (i.length > 2 ? { pack: i[0], record: i[1], event_id: i[2] } : { pack: i[0], record: i[1] })),
|
|
546
|
+
};
|
|
547
|
+
if (p.reason !== null) out.reason = p.reason;
|
|
548
|
+
for (const name of PROBLEM_VALUES) {
|
|
549
|
+
const value = p[name];
|
|
550
|
+
if (value !== undefined && value !== null) (out as unknown as Record<string, unknown>)[name] = value;
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export interface ProblemValues {
|
|
556
|
+
rule?: string | null;
|
|
557
|
+
count?: number | null;
|
|
558
|
+
of?: number | null;
|
|
559
|
+
presented?: number | null;
|
|
560
|
+
key?: string | null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function lookup(table: Readonly<Record<string, Mapping>>, key: string): Mapping {
|
|
564
|
+
if (!Object.prototype.hasOwnProperty.call(table, key)) throw new Error(`KeyError: '${key}'`);
|
|
565
|
+
return table[key]!;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The `Problem` of a detailed kind. A kind with no mapping throws: a new kind is a
|
|
570
|
+
* decision, never a quiet `verified`. `rule` is a record-layer reason; for
|
|
571
|
+
* `agent_record_incomplete` it decides the claim.
|
|
572
|
+
*/
|
|
573
|
+
export function problem(
|
|
574
|
+
kind: string,
|
|
575
|
+
items: readonly ProblemItem[] = [],
|
|
576
|
+
values: ProblemValues = {},
|
|
577
|
+
): Problem {
|
|
578
|
+
const rule = values.rule ?? undefined;
|
|
579
|
+
let mapping: Mapping;
|
|
580
|
+
if (kind === "agent_record_incomplete") {
|
|
581
|
+
mapping = lookup(RECORD_REASON_CLAIMS, rule ?? "");
|
|
582
|
+
if (mapping[1] === D) throw new Error(`'${rule}' disproves a record`);
|
|
583
|
+
} else if (kind === "agent_record_nonconforming") {
|
|
584
|
+
mapping = lookup(KIND_CLAIMS, kind);
|
|
585
|
+
if (rule !== undefined && lookup(RECORD_REASON_CLAIMS, rule)[1] !== D) {
|
|
586
|
+
throw new Error(`'${rule}' does not disprove a record`);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
mapping = lookup(KIND_CLAIMS, kind);
|
|
590
|
+
}
|
|
591
|
+
const [claim, state, reason] = mapping;
|
|
592
|
+
const out: {
|
|
593
|
+
kind: string;
|
|
594
|
+
claim: Claim;
|
|
595
|
+
state: ClaimState;
|
|
596
|
+
reason: NotProvenReason | null;
|
|
597
|
+
items: readonly ProblemItem[];
|
|
598
|
+
count?: number;
|
|
599
|
+
of?: number;
|
|
600
|
+
presented?: number;
|
|
601
|
+
key?: string;
|
|
602
|
+
rule?: string;
|
|
603
|
+
} = { kind, claim, state, reason, items: [...items] };
|
|
604
|
+
if (values.count !== undefined && values.count !== null) out.count = values.count;
|
|
605
|
+
if (values.of !== undefined && values.of !== null) out.of = values.of;
|
|
606
|
+
if (values.presented !== undefined && values.presented !== null) out.presented = values.presented;
|
|
607
|
+
if (values.key !== undefined && values.key !== null) out.key = values.key;
|
|
608
|
+
if (rule !== undefined && rule !== null) out.rule = rule;
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The same problem about other items. */
|
|
613
|
+
export function withItems(p: Problem, items: readonly ProblemItem[]): Problem {
|
|
614
|
+
return { ...p, items: [...items] };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A disproof that rests on bytes nobody signed: the claim is `not_proven`
|
|
619
|
+
* (`not_checked`), never `disproven`.
|
|
620
|
+
*/
|
|
621
|
+
export function downgraded(p: Problem): Problem {
|
|
622
|
+
if (p.state !== D) return p;
|
|
623
|
+
return { ...p, state: N, reason: "not_checked" };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Disproofs a lookup on the public record makes. They read no bytes handed in beside a pack,
|
|
627
|
+
// so the reading without those bytes, which is offline, never needs to make them too.
|
|
628
|
+
const PUBLIC_RECORD_KINDS: readonly string[] = ["witness_differs", "witness_time_differs"];
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The problems of a pack read with the bytes handed in beside it (`full`), where each
|
|
632
|
+
* disproof the same pack read without them (`signed`) does not also make is `downgraded`.
|
|
633
|
+
* A disproof only the signed reading makes is kept, and so is one the public record makes.
|
|
634
|
+
*/
|
|
635
|
+
export function restOnSigned(full: readonly Problem[], signed: readonly Problem[]): Problem[] {
|
|
636
|
+
const key = (p: Problem) =>
|
|
637
|
+
JSON.stringify([p.kind, p.claim, p.items, p.rule === undefined ? null : p.rule]);
|
|
638
|
+
const held = new Set(signed.filter((p) => p.state === D).map(key));
|
|
639
|
+
const out = full.map((p) =>
|
|
640
|
+
p.state !== D || held.has(key(p)) || PUBLIC_RECORD_KINDS.includes(p.kind) ? p : downgraded(p),
|
|
641
|
+
);
|
|
642
|
+
const kept = new Set(out.filter((p) => p.state === D).map(key));
|
|
643
|
+
for (const p of signed) if (p.state === D && !kept.has(key(p))) out.push(p);
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** One item a claim is about, with the names a person reads. */
|
|
648
|
+
export interface ClaimItem {
|
|
649
|
+
pack: number | null;
|
|
650
|
+
record: number | null;
|
|
651
|
+
pack_ref: string | null;
|
|
652
|
+
event_id: string | null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** One claim as the machine layer carries it. */
|
|
656
|
+
export interface ClaimEntry {
|
|
657
|
+
claim: Claim;
|
|
658
|
+
label: string;
|
|
659
|
+
question: string;
|
|
660
|
+
state: ClaimState;
|
|
661
|
+
items: ClaimItem[];
|
|
662
|
+
problems: ProblemJson[];
|
|
663
|
+
/** Why it is not proven, or, for `not_in_scope`, why nothing is about it. */
|
|
664
|
+
reason?: NotProvenReason | NotInScopeReason;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** An item's `[pack_ref, event_id]`, the names a person reads. */
|
|
668
|
+
export type Labels = (pack: number | null, record: number | null) => readonly [string | null, string | null];
|
|
669
|
+
|
|
670
|
+
const NO_LABELS: Labels = () => [null, null];
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The claims, in claim order, as the machine layer carries them, each with its label and
|
|
674
|
+
* its question. A claim is in scope when it is named in `inScope` or a problem is about
|
|
675
|
+
* it; `signed`, `unchanged` and `witnessed` always are. A claim not in scope is left out,
|
|
676
|
+
* unless `notInScope` gives the reason, which it then carries as `not_in_scope`.
|
|
677
|
+
*
|
|
678
|
+
* `labels(pack, record)` gives an item's `[pack_ref, event_id]`; a record a pack names and
|
|
679
|
+
* does not hold carries its own `event_id`.
|
|
680
|
+
*/
|
|
681
|
+
export function claimsOf(
|
|
682
|
+
problems: readonly Problem[],
|
|
683
|
+
inScope: Iterable<string> = [],
|
|
684
|
+
options: { notInScope?: Readonly<Partial<Record<Claim, NotInScopeReason>>>; labels?: Labels } = {},
|
|
685
|
+
): ClaimEntry[] {
|
|
686
|
+
const scope = new Set<string>(["signed", "unchanged", "witnessed", ...inScope]);
|
|
687
|
+
const label = options.labels ?? NO_LABELS;
|
|
688
|
+
const out: ClaimEntry[] = [];
|
|
689
|
+
for (const claim of CLAIMS) {
|
|
690
|
+
const mine = problems.filter((p) => p.claim === claim);
|
|
691
|
+
if (mine.length === 0 && !scope.has(claim)) {
|
|
692
|
+
const why = options.notInScope?.[claim];
|
|
693
|
+
if (why !== undefined) {
|
|
694
|
+
out.push({
|
|
695
|
+
claim,
|
|
696
|
+
label: CLAIM_LABELS[claim],
|
|
697
|
+
question: CLAIM_QUESTIONS[claim],
|
|
698
|
+
state: "not_in_scope",
|
|
699
|
+
reason: why,
|
|
700
|
+
items: [],
|
|
701
|
+
problems: [],
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
let state: ClaimState;
|
|
707
|
+
if (mine.some((p) => p.state === D)) state = D;
|
|
708
|
+
else if (mine.length > 0) state = N;
|
|
709
|
+
else state = "proven";
|
|
710
|
+
const shown = mine.filter((p) => p.state === state);
|
|
711
|
+
const items: ClaimItem[] = [];
|
|
712
|
+
for (const p of shown) {
|
|
713
|
+
for (const i of p.items) {
|
|
714
|
+
const [pack, record] = [i[0], i[1]];
|
|
715
|
+
const [packRef, eventId] = label(pack, record);
|
|
716
|
+
const item: ClaimItem = {
|
|
717
|
+
pack,
|
|
718
|
+
record,
|
|
719
|
+
pack_ref: packRef,
|
|
720
|
+
event_id: i.length > 2 ? (i[2] as string) : eventId,
|
|
721
|
+
};
|
|
722
|
+
if (
|
|
723
|
+
!items.some(
|
|
724
|
+
(x) =>
|
|
725
|
+
x.pack === item.pack &&
|
|
726
|
+
x.record === item.record &&
|
|
727
|
+
x.pack_ref === item.pack_ref &&
|
|
728
|
+
x.event_id === item.event_id,
|
|
729
|
+
)
|
|
730
|
+
) {
|
|
731
|
+
items.push(item);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const entry: ClaimEntry = {
|
|
736
|
+
claim,
|
|
737
|
+
label: CLAIM_LABELS[claim],
|
|
738
|
+
question: CLAIM_QUESTIONS[claim],
|
|
739
|
+
state,
|
|
740
|
+
items,
|
|
741
|
+
problems: mine.map(problemToJson),
|
|
742
|
+
};
|
|
743
|
+
if (state === N) entry.reason = shown[0]!.reason ?? undefined;
|
|
744
|
+
out.push(entry);
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** A verdict: `reason_kind` only on `failed`. */
|
|
750
|
+
export interface VerdictJson {
|
|
751
|
+
status: Verdict;
|
|
752
|
+
reason_kind?: ReasonKind;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** The verdict of a set of claims: failed, then incomplete, then verified. */
|
|
756
|
+
export function verdictOf(claims: readonly { claim: string; state: string }[]): VerdictJson {
|
|
757
|
+
const disproven = claims.filter((c) => c.state === D).map((c) => c.claim);
|
|
758
|
+
if (disproven.length > 0) {
|
|
759
|
+
const onlyRules = disproven.every((c) => c === "follows_rules");
|
|
760
|
+
return { status: "failed", reason_kind: onlyRules ? "nonconforming" : "changed" };
|
|
761
|
+
}
|
|
762
|
+
if (claims.some((c) => c.state === N)) return { status: "incomplete" };
|
|
763
|
+
return { status: "verified" };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
type Item = { pack: number | null; record: number | null; event_id?: string | null };
|
|
767
|
+
|
|
768
|
+
/** A record the pack holds, or one it names and does not hold. */
|
|
769
|
+
function isRecord(i: Item): boolean {
|
|
770
|
+
return i.record !== null || (i.event_id !== undefined && i.event_id !== null);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* What a line is about, as `[count, noun]` parts: records, then packs, then the case
|
|
775
|
+
* (`[null, "the case"]`).
|
|
776
|
+
*/
|
|
777
|
+
function scopeParts(items: readonly Item[]): [number | null, string][] {
|
|
778
|
+
const records = items.filter((i) => isRecord(i)).length;
|
|
779
|
+
const packs = items.filter((i) => !isRecord(i) && i.pack !== null).length;
|
|
780
|
+
const theCase = items.some((i) => !isRecord(i) && i.pack === null);
|
|
781
|
+
const parts: [number | null, string][] = [];
|
|
782
|
+
if (records) parts.push([records, records === 1 ? "record" : "records"]);
|
|
783
|
+
if (packs) parts.push([packs, packs === 1 ? "pack" : "packs"]);
|
|
784
|
+
if (theCase) parts.push([null, "the case"]);
|
|
785
|
+
return parts;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function scopeText(items: readonly Item[]): string {
|
|
789
|
+
return scopeParts(items)
|
|
790
|
+
.map(([count, noun]) => (count === null ? noun : `${count} ${noun}`))
|
|
791
|
+
.join(" and ");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function recordsIn(problems: readonly ProblemJson[]): number {
|
|
795
|
+
const seen: [number | null, number | null, string | null][] = [];
|
|
796
|
+
for (const p of problems) {
|
|
797
|
+
for (const i of p.items) {
|
|
798
|
+
const eventId = i.event_id ?? null;
|
|
799
|
+
if (isRecord(i) && !seen.some(([a, b, c]) => a === i.pack && b === i.record && c === eventId)) {
|
|
800
|
+
seen.push([i.pack, i.record, eventId]);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return seen.length;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function wordsOf(p: ProblemJson): readonly [string, string] {
|
|
808
|
+
if (p.kind !== "agent_record_incomplete" && p.state === N && lookup(KIND_CLAIMS, p.kind)[1] === D) {
|
|
809
|
+
// Downgraded: the disproof rests on bytes handed in beside the pack.
|
|
810
|
+
return [L_OUTSIDE, A_OWN_LOGS];
|
|
811
|
+
}
|
|
812
|
+
if (
|
|
813
|
+
p.kind === "pair_failed" &&
|
|
814
|
+
p.rule !== undefined &&
|
|
815
|
+
Object.prototype.hasOwnProperty.call(PAIR_WORDS, p.rule)
|
|
816
|
+
) {
|
|
817
|
+
return PAIR_WORDS[p.rule]!;
|
|
818
|
+
}
|
|
819
|
+
const table = p.kind === "agent_record_incomplete" ? INCOMPLETE_WORDS : WORDS;
|
|
820
|
+
const key = p.kind === "agent_record_incomplete" ? (p.rule ?? "") : p.kind;
|
|
821
|
+
if (!Object.prototype.hasOwnProperty.call(table, key)) throw new Error(`KeyError: '${key}'`);
|
|
822
|
+
return table[key]!;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function fill(template: string, p: ProblemJson, group: readonly ProblemJson[]): string {
|
|
826
|
+
const records = recordsIn(group) || 1;
|
|
827
|
+
const count = p.count !== undefined ? p.count : records;
|
|
828
|
+
const of = p.of;
|
|
829
|
+
const values: Record<string, string> = {
|
|
830
|
+
count: pyStr(count),
|
|
831
|
+
count_records: n(count, "record"),
|
|
832
|
+
count_are: count === 1 ? "is" : "are",
|
|
833
|
+
of_records: typeof of === "number" && Number.isInteger(of) ? n(of, "record") : pyStr(of),
|
|
834
|
+
presented: pyStr(p.presented),
|
|
835
|
+
key: pyStr(p.key),
|
|
836
|
+
records_do: n(records, "agent record") + (records === 1 ? " does" : " do"),
|
|
837
|
+
their: records === 1 ? "its" : "their",
|
|
838
|
+
them: records === 1 ? "it" : "them",
|
|
839
|
+
records_use: n(records, "record") + (records === 1 ? " uses" : " use"),
|
|
840
|
+
};
|
|
841
|
+
return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => {
|
|
842
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) throw new Error(`KeyError: '${name}'`);
|
|
843
|
+
return values[name]!;
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function groupKey(p: ProblemJson): string {
|
|
848
|
+
return p.kind !== "agent_record_incomplete" ? p.kind : `${p.kind}:${pyStr(p.rule)}`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The reasons behind one claim's line: one sentence per kind, counted over every
|
|
853
|
+
* problem of that kind, then each record-layer rule it breaks, each said once.
|
|
854
|
+
*/
|
|
855
|
+
function why(problems: readonly ProblemJson[]): string {
|
|
856
|
+
const sentences: string[] = [];
|
|
857
|
+
const kinds: string[] = [];
|
|
858
|
+
for (const p of problems) {
|
|
859
|
+
const key = groupKey(p);
|
|
860
|
+
if (!kinds.includes(key)) kinds.push(key);
|
|
861
|
+
}
|
|
862
|
+
for (const key of kinds) {
|
|
863
|
+
const group = problems.filter((p) => groupKey(p) === key);
|
|
864
|
+
const counted =
|
|
865
|
+
group[0]!.kind === "agent_record_nonconforming" || group[0]!.kind === "agent_record_incomplete";
|
|
866
|
+
for (const p of counted ? group.slice(0, 1) : group) {
|
|
867
|
+
const line = fill(wordsOf(p)[0], p, group);
|
|
868
|
+
if (!sentences.includes(line)) sentences.push(line);
|
|
869
|
+
}
|
|
870
|
+
if (group[0]!.kind === "agent_record_nonconforming") {
|
|
871
|
+
for (const p of group) {
|
|
872
|
+
const rule = Object.prototype.hasOwnProperty.call(RECORD_REASON_LINES, p.rule ?? "")
|
|
873
|
+
? RECORD_REASON_LINES[p.rule ?? ""]
|
|
874
|
+
: undefined;
|
|
875
|
+
if (rule !== undefined && !sentences.includes(rule)) sentences.push(rule);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return sentences.join(" ");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** One note for a record-layer finding carried by `records` records. */
|
|
883
|
+
export function renderFindingNote(finding: string, records: number): string {
|
|
884
|
+
const text = Object.prototype.hasOwnProperty.call(NOTE_FINDINGS, finding)
|
|
885
|
+
? NOTE_FINDINGS[finding]!
|
|
886
|
+
: finding.split("_").join(" ");
|
|
887
|
+
return `${text} (${n(records, "record")}).`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** One note as the machine layer carries it: its kind and its sentence. */
|
|
891
|
+
export interface NoteEntry {
|
|
892
|
+
kind: string;
|
|
893
|
+
text: string;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** One person line, laid out: the claim, its state, what it is about and why. */
|
|
897
|
+
export interface LinePart {
|
|
898
|
+
claim: Claim;
|
|
899
|
+
state: ClaimState;
|
|
900
|
+
reason: NotProvenReason | NotInScopeReason | null;
|
|
901
|
+
count: number | null;
|
|
902
|
+
noun: string;
|
|
903
|
+
scope: string;
|
|
904
|
+
why: string;
|
|
905
|
+
text: string;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** The person layer: what a reader is told, from the machine layer alone. */
|
|
909
|
+
export interface Person {
|
|
910
|
+
title: string;
|
|
911
|
+
title_parts: { word: string; sentence: string | null };
|
|
912
|
+
lines: string[];
|
|
913
|
+
line_parts: LinePart[];
|
|
914
|
+
next: string | null;
|
|
915
|
+
notes: string[];
|
|
916
|
+
note_parts: NoteEntry[];
|
|
917
|
+
sandbox: string | null;
|
|
918
|
+
witness: string;
|
|
919
|
+
witness_state: string;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Every note kind, in the order notes are printed: first what can change what a reader does
|
|
923
|
+
// (a run or chain that may be incomplete, a tool that ran after a refusal, content that did
|
|
924
|
+
// not travel), then what only describes the records.
|
|
925
|
+
export const NOTE_ORDER: readonly string[] = Object.freeze([
|
|
926
|
+
"ran_after_refusal",
|
|
927
|
+
"completed_with_open_operation",
|
|
928
|
+
"pack_undeclared",
|
|
929
|
+
"disclosure_did_not_travel",
|
|
930
|
+
"content_withheld_by_declaration",
|
|
931
|
+
"pair_record_withheld",
|
|
932
|
+
"role_step_withheld",
|
|
933
|
+
"other_declaration_held",
|
|
934
|
+
"other_declaration_withheld",
|
|
935
|
+
"chain_claim_withheld",
|
|
936
|
+
"chains_unreadable",
|
|
937
|
+
"chain-link-dangling",
|
|
938
|
+
"chain-gap",
|
|
939
|
+
"chain-second-root",
|
|
940
|
+
"seq_gap",
|
|
941
|
+
"closing_not_held",
|
|
942
|
+
"start_absent",
|
|
943
|
+
"unended",
|
|
944
|
+
"ended_more_than_once",
|
|
945
|
+
"start_ambiguous",
|
|
946
|
+
"start_undeclared",
|
|
947
|
+
"seq_duplicate",
|
|
948
|
+
"handoff_parts_mismatch",
|
|
949
|
+
"data_mode_mixed",
|
|
950
|
+
"case_index_unreadable",
|
|
951
|
+
"case_index_pack_missing",
|
|
952
|
+
"case_index_pack_unlisted",
|
|
953
|
+
"case_index_file_missing",
|
|
954
|
+
"case_index_file_unlisted",
|
|
955
|
+
"case_index_file_differs",
|
|
956
|
+
"case_index_file_too_large",
|
|
957
|
+
"case_index_names_absent",
|
|
958
|
+
"witness_cache_refused",
|
|
959
|
+
"start_not_captured",
|
|
960
|
+
"plumbing",
|
|
961
|
+
"content_scrubbed",
|
|
962
|
+
"not_an_agent_record",
|
|
963
|
+
"co_signatures_present",
|
|
964
|
+
]);
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* One note as the machine layer carries it. A kind with no place in `NOTE_ORDER` throws:
|
|
968
|
+
* a new note is a decision about where it is printed.
|
|
969
|
+
*/
|
|
970
|
+
export function note(kind: string, text: string): NoteEntry {
|
|
971
|
+
if (!NOTE_ORDER.includes(kind)) throw new Error(`KeyError: '${kind}' has no place in NOTE_ORDER`);
|
|
972
|
+
return { kind, text };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** The notes in `NOTE_ORDER`, each kind's notes in the order they came. */
|
|
976
|
+
export function orderedNotes(notes: readonly NoteEntry[]): NoteEntry[] {
|
|
977
|
+
return notes
|
|
978
|
+
.map((n, i) => [n, i] as const)
|
|
979
|
+
.sort(([a, i], [b, j]) => NOTE_ORDER.indexOf(a.kind) - NOTE_ORDER.indexOf(b.kind) || i - j)
|
|
980
|
+
.map(([n]) => ({ kind: n.kind, text: n.text }));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* The person layer, from the machine layer alone: the title, one line per claim that is
|
|
985
|
+
* disproven or not proven (disproven first, then the claim order), one next action from the
|
|
986
|
+
* first line, the notes, the sandbox line and the witness line.
|
|
987
|
+
*
|
|
988
|
+
* Each string member has a structured twin for a surface that lays the words out:
|
|
989
|
+
* `title_parts` (the verdict word and the sentence), `line_parts` (each line's claim,
|
|
990
|
+
* state, reason, what it is about and why), `note_parts` (each note's kind) and
|
|
991
|
+
* `witness_state`. The text in a twin is the string member's, word for word.
|
|
992
|
+
*/
|
|
993
|
+
export function renderPerson(
|
|
994
|
+
verdict: VerdictJson,
|
|
995
|
+
claims: readonly ClaimEntry[],
|
|
996
|
+
notes: readonly NoteEntry[],
|
|
997
|
+
environmentMarker: string | null,
|
|
998
|
+
witness: string,
|
|
999
|
+
witnessState: string,
|
|
1000
|
+
): Person {
|
|
1001
|
+
const titleKey =
|
|
1002
|
+
verdict.reason_kind !== undefined ? `${verdict.status}:${verdict.reason_kind}` : verdict.status;
|
|
1003
|
+
if (!Object.prototype.hasOwnProperty.call(PERSON_TITLES, titleKey)) {
|
|
1004
|
+
throw new Error(`KeyError: '${titleKey}'`);
|
|
1005
|
+
}
|
|
1006
|
+
const title = PERSON_TITLES[titleKey]!;
|
|
1007
|
+
const parts = PERSON_TITLE_PARTS[titleKey]!;
|
|
1008
|
+
const shown = [...claims.filter((c) => c.state === D), ...claims.filter((c) => c.state === N)];
|
|
1009
|
+
const lines: string[] = [];
|
|
1010
|
+
const lineParts: LinePart[] = [];
|
|
1011
|
+
for (const c of shown) {
|
|
1012
|
+
const said = c.state === D ? "disproven" : "not proven";
|
|
1013
|
+
const mine = c.problems.filter((p) => p.state === c.state);
|
|
1014
|
+
const scope = scopeParts(c.items);
|
|
1015
|
+
const scopeLine = scopeText(c.items);
|
|
1016
|
+
const because = why(mine);
|
|
1017
|
+
const text = `${CLAIM_LABELS[c.claim]}: ${said} for ${scopeLine}. ${because}`;
|
|
1018
|
+
lines.push(text);
|
|
1019
|
+
lineParts.push({
|
|
1020
|
+
claim: c.claim,
|
|
1021
|
+
state: c.state,
|
|
1022
|
+
reason: c.reason ?? null,
|
|
1023
|
+
count: scope[0]![0],
|
|
1024
|
+
noun: scope[0]![1],
|
|
1025
|
+
scope: scopeLine,
|
|
1026
|
+
why: because,
|
|
1027
|
+
text,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
let next: string | null = null;
|
|
1031
|
+
if (shown.length > 0) {
|
|
1032
|
+
const first = shown[0]!.problems.filter((p) => p.state === shown[0]!.state)[0]!;
|
|
1033
|
+
next = fill(wordsOf(first)[1], first, [first]);
|
|
1034
|
+
}
|
|
1035
|
+
const noteParts = notes.map((x) => ({ kind: x.kind, text: x.text.endsWith(".") ? x.text : `${x.text}.` }));
|
|
1036
|
+
return {
|
|
1037
|
+
title,
|
|
1038
|
+
title_parts: { word: parts.word, sentence: parts.sentence },
|
|
1039
|
+
lines,
|
|
1040
|
+
line_parts: lineParts,
|
|
1041
|
+
next,
|
|
1042
|
+
notes: noteParts.map((x) => x.text),
|
|
1043
|
+
note_parts: noteParts,
|
|
1044
|
+
sandbox: environmentMarker !== null ? SANDBOX_PERSON_LINE : null,
|
|
1045
|
+
witness,
|
|
1046
|
+
witness_state: witnessState,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** The person layer as the text report prints it, first, before the detail. */
|
|
1051
|
+
export function personText(person: Person): string[] {
|
|
1052
|
+
const out = [person.title, ...person.lines];
|
|
1053
|
+
if (person.next !== null) out.push(`Next: ${person.next}`);
|
|
1054
|
+
for (const note of person.notes) out.push(`Note: ${note}`);
|
|
1055
|
+
if (person.sandbox !== null) out.push(`Sandbox: ${person.sandbox}`);
|
|
1056
|
+
out.push(`Witness: ${person.witness}`);
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The bundle problems: what a bundle verifier's result says, as data. Read from the result's
|
|
1061
|
+
// own fields, never from its prose.
|
|
1062
|
+
const KNOWN_BODIES = [
|
|
1063
|
+
"sanning.anchor.trace/v3",
|
|
1064
|
+
"sanning.stamp.trace/v1",
|
|
1065
|
+
"sanning.stamp.evidence/v2",
|
|
1066
|
+
"sanning.evidence.export/v2",
|
|
1067
|
+
];
|
|
1068
|
+
|
|
1069
|
+
type Loose = Record<string, unknown>;
|
|
1070
|
+
|
|
1071
|
+
function field(obj: unknown, name: string): unknown {
|
|
1072
|
+
return obj !== null && typeof obj === "object" ? (obj as Loose)[name] : undefined;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function isFalse(obj: unknown, name: string): boolean {
|
|
1076
|
+
return field(obj, name) === false;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function listOf(v: unknown): unknown[] {
|
|
1080
|
+
return Array.isArray(v) ? v : [];
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* An export's own problems: each attestation whose signature fails or whose signed fields
|
|
1085
|
+
* break their format, a signed source that cannot be read, then anything else that keeps
|
|
1086
|
+
* the export from verifying.
|
|
1087
|
+
*/
|
|
1088
|
+
function exportProblems(exp: unknown, status: unknown, whole: readonly ProblemItem[]): Problem[] {
|
|
1089
|
+
const out: Problem[] = [];
|
|
1090
|
+
const attestations = listOf(field(exp, "attestations"));
|
|
1091
|
+
const fieldError = (a: unknown) => {
|
|
1092
|
+
const v = field(a, "fieldError");
|
|
1093
|
+
return v === undefined ? null : v;
|
|
1094
|
+
};
|
|
1095
|
+
for (const a of attestations) {
|
|
1096
|
+
if (isFalse(a, "signatureOk")) out.push(problem("attestation_signature_failed", whole));
|
|
1097
|
+
else if (fieldError(a) !== null) out.push(problem("attestation_nonconforming", whole));
|
|
1098
|
+
}
|
|
1099
|
+
if (field(exp, "sourceUnreadable") === true) {
|
|
1100
|
+
// The issuer signed a source no reader can read: whatever else then fails to bind
|
|
1101
|
+
// follows from it.
|
|
1102
|
+
out.push(problem("export_source_nonconforming", whole));
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
const other =
|
|
1106
|
+
isFalse(exp, "sourceLinkageOk") ||
|
|
1107
|
+
isFalse(exp, "verdictAgreementOk") ||
|
|
1108
|
+
field(exp, "sourceStatus") === "failed" ||
|
|
1109
|
+
attestations.some(
|
|
1110
|
+
(a) => isFalse(a, "ok") && field(a, "signatureOk") === true && fieldError(a) === null,
|
|
1111
|
+
);
|
|
1112
|
+
if (status === "failed" && (other || out.length === 0)) {
|
|
1113
|
+
out.push(problem("export_does_not_verify", whole));
|
|
1114
|
+
} else if (status !== "failed" && out.length === 0) {
|
|
1115
|
+
out.push(problem("export_not_checked", whole));
|
|
1116
|
+
}
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Every problem the bundle verifier found, mapped. `pack` is the pack's position in a
|
|
1122
|
+
* case (`null` for a single pack read on its own). The result is read structurally, so a
|
|
1123
|
+
* field a result does not carry is never read as a failure; a result whose status is not
|
|
1124
|
+
* `verified` always yields at least one problem, and a `failed` one a disproof.
|
|
1125
|
+
*/
|
|
1126
|
+
export function bundleProblems(result: unknown, pack: number | null = null): Problem[] {
|
|
1127
|
+
const status = field(result, "status");
|
|
1128
|
+
const out: Problem[] = [];
|
|
1129
|
+
const whole: ProblemItem[] = [[pack, null]];
|
|
1130
|
+
const add = (kind: string, items: readonly ProblemItem[] = whole) => {
|
|
1131
|
+
out.push(problem(kind, items));
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const timeMalformed = field(result, "generatedAtMalformed") === true;
|
|
1135
|
+
if (field(result, "unreadable") === true) {
|
|
1136
|
+
const holds = !isFalse(result, "signatureOk") && !isFalse(result, "bodyHashOk");
|
|
1137
|
+
if (field(result, "versionUnknown") === true) add("pack_version_unknown");
|
|
1138
|
+
else add(holds ? "pack_body_unreadable" : "pack_unreadable");
|
|
1139
|
+
if (timeMalformed) add("generated_at_nonconforming");
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
// No key to check the signature against: not a signature that fails, and the body is still
|
|
1143
|
+
// read, so a disproof inside it stands.
|
|
1144
|
+
const keyUnreadable = field(result, "keyUnreadable") === true;
|
|
1145
|
+
if (keyUnreadable) add("pack_key_unreadable");
|
|
1146
|
+
else if (isFalse(result, "signatureOk")) add("pack_does_not_verify");
|
|
1147
|
+
if (isFalse(result, "bodyHashOk")) add("pack_body_hash_mismatch");
|
|
1148
|
+
if (out.length > (keyUnreadable ? 1 : 0)) return out;
|
|
1149
|
+
// The wrapper's time breaks its format, and the body is still read: a disproof inside it
|
|
1150
|
+
// stands beside this one.
|
|
1151
|
+
if (timeMalformed) add("generated_at_nonconforming");
|
|
1152
|
+
|
|
1153
|
+
const bodyType = field(result, "bodyType");
|
|
1154
|
+
const events = listOf(field(result, "events"));
|
|
1155
|
+
const recordItems: ProblemItem[] =
|
|
1156
|
+
events.length > 0 ? events.map((_, i) => [pack, i] as ProblemItem) : whole;
|
|
1157
|
+
if (typeof bodyType === "string" && !KNOWN_BODIES.includes(bodyType)) {
|
|
1158
|
+
add("pack_body_type_unknown");
|
|
1159
|
+
return out;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const checkpoints = listOf(field(result, "checkpoints"));
|
|
1163
|
+
for (const cp of checkpoints) {
|
|
1164
|
+
if (!isFalse(cp, "ok")) continue;
|
|
1165
|
+
if (isFalse(cp, "onChainOk")) add("witness_differs");
|
|
1166
|
+
else if (isFalse(cp, "envelopeOk")) add("stamp_signature_failed");
|
|
1167
|
+
else if (field(cp, "anchorRefMalformed") === true) add("anchor_ref_nonconforming");
|
|
1168
|
+
else add("stamp_does_not_verify");
|
|
1169
|
+
}
|
|
1170
|
+
const stamps = listOf(field(result, "stamps"));
|
|
1171
|
+
for (const st of stamps) {
|
|
1172
|
+
if (isFalse(st, "ok")) {
|
|
1173
|
+
const rollup = field(field(st, "onChain"), "rollup");
|
|
1174
|
+
if (rollup === "mismatch") add("witness_differs");
|
|
1175
|
+
else if (field(st, "anchorRefMalformed") === true) add("anchor_ref_nonconforming");
|
|
1176
|
+
else add("stamp_does_not_verify");
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// A stamp or checkpoint nobody has witnessed yet (no anchor_ref) leaves `witnessed` not
|
|
1180
|
+
// proven, as does an anchor trace with no checkpoint at all.
|
|
1181
|
+
if (
|
|
1182
|
+
stamps.some((st) => field(st, "anchorRef") === null) ||
|
|
1183
|
+
checkpoints.some((cp) => field(cp, "anchorRef") === null)
|
|
1184
|
+
) {
|
|
1185
|
+
add("stamp_unwitnessed", recordItems);
|
|
1186
|
+
} else if (bodyType === "sanning.anchor.trace/v3" && checkpoints.length === 0) {
|
|
1187
|
+
add("records_unstamped", recordItems);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
events.forEach((ev, index) => {
|
|
1191
|
+
const at: ProblemItem[] = [[pack, index]];
|
|
1192
|
+
if (isFalse(ev, "ok") || isFalse(ev, "contentOk")) {
|
|
1193
|
+
// Every check on the record speaks for itself. A claim one check cannot settle (a key
|
|
1194
|
+
// or an envelope that cannot be read, a version this verifier does not know, bytes
|
|
1195
|
+
// nobody signed) is not proven on that claim only, and never hides a disproof another
|
|
1196
|
+
// check makes.
|
|
1197
|
+
const said = out.length;
|
|
1198
|
+
const listed = field(ev, "envelopeFaults");
|
|
1199
|
+
const fault = field(ev, "envelopeFault");
|
|
1200
|
+
const faults: EnvelopeFault[] =
|
|
1201
|
+
Array.isArray(listed) && listed.length > 0
|
|
1202
|
+
? (listed as EnvelopeFault[])
|
|
1203
|
+
: typeof fault === "string"
|
|
1204
|
+
? [fault as EnvelopeFault]
|
|
1205
|
+
: [];
|
|
1206
|
+
for (const f of faults) add(ENVELOPE_FAULT_KINDS[f], at);
|
|
1207
|
+
if (isFalse(ev, "envelopeOk") && faults.length === 0) add("record_envelope_does_not_verify", at);
|
|
1208
|
+
if (isFalse(ev, "payloadBindingOk") && !faults.includes("payload_unbound")) add("record_bytes_unbound", at);
|
|
1209
|
+
if (field(ev, "recordNotJson") === true) add("record_bytes_nonconforming", at);
|
|
1210
|
+
if (isFalse(ev, "contentOk")) {
|
|
1211
|
+
const mismatch = problem("record_content_mismatch", at);
|
|
1212
|
+
// Bytes nobody signed that disagree prove nothing changed.
|
|
1213
|
+
out.push(field(ev, "contentUnsigned") === true ? downgraded(mismatch) : mismatch);
|
|
1214
|
+
}
|
|
1215
|
+
if (field(ev, "stampAbsent") === true) add("stamp_absent", at);
|
|
1216
|
+
else if (isFalse(ev, "inclusionOk") || isFalse(ev, "checkpointBound")) add("record_not_covered", at);
|
|
1217
|
+
// A retired field or present bytes that cannot be read fail the record in its own
|
|
1218
|
+
// right wherever its key can be read: no claim left not proven, and no bytes beside
|
|
1219
|
+
// the pack, stand in for that disproof.
|
|
1220
|
+
const ownForm =
|
|
1221
|
+
field(ev, "ownFormFailed") === true &&
|
|
1222
|
+
(faults.length === 0 || lookup(KIND_CLAIMS, ENVELOPE_FAULT_KINDS[faults[0]])[1] === D);
|
|
1223
|
+
if (out.length === said || (ownForm && !out.slice(said).some((p) => p.state === D))) {
|
|
1224
|
+
add("record_does_not_verify_in_its_pack", at);
|
|
1225
|
+
}
|
|
1226
|
+
} else if (
|
|
1227
|
+
ev !== null &&
|
|
1228
|
+
typeof ev === "object" &&
|
|
1229
|
+
"payloadBindingOk" in ev &&
|
|
1230
|
+
(ev as Loose).payloadBindingOk === null
|
|
1231
|
+
) {
|
|
1232
|
+
add("record_bytes_withheld", at);
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
if (bodyType === "sanning.stamp.trace/v1" && stamps.length === 0) add("pack_holds_no_records");
|
|
1237
|
+
if (bodyType === "sanning.stamp.evidence/v2" && events.length === 0) add("pack_holds_no_records");
|
|
1238
|
+
const exp = field(result, "export");
|
|
1239
|
+
// An export reads on its own status, apart from the failure its wrapper's time makes.
|
|
1240
|
+
const exportStatus = timeMalformed ? (field(exp, "status") ?? status) : status;
|
|
1241
|
+
const others = out.filter((p) => p.kind !== "generated_at_nonconforming");
|
|
1242
|
+
if (exp !== undefined && exp !== null && exportStatus !== "verified" && others.length === 0) {
|
|
1243
|
+
out.push(...exportProblems(exp, exportStatus, whole));
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Fail closed: a status the problems above do not account for still reads as itself.
|
|
1247
|
+
// A failure that rests only on bytes nobody signed is accounted for, not proven.
|
|
1248
|
+
if (status === "failed" && !out.some((p) => p.state === D || (p.state === N && lookup(KIND_CLAIMS, p.kind)[1] === D))) {
|
|
1249
|
+
add("bundle_does_not_verify");
|
|
1250
|
+
}
|
|
1251
|
+
else if (status !== "verified" && out.length === 0) add("bundle_not_checked");
|
|
1252
|
+
return out;
|
|
1253
|
+
}
|