@sanning/proof 0.19.0 → 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.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +343 -810
  9. package/dist/case.js +693 -1151
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +848 -1322
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +132 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +597 -580
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/src/case.ts CHANGED
@@ -1,245 +1,64 @@
1
1
  /**
2
- * One verdict for a CASE, across packs and across organizations (anchor#198).
3
- *
4
- * The TypeScript half of `src/sanning_proof/case.py`. Both kernels are held to
5
- * the same corpus set, `test-vectors/case/`, so neither can pass by agreeing
6
- * with the other.
7
- *
8
- * WHY THIS LAYER EXISTS, AND WHAT IT IS THE CLOSURE FOR
9
- * =====================================================
10
- *
11
- * `envelope-spec.md` 4.1 reads records. `evidence-bundle.md` 5.4 reads one pack
12
- * against its own declaration. Neither can answer the question an audit team
13
- * actually asks, and 4.1 (v1.10, A15) and 5.4.2 rule 7 (v1.10) now say so in
14
- * these words:
15
- *
16
- * **A pack a party assembles about itself is not self-proving about what
17
- * that party LEFT OUT.**
18
- *
19
- * `handoffs[]` MAY be empty (5.4.1), so a holder declares a record in
20
- * `records[]` and claims it nowhere: rule 2 cannot reach it because it is
21
- * declared, rule 7 cannot reach it because it is unclaimed, and the pack reads
22
- * `verified` with no deviation, which is the same reading the honest pack gets.
23
- * The corpus pins that pack as EXPECTED
24
- * (`test-vectors/declaration/declaration-unclaimed-record-01`) rather than
25
- * leaving it to be rediscovered. Six repairs treated a property of one-sided
26
- * evidence as a defect in a rule (proof#137, ratified by Phil 2026-09-22), and
27
- * the omission happens BEFORE any reader runs, so no rule at either of those
28
- * layers reaches it.
29
- *
30
- * **This module is the closure, and the reason it can be is that it reads
31
- * several parties' packs at once.** A `handoff.sent` with no counterpart among
32
- * the records of the case is always NAMED, and when a pack signed by the key
33
- * that record names is in the case, the reading says that pack holds no
34
- * counterpart. No party can make a missing counterpart read clean.
35
- *
36
- * What it does NOT establish is that a party which presented no pack signed by
37
- * the named key recorded nothing. Nothing binds a pack's wrapper key to the
38
- * keys its records are signed with, so a counterparty that hands over its
39
- * account under any other key, or inside an export an issuer signed, or not at
40
- * all, gets the reading that says only that no pack in the case is signed by
41
- * the named key. Proving that a party did NOT record something needs that
42
- * party's account or a completeness mechanism over its records, and which of
43
- * those this reading waits for is proof#143.
44
- *
45
- * WHAT THAT MEANS FOR HOW THE JOIN IS BUILT
46
- * =========================================
47
- *
48
- * **The case reader does not consult `handoffs[]` to pair anything.** That is
49
- * not an oversight, it is the whole mechanism. The declaration is written by
50
- * the party being audited, so a join keyed on it inherits exactly the hole it
51
- * is meant to close: claim the clean pair, keep the tampered receipt
52
- * unclaimed, and a declaration-keyed reader never grades the receipt at all.
53
- * This reader pairs records with records, through `readHandoff`, over the
54
- * union of every record every pack in the case supplied. `handoffs[]` is read
55
- * for ONE thing and one only: to name a record its own holder DECLARED and
56
- * claimed nowhere (`RECORD_UNCLAIMED`), which is the pinned vector above
57
- * becoming visible. That claim check runs over every record a pack declares,
58
- * and not only over the ones 4.1 governs, because "is this a conformant
59
- * hand-off record" is an answer the audited party writes.
60
- *
61
- * **Matching is on hashes and identifiers alone and NEVER on tenancy**
62
- * (anchor#198, and DL-60: producer identity gates what the plane accepts and
63
- * never whether evidence verifies). The union pile is not partitioned by pack,
64
- * by issuer, by `tenant_id` or by organization anywhere in this module, so a
65
- * pair assembled by two organizations reads exactly as a pair assembled by
66
- * one. There is no code path here that reads a tenant.
67
- *
68
- * THE DEFECT CLASS THIS MODULE IS WRITTEN AGAINST
69
- * ===============================================
70
- *
71
- * Seventeen findings on this program share one generator: **a deviation
72
- * designed as a comparison between two present things, where the deviation
73
- * itself removes one of them, so the code takes the "nothing to compare"
74
- * branch written for the honest case.** A rule shaped that way reads the
75
- * absence of a signal as "fine" rather than as "I could not tell", and a real
76
- * deviation grades clean.
77
- *
78
- * Acceptance criterion 3 of anchor#198 is made of exactly that shape: *a
79
- * one-sided hand-off is an exception, not a failure*. So the four rules below
80
- * are structural and are the thing to preserve in any change here.
81
- *
82
- * 1. **Every exception is NAMED and appears in the output, and every exception
83
- * contributes a verdict.** There is no path through this module that
84
- * removes a record, a pack or a claim from the reading. An empty
85
- * `exceptions` is the ONLY shape a reader may read as clean, and `readCase`
86
- * is invariant: `verified` is reachable only when that array is empty.
87
- * 2. **Nothing a party writes decides whether grading happens.** No graded
88
- * value is part of any key that selects a code path. The pile is everything
89
- * every pack supplied; there is no filter.
90
- * 3. **Every field that selects a code path and is chosen by the party being
91
- * audited has an accept-set, and an unknown value goes to a named exception
92
- * rather than to silence.** `body_type` is `CASE_BODY_TYPES`;
93
- * `declaration_version` is `checkDeclaration`'s; `event_type` and
94
- * `event.counterparty_public_key` are 4.1's, through `readHandoffs`. A pack
95
- * that supplies zero records is `PACK_HOLDS_NO_RECORDS` and a case with no
96
- * hand-off record in it at all is `CASE_HOLDS_NO_HANDOFF_RECORDS`, because
97
- * "nothing to grade" is never "nothing is wrong".
98
- * 4. **The 4.1 predicates are CALLED, never restated.** `readHandoffs`,
99
- * `normalizedSigningKey` and `packRecords` are the one implementation of
100
- * each rule. A validation copied beside the one that enforces it drifts,
101
- * and a copy that is looser blesses a value the real one then rejects.
102
- *
103
- * EVERY EARLY EXIT IN THIS MODULE, AND WHAT IT SKIPS
104
- * ==================================================
105
- *
106
- * Three defects on this layer were one shape: an exit between a record
107
- * entering the pile and a check running over it, with the audited party
108
- * choosing whether the exit was taken. So the exits are enumerated here rather
109
- * than left to be rediscovered a fourth time. `src/sanning_proof/case.py` has
110
- * the same exits in the same order, and the pull request that added this list
111
- * carries the same table.
112
- *
113
- * 1. **No packs** (`return`). Skips everything. Reached by the reader, who
114
- * chose what to hand in. Named: `CASE_HOLDS_NO_PACKS`.
115
- * 2. **A wrapper key this reader cannot read** (`continue`). Skips the bundle
116
- * verification, the declaration check and every record in the pack. The
117
- * holder writes the key, so it steers here freely, and the pack is NAMED
118
- * (`PACK_KEY_UNREADABLE`). Both bundle verifiers now refuse every such
119
- * spelling in the same words (proof#142), so this exit and exit 3 agree on
120
- * which packs they take; it stays first because it names WHY. While any
121
- * pack takes this exit, a one-sided record whose named key signed no pack
122
- * reads `COUNTERPART_ABSENT_SIGNER_UNKNOWN` and never the innocent
123
- * `COUNTERPART_ABSENT`.
124
- * 3. **The wrapper does not hold** (`continue`): `signatureOk` and
125
- * `bodyHashOk` are not both true. Skips the declaration check and every
126
- * record in the pack, and the key it claims is never counted as a pack
127
- * signer. **This is the forger's case and the only exit that turns on
128
- * provenance**: those two fields are the whole of what ties a pack to a
129
- * key. Reached by whoever built the pack, and by anyone who alters its
130
- * unsigned `signature` field, which is indistinguishable from a forgery.
131
- * Named: `PACK_DOES_NOT_VERIFY`, and it moves one-sided records to
132
- * `COUNTERPART_ABSENT_SIGNER_UNKNOWN` exactly as exit 2 does.
133
- *
134
- * A pack whose wrapper HOLDS is never sent here, whatever is wrong inside
135
- * it (gate 3 on proof#140): its key is counted, its own bundle verdict is
136
- * `PACK_BUNDLE_NOT_VERIFIED` when that verdict is not `verified`, and each
137
- * record is graded on its own envelope below.
138
- * 3b. **The bundle verdict is `malformed`** (`continue`, after exit 3 and after
139
- * `PACK_BUNDLE_NOT_VERIFIED`). The body is one the verifier could not read,
140
- * so it graded no record. The key is already counted.
141
- * 4. **`body_type` outside `CASE_BODY_TYPES`** (`continue`). Skips the
142
- * declaration check and every record in the pack. The holder writes
143
- * `body_type`, so the holder steers here freely, which is why it is named
144
- * every time (`PACK_BODY_TYPE_UNKNOWN`) and why the pack's wrapper key is
145
- * counted ABOVE it: a counterparty cannot leave the damning counterpart
146
- * line by choosing a body (F3). `sanning.evidence.export/v2` lands here and
147
- * is not unwrapped.
148
- * 5. **No records** (`continue`). Skips the declaration check, over nothing.
149
- * The holder steers here by handing over an empty `events[]`. Named:
150
- * `PACK_HOLDS_NO_RECORDS`; the wrapper key is already counted.
151
- * 5b. **A record under a quarantined profile** and 5c. **a record whose own
152
- * envelope does not verify** (`continue`). Each skips 4.1 for that record,
153
- * its candidacy for any other record, and the claim check, because 4.1
154
- * compares only envelopes that verified (`HeldRecord`). Named:
155
- * `RECORD_PROFILE_QUARANTINED` (`incomplete`, pending DL-63) and
156
- * `RECORD_ENVELOPE_DOES_NOT_VERIFY` (`failed`). Neither removes a named
157
- * failure from another record: the other records of the pack are still
158
- * graded, and pairs still form over every record that verified.
159
- * 6. **`event_bytes` withheld** and 7. **`event_bytes` unreadable**
160
- * (`continue`). Each skips 4.1 for that record, its candidacy for any other
161
- * record, and the claim check. The holder steers into both by what it
162
- * discloses. Each is named in the claim check's place
163
- * (`RECORD_BYTES_WITHHELD` `incomplete`, `RECORD_BYTES_UNREADABLE`
164
- * `malformed`), so neither buys a cleaner reading than an unclaimed record
165
- * would get, and neither decides which counterpart kind another record
166
- * gets, since that reads wrapper keys.
167
- *
168
- * Not exits: a record whose envelope verifies and whose content does not
169
- * match its commitment is `RECORD_CONTENT_MISMATCH` where that content is
170
- * inside the signed body and `RECORD_CONTENT_DEPENDENT` where it was handed
171
- * in beside the pack (proof#159), and one that fails
172
- * another check inside its pack is `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`.
173
- * Both are named and both STAY in the pile, so neither unsigned content
174
- * handed in beside a pack nor a broken inclusion proof removes a record
175
- * from pairing.
176
- * 8. **`not_a_handoff_record`** (`continue`). Skips the hand-off count and the
177
- * counterpart, ambiguity and pair reporting, which 4.1 does not give a
178
- * record outside its scope. The holder steers here by respelling a field.
179
- * It no longer skips the claim check, which runs ABOVE it because it asks
180
- * what the pack SAID and not what the record is (F1).
181
- * 9. **THIS record is malformed** (`continue`). Skips the counterpart,
182
- * ambiguity and pair reporting, and 4.1 computed none of them for a
183
- * malformed subject, so nothing is lost. A party steers here only by
184
- * breaking a record it signed, and the record is named `RECORD_MALFORMED`.
185
- * A well formed record whose CANDIDATE is malformed does not take this
186
- * exit: it is `COUNTERPART_MALFORMED` and the reporting below still runs
187
- * (F2).
188
- * 10. **`counterpart_absent`** (`continue`). Skips the ambiguity and pair
189
- * reporting, and there is nothing to skip: 4.1 emits that finding only
190
- * where no candidate is left to read, an EMPTY candidate list or one whose
191
- * every candidate another record accounts for (4.1 v1.17, A20). Which of the three counterpart kinds it
192
- * gets reads the keys of the packs that verified, counted before exit 4,
193
- * and whether any pack took exit 2 or 3.
194
- *
195
- * Not an exit, and listed because it is one more place this shape could
196
- * hide: the claim check runs only where the pack's declaration is READABLE. An
197
- * absent or unreadable one is named once for the pack (`PACK_UNDECLARED`,
198
- * `PACK_DECLARATION_UNREADABLE`) rather than once per record.
199
- *
200
- * THE VERDICT VOCABULARY IS CLOSED
201
- * ================================
202
- *
203
- * `verified`, `failed`, `partial`, `mixed`, `incomplete` and `malformed`,
204
- * every one of them already in use (`evidence-bundle.md` 3 and
205
- * `PACK_VERDICTS`). **No new word is coined anywhere in this module**, and
206
- * `CASE_VERDICTS` is asserted against `PACK_VERDICTS` by the test suite so a
207
- * future addition here cannot invent one quietly. Everything this module has
208
- * to say that is not one of those six is an EXCEPTION KIND reported beside the
209
- * verdict, exactly as `counterpart_absent` and `not_a_handoff_record` are
210
- * findings beside a verdict in 4.1.
211
- *
212
- * A case with any `failed` contribution is `failed`: a proven failure keeps
213
- * the headline (5.5.4 v1.16, A17, proof#158). Otherwise a case with more than
214
- * one outcome is `mixed` (anchor#198, criterion 4).
215
- *
216
- * WHAT THIS READER DOES NOT CLAIM
217
- * ===============================
218
- *
219
- * * **Not that the case is complete.** Joining is voluntary. A counterparty
220
- * that never handed over a pack leaves `COUNTERPART_ABSENT`, which is an
221
- * exception and never a failure. It says that no pack in the case is signed
222
- * by the named key, and nothing about what that party recorded.
223
- * * **Not that a party which presented no pack under the named key recorded
224
- * nothing.** See the top of this comment, and proof#143.
225
- * * **Not that two organizations are independent.** One team holding both keys
226
- * proves which agent said what, not that two parties agreed (4.1, 5.4.4).
227
- * * **Not that a `verified` case has no other problem.** It reads hand-offs.
228
- * It runs `verifyEvidenceBundle` over every pack itself, offline and with no
229
- * gateways, and a pack whose own bundle verdict is not `verified` is named
230
- * with that verdict and its reasons, so the case never reads above its
231
- * weakest pack. On-chain re-fetch and `gradePack` are a caller's to run.
232
- *
233
- * THE COST OF 4.1's "GRADE EVERY CANDIDATE", AND WHAT IS LEFT OF IT (4.1
234
- * v1.17, amendment A20, which reopens proof#134). Up to v1.16 a pile holding
235
- * two or more records of the complementary type signed by one counterparty key
236
- * could not reach `verified`. 4.1 now sets such a candidate aside where a
237
- * record the subject's own key signed pairs with it verified at another
238
- * sequence, so two messages in one direction, and the demo's A to B, B to A,
239
- * A to B, read clean. What is left surfaces as `COUNTERPART_AMBIGUOUS` beside
240
- * the failure: fan-in, fan-out, and the same pair in two cases at the same
241
- * sequence. Narrowing any of it here with `handoffs[]` is precisely the move
242
- * that would reopen proof#137's door.
2
+ * One verdict for a case: several packs, possibly from several organizations.
3
+ * Conformance: `test-vectors/case/`.
4
+ *
5
+ * A pack a party assembles about itself is not self-proving about what that
6
+ * party left out: a holder can declare a record and claim it nowhere, and the
7
+ * pack still reads `verified` (`declaration-unclaimed-record-01`). Reading
8
+ * several parties' packs at once closes that. An `agent.handoff.sent` with no
9
+ * counterpart among the case's records is always named, and when a pack signed
10
+ * by the key that record names is in the case, the reading says that pack
11
+ * holds no counterpart. It does not establish that a party which presented no
12
+ * such pack recorded nothing: nothing binds a pack's wrapper key to its
13
+ * records' keys.
14
+ *
15
+ * How the join works:
16
+ * - Records pair with records, through `readHandoffs`, over the union of every
17
+ * record every pack supplied. `handoffs[]` is written by the audited party,
18
+ * so it never selects a pair; it is read only to name a declared record that
19
+ * is claimed nowhere (`RECORD_UNCLAIMED`).
20
+ * - Matching is on hashes and identifiers only, never tenancy. Nothing here
21
+ * partitions the pile by pack, issuer, `tenant_id` or organization.
22
+ *
23
+ * Structural rules, to preserve in any change:
24
+ * 1. Every exception is named in the output and contributes a verdict.
25
+ * Nothing removes a record, pack or claim from the reading. `verified` is
26
+ * reachable only when `exceptions` is empty.
27
+ * 2. Nothing a party writes decides whether grading happens. The pile is
28
+ * everything every pack supplied.
29
+ * 3. Every party-chosen field that selects a code path has an accept-set, and
30
+ * an unknown value is a named exception. "Nothing to grade" is never
31
+ * "nothing is wrong".
32
+ * 4. The §4.1 predicates (`readHandoffs`, `normalizedSigningKey`,
33
+ * `packRecords`) are called, never restated.
34
+ *
35
+ * Every early exit in `readCase` names its exception, so no exit buys a
36
+ * cleaner reading than the record or pack would otherwise get. In particular:
37
+ * - A pack with an unreadable key or a wrapper that does not hold is skipped
38
+ * and its key never counted; while any pack takes that exit, a one-sided
39
+ * record reads `COUNTERPART_ABSENT_SIGNER_UNKNOWN`, never `COUNTERPART_ABSENT`.
40
+ * - A wrapper key is counted before the body-type and no-records exits, so
41
+ * choosing a body cannot dodge the counterpart line.
42
+ * - The claim check runs before the not-a-hand-off and malformed exits, so
43
+ * respelling or breaking a record cannot switch it off.
44
+ * - Content and in-pack failures are named and the record stays in the pile.
45
+ *
46
+ * The verdict vocabulary is closed: `verified`, `failed`, `incomplete`
47
+ * (evidence-bundle.md section 3). Every exception maps to one claim and one
48
+ * state (`claims.ts`), and the case reads its worst pack: any `failed`
49
+ * contribution makes the case `failed`, otherwise any `incomplete` makes it
50
+ * `incomplete`, and a reading with no contribution is `verified`.
51
+ *
52
+ * Not claimed: that the case is complete (joining is voluntary), that two
53
+ * organizations are independent (one team can hold both keys), or that a
54
+ * `verified` case has no other problem. Every pack is verified offline here
55
+ * and a pack whose bundle is not `verified` is named; on-chain re-fetch and
56
+ * `gradePack` are the caller's.
57
+ *
58
+ * Several records of the complementary type under one counterparty key can
59
+ * still be ambiguous (fan-in, fan-out, one pair in two cases at one sequence):
60
+ * that surfaces as `COUNTERPART_AMBIGUOUS`. Narrowing it with `handoffs[]`
61
+ * would reopen the hole above.
243
62
  */
244
63
 
245
64
  import {
@@ -253,6 +72,7 @@ import {
253
72
  declarationClaims,
254
73
  packRecords,
255
74
  } from "./declaration.js";
75
+ import { readAgentRecords, type AgentHeldRecord, type AgentRecordReading } from "./agent-record.js";
256
76
  import { readChainLinks } from "./chain.js";
257
77
  import {
258
78
  type HandoffReading,
@@ -261,6 +81,7 @@ import {
261
81
  normalizedSigningKey,
262
82
  readHandoffs,
263
83
  recordIsMalformed,
84
+ recordIsNonconforming,
264
85
  } from "./handoff.js";
265
86
  import { coSignaturesPresent, resolveTraceEvents, verifyEvidenceBundle } from "./evidence.js";
266
87
  import type { EvidenceBundleResult } from "./evidence.js";
@@ -272,58 +93,37 @@ import {
272
93
  completenessNotes,
273
94
  gradePack,
274
95
  } from "./grading.js";
96
+ import type { EnvironmentMarker } from "./environment.js";
275
97
  import type { SideContentMap } from "./side-content.js";
276
98
  import { renderWitnessTime } from "./witness-cache.js";
277
-
278
- // ---- The vocabulary ---------------------------------------------------------
279
-
280
- /**
281
- * The verdicts a case can reach. Every one is already in use in
282
- * `evidence-bundle.md` 3 or `PACK_VERDICTS`, and anchor#198 says in terms that
283
- * no new verdict word is introduced anywhere.
284
- */
285
- export const CASE_VERDICTS = [
286
- "verified",
287
- "failed",
288
- "partial",
289
- "mixed",
290
- "incomplete",
291
- "malformed",
292
- ] as const;
99
+ import {
100
+ KIND_CLAIMS,
101
+ VERDICTS,
102
+ VERDICT_TITLES,
103
+ bundleProblems,
104
+ claimsOf,
105
+ problem,
106
+ restOnSigned,
107
+ verdictOf as claimsVerdictOf,
108
+ withItems,
109
+ type Problem,
110
+ type ProblemItem,
111
+ type ReasonKind,
112
+ } from "./claims.js";
113
+
114
+ /** The three verdicts (evidence-bundle.md section 3). A case reads its worst pack. */
115
+ export const CASE_VERDICTS = VERDICTS;
293
116
 
294
117
  export type CaseVerdict = (typeof CASE_VERDICTS)[number];
295
118
 
296
119
  /**
297
- * anchor#198 says in terms that no new verdict word is introduced anywhere, and
298
- * this line is what ENFORCES it rather than asserting it in prose.
299
- *
300
- * `tsc` refuses to compile if any member of `CASE_VERDICTS` is not already a
301
- * `PackVerdict`, so a seventh word added here fails the BUILD rather than
302
- * reaching a test that might not cover it. The Python half asserts the same
303
- * containment at run time against `grading.PACK_VERDICTS`, which is that
304
- * language's equivalent gate.
305
- */
306
- const _caseVerdictsAreSharedVocabulary: PackVerdict[] = [...CASE_VERDICTS];
307
- void _caseVerdictsAreSharedVocabulary;
308
-
309
- /**
310
- * The two pack bodies that carry `events[]` and may carry a `declaration`
311
- * (`evidence-bundle.md` 5.4.1). This is an ACCEPT-SET and not a hint.
120
+ * The pack bodies that carry `events[]` and may carry a `declaration`
121
+ * (evidence-bundle.md §5.4.1). An accept-set: `body_type` is party-written, so
122
+ * an unknown value is `PACK_BODY_TYPE_UNKNOWN`, never zero records read clean.
312
123
  *
313
- * `body_type` selects a code path and is written by the party assembling the
314
- * pack. Without this set, a pack whose records live under a key this reader
315
- * does not know yields zero records, contributes nothing, and lets the rest of
316
- * the case read clean over it: the exact defect class this module is written
317
- * against. An unknown value is `PACK_BODY_TYPE_UNKNOWN`.
318
- *
319
- * **`sanning.evidence.export/v2` is NOT in this set and is NOT unwrapped.** It
320
- * verifies as a bundle and carries a whole `sanning.anchor.trace/v3` pack under
321
- * `body.source_bundle`, so its records do not reach the pile and the export is
322
- * named `PACK_BODY_TYPE_UNKNOWN`, `incomplete`. Reading inside it changes which
323
- * packs this reader grades, so it is a decision of its own. A reader who needs
324
- * the records hands in the source bundle as a pack. The export's wrapper key is
325
- * still read, so wrapping a pack in one does not change which counterpart kind
326
- * another record gets.
124
+ * `sanning.evidence.export/v2` is not unwrapped: it is named
125
+ * `PACK_BODY_TYPE_UNKNOWN`, and a reader who needs its records hands in the
126
+ * source bundle. Its wrapper key is still read.
327
127
  */
328
128
  export const CASE_BODY_TYPES = [
329
129
  "sanning.anchor.trace/v3",
@@ -331,398 +131,224 @@ export const CASE_BODY_TYPES = [
331
131
  ] as const;
332
132
 
333
133
  /**
334
- * The envelope profiles the case reader reads records under: the ratified
335
- * accept-set (`envelope-spec.md` 5) minus every profile under quarantine in
336
- * `cross-kernel/accept-set.json`. A test in each kernel derives both arrays
337
- * from that file, and a test here pins this one equal to this kernel's own
338
- * `ACCEPTED_SPEC_MAJORS`, which is what `verifyEvidenceBundle` reads under:
339
- * the Python kernel narrows its verifier to this set for the case reader, and
340
- * this kernel's verifier already reads exactly it. If this kernel ever admits
341
- * a quarantined profile, that test fails, and the case reader has to narrow
342
- * its verifier the way the Python one does before it can pass.
134
+ * The profiles records are read under: the accept-set (envelope-spec.md §5)
135
+ * minus quarantined profiles in `cross-kernel/accept-set.json`. A test pins
136
+ * this equal to `ACCEPTED_SPEC_MAJORS`, which `verifyEvidenceBundle` reads under.
343
137
  */
344
- export const CASE_RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"] as const;
138
+ export const CASE_RECORD_PROFILES = ["sanning.events/v2"] as const;
345
139
 
346
- /** The ratified profiles under quarantine, pending DL-63. */
140
+ /** Accepted profiles under quarantine. */
347
141
  export const CASE_QUARANTINED_PROFILES = ["sanning.mlflow/v1"] as const;
348
142
 
349
- // ---- The exceptions ---------------------------------------------------------
350
- //
351
- // Each of these is a NAME for something a reader would otherwise have to infer
352
- // from an absence. None of them is a verdict word.
353
-
354
143
  /**
355
- * A conformant hand-off record with no counterpart among the records this
356
- * reader read, where every pack in the case verified under a key this reader
357
- * can read and NO pack in the case is signed by the key the record names,
358
- * or where every candidate it had was accounted for by another record under
359
- * A20 (the exception then carries `count`, and names no pack).
360
- * **An exception, never a failure** (anchor#198, criterion 3).
361
- *
362
- * That is ALL it says. It does not say the counterparty's account is absent,
363
- * and it does not say nothing was tampered with: a counterparty that signs its
364
- * own pack under a second key, or hands over an export an issuer signed,
365
- * reaches this reading too, and nothing offline binds a pack's key to the keys
366
- * of the records inside it (proof#143). The rendered text states the fact and
367
- * stops.
368
- *
369
- * It says nothing about RECORDS signed by that key either. A party may hold records
370
- * its counterparty signed, and those are still graded as candidates, but they
371
- * are not the counterparty's account of itself, so they do not move this
372
- * record to `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS`.
144
+ * A conformant hand-off record with no counterpart, where every pack verified
145
+ * under a readable key and none is signed by the key the record names, or every
146
+ * candidate was accounted for by another record (then `count` is set and no
147
+ * pack is named). An exception, never a failure. It says only that no pack is
148
+ * signed by the named key; records that key signed and others hold are not that
149
+ * party's own account and do not change it.
373
150
  */
374
151
  export const COUNTERPART_ABSENT = "counterpart_absent";
375
152
 
376
153
  /**
377
- * THE CLOSURE anchor#198 exists for.
378
- *
379
- * A conformant hand-off record with no counterpart, where a pack that VERIFIES
380
- * and is SIGNED by the key this record names is in the case, and not one
381
- * record this reader could read is this record's counterpart. It states that a
382
- * pack signed by the named key is present and holds no counterpart this reader
383
- * could read, and nothing more. 4.1 (v1.10, A15) and 5.4.2 rule 7 (v1.10) both point here, and
384
- * `declaration/declaration-unclaimed-record-01` is the pack that reaches this
385
- * line.
386
- *
387
- * **"Its own account" is read from the key that signed the PACK, and never
388
- * from the keys that signed records.** A pack's wrapper key signs `body_hash`
389
- * and `body` carries the declaration (`evidence-bundle.md` 5.4, "Who signs
390
- * it"), so a wrapper signed by B is provenance the accusing party cannot forge
391
- * without B's private key. Records signed by B that A merely HOLDS are not B's
392
- * account of itself: keyed on record keys, one pack from A plus one unrelated
393
- * record B signed at some other time reached this line when B had handed over
394
- * nothing at all.
395
- *
396
- * What it does NOT prove, stated so no surface claims it: that B handed the
397
- * pack over for THIS case. A pack B signed for some other purpose and a third
398
- * party replayed reads the same, because the only values that could say
399
- * otherwise (the declaration's `case_id` and `period`) are written by B and
400
- * would let B leave this line by writing them differently. The cost the other
401
- * way: a pack B assembles under any key other than the one its records name, a
402
- * per-organization hand-over key (anchor#196) included, reads
403
- * `COUNTERPART_ABSENT`, because nothing in the case binds that key to B. That
404
- * reading is still true: no pack signed by the named key is here. So a
405
- * counterparty can AVOID this line by choosing the key it signs under; what it
406
- * cannot do is make the missing counterpart read clean.
407
- *
408
- * It is deliberately NOT also conditioned on a record signed by B being in the
409
- * pile. B's pack can hold none this reader reads (a body it does not unwrap,
410
- * bytes B withheld, an empty `events[]`), and each of those is a choice B makes
411
- * and is named on its own. A second conjunct on them hands B the innocent line
412
- * for making one.
413
- *
414
- * It is a DISTINCT kind from `COUNTERPART_ABSENT` and not a louder spelling of
415
- * it. Collapsing the two would be the defect class again: "the counterparty
416
- * never joined" and "the counterparty joined and its own account does not
417
- * contain this hand-off" are the honest reading and the damning one, and one
418
- * name for both lets the second read as the first.
419
- *
420
- * It is still an EXCEPTION and not a failure, which criterion 3 requires and
421
- * which is also the honest call: the counterparty's pack is a selection over
422
- * its own interval, and a hand-off outside that interval is legitimately not
423
- * in it. What this names is that the question can now be ASKED of the other
424
- * party's own evidence, which is what no single pack could ever support.
154
+ * A conformant hand-off record with no counterpart, where a verified pack
155
+ * signed by the key the record names is in the case and holds no counterpart.
156
+ * The closure this module exists for (`declaration-unclaimed-record-01`).
157
+ *
158
+ * "Its own account" is read from the key that signed the pack, never from
159
+ * record keys: a wrapper signed by B is provenance an accuser cannot forge, but
160
+ * records signed by B that A holds are not B's account.
161
+ *
162
+ * Not proven: that B handed the pack over for this case. A pack B signs under
163
+ * any other key reads `COUNTERPART_ABSENT`, which is still true; B can avoid
164
+ * this line by choice of key, but cannot make the missing counterpart read
165
+ * clean. Not conditioned on B's records being readable, since each way B could
166
+ * make them unreadable is its own named choice.
167
+ *
168
+ * A distinct kind from `COUNTERPART_ABSENT`: one name for both would let the
169
+ * damning reading pass as the honest one. Still `incomplete`, not a failure: a
170
+ * hand-off outside the interval B's pack covers is legitimately absent.
425
171
  */
426
172
  export const COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS =
427
173
  "counterpart_absent_from_counterparty_records";
428
174
 
429
175
  /**
430
- * A conformant hand-off record with no counterpart, no VERIFIED pack in the
431
- * case signed by the key it names, and at least one pack in the case that this
432
- * reader could not tie to a key: its wrapper key is unreadable
433
- * (`PACK_KEY_UNREADABLE`) or it does not verify (`PACK_DOES_NOT_VERIFY`).
434
- * Whether a pack signed by the named key is in the case is UNKNOWN, so neither
435
- * of the other two kinds is true.
436
- *
437
- * The gate on the previous version of this reader showed why it exists. A
438
- * counterparty that signed its own pack under `" " + key` got
439
- * `PACK_KEY_UNREADABLE` for the pack and the innocent `COUNTERPART_ABSENT` for
440
- * the record, and the output contradicted itself: whose pack it was is
441
- * UNKNOWN, and then no pack is signed by the named key. The second sentence
442
- * was not established.
443
- *
444
- * `incomplete`, like the other two one-sided kinds: "I could not tell".
176
+ * A conformant hand-off record with no counterpart, no verified pack signed by
177
+ * the key it names, and at least one pack whose signer is unknown
178
+ * (`PACK_KEY_UNREADABLE` or `PACK_DOES_NOT_VERIFY`). Whether the named key
179
+ * signed a pack is unknown, so neither other kind is true. `incomplete`.
445
180
  */
446
181
  export const COUNTERPART_ABSENT_SIGNER_UNKNOWN = "counterpart_absent_signer_unknown";
447
182
 
448
183
  /**
449
- * More than one candidate counterpart that nothing accounts for (4.1 v1.17,
450
- * A20). The reading is the worst of them and the ambiguity is named: a reader
451
- * permitted to take one match can be handed an honest decoy beside a tampered
452
- * record. `count` is how many of the candidates are live, which is the number
453
- * the ambiguity is about.
184
+ * More than one live candidate counterpart (§4.1). The reading is the worst of
185
+ * them and the ambiguity is named, so an honest decoy cannot hide a tampered
186
+ * record. `count` is the number of live candidates.
454
187
  */
455
188
  export const COUNTERPART_AMBIGUOUS = "counterpart_ambiguous";
456
189
 
190
+ /** A formed pair that failed checks, listed in §4.1's fixed order. */
191
+ export const PAIR_FAILED = "pair_failed";
192
+
193
+ /** This record itself is malformed under §4.1 (`recordIsMalformed`). */
194
+ export const RECORD_MALFORMED = "record_malformed";
195
+
457
196
  /**
458
- * A formed pair that failed a check. Carries which checks, from 4.1's own
459
- * fixed order, so a reader is told the record AND the check.
197
+ * A candidate counterpart is malformed and this record is not. §4.1 reports
198
+ * the worst candidate, so without this kind one junk record signed by the
199
+ * counterparty's key would blame the honest record and drop its other failures.
200
+ * `incomplete`: the defect is the candidate's, which carries its own
201
+ * `RECORD_MALFORMED`.
460
202
  */
461
- export const PAIR_FAILED = "pair_failed";
203
+ export const COUNTERPART_MALFORMED = "counterpart_malformed";
462
204
 
463
205
  /**
464
- * THIS record is wrong: 4.1 governs it and `recordIsMalformed` says so of the
465
- * record itself. A statement about the record this exception names, and about
466
- * no other.
206
+ * This record is one of the two hand-off types, signed and intact, and does
207
+ * not follow its vocabulary (`recordIsNonconforming`). A producer's error,
208
+ * never a proven alteration.
467
209
  */
468
- export const RECORD_MALFORMED = "record_malformed";
210
+ export const RECORD_NONCONFORMING = "record_nonconforming";
469
211
 
470
212
  /**
471
- * A CANDIDATE counterpart of this record is malformed, and this record is not.
472
- *
473
- * 4.1 grades every candidate and reports the worst of them, and `malformed`
474
- * outranks `failed`, so one junk record signed by the counterparty's key makes
475
- * the reading of a perfectly well formed record `malformed` too. Filed under
476
- * `RECORD_MALFORMED`, that read as "this record is not a well formed hand-off
477
- * record" about the HONEST record, and the branch it took skipped the
478
- * counterpart, ambiguity and `failedChecks` reporting entirely: one junk record
479
- * both blamed the wrong party and stripped a named `message_hash` failure out
480
- * of the case. That is proof#133 ruling 6, which 4.1 closed by keeping the
481
- * UNION of `failedChecks`, reappearing one layer out because this layer
482
- * discarded that union.
483
- *
484
- * A record's own conformance and its candidate's are different facts and do
485
- * not share a kind. This one is `incomplete` and not `malformed`: the proven
486
- * defect belongs to the candidate, which carries its own `RECORD_MALFORMED`
487
- * line, and what is true of THIS record is that its pairing could not be
488
- * determined. Contributing `malformed` here would attribute the candidate's
489
- * defect to the record a party being accused did not write.
213
+ * A candidate counterpart is nonconforming and this record is not.
214
+ * `incomplete` for the reason `COUNTERPART_MALFORMED` is: the defect is the
215
+ * candidate's, which carries its own `RECORD_NONCONFORMING`.
490
216
  */
491
- export const COUNTERPART_MALFORMED = "counterpart_malformed";
217
+ export const COUNTERPART_NONCONFORMING = "counterpart_nonconforming";
492
218
 
493
219
  /**
494
- * A record that its own pack DECLARES and its own pack's READABLE declaration
495
- * claims in no `handoffs[]` entry. This is proof#137's door, named at the layer
496
- * that can see it: the holder declared the record and claimed it nowhere, so
497
- * neither 5.4.2 rule 2 nor rule 7 reaches it and the pack reads `verified`.
498
- *
499
- * **It turns on what the PACK said about the record, and never on whether 4.1
500
- * governs it.** Gating it on "is this a conformant hand-off record" put it
501
- * behind a value the audited party writes: the same second receipt, respelled
502
- * with an `ff` ligature in `event_type` and a Cyrillic `е` in
503
- * `counterparty_public_key`, left 4.1's scope, took the `not_a_handoff_record`
504
- * branch, and bought a case reading of `verified` with zero exceptions where
505
- * the conformant spelling read `mixed` with four. proof#136 ratified that a
506
- * respelling buys a DIFFERENT LINE rather than no line; at this layer it
507
- * bought no line at all, and a strictly better outcome than honesty.
508
- *
509
- * It is not an accusation. `handoffs[]` MAY be empty and an unclaimed record
510
- * may be entirely innocent. What it is, is the end of the silence: the reader
511
- * cannot tell whether an unclaimed record is part of the hand-over the holder
512
- * meant to give or one it is keeping out of its own account, and that is "I
513
- * could not tell", which is why it carries `incomplete` and not `verified`.
514
- *
515
- * THE COST, stated rather than left to be found: a pack that declares records
516
- * which are not hand-off sides at all, an ordinary log line among them, is
517
- * named once per such record, so a case holding one cannot read `verified`.
518
- * That is the price of a rule with no branch the holder selects, and the only
519
- * narrower rule available keys on a value the holder writes. A surface that
520
- * wants the two apart reads `CaseRecordReading.findings`, where
521
- * `not_a_handoff_record` still says which is which.
220
+ * A record its pack declares and its pack's readable declaration claims in no
221
+ * `handoffs[]` entry (§5.4.2 rules 2 and 7 cannot reach it). Turns on what the
222
+ * pack said, never on whether §4.1 governs the record, so respelling
223
+ * `event_type` cannot move a record out of this check.
224
+ *
225
+ * Not an accusation: `handoffs[]` may be empty and the record innocent, so it
226
+ * is `incomplete`. The cost is that a declared non-hand-off record (a log line)
227
+ * is named too; `CaseRecordReading.findings` tells the two apart.
522
228
  */
523
229
  export const RECORD_UNCLAIMED = "record_unclaimed_by_its_pack";
524
230
 
525
231
  /**
526
- * The pack supplied no `event_bytes` for a record, so this reader cannot see
527
- * what the record IS in either direction.
528
- *
529
- * `event_bytes` is OPTIONAL (5.1, 5.3) and withholding it is minimum
530
- * disclosure, which the corpus calls a customer's right and not a defect
531
- * (proof#137, defect 3), so this is not a deviation and does not fail. It is
532
- * still SAID, because a withheld record is exactly where a missing counterpart
533
- * would hide, and a reader that dropped it silently would read a trimmed pack
534
- * as an honest one.
232
+ * The pack withheld `event_bytes` for a record. Withholding is minimal
233
+ * disclosure (§5.1, §5.3), not a deviation, but it is said: a withheld record is
234
+ * where a missing counterpart would hide.
535
235
  */
536
236
  export const RECORD_BYTES_WITHHELD = "record_bytes_withheld";
537
237
 
538
238
  /**
539
- * The pack SUPPLIED `event_bytes` this reader cannot read as
540
- * `JCS(event record)` (5.4.2 rule 7's parser rule, one implementation in
541
- * `packRecords`). Bytes the holder handed over that do not decode are a defect
542
- * in what was handed over, not an absence, so this one is `malformed`.
239
+ * The pack supplied `event_bytes` that do not read as `JCS(event record)`.
240
+ * A defect in what was handed over, so `malformed`.
543
241
  */
544
242
  export const RECORD_BYTES_UNREADABLE = "record_bytes_unreadable";
545
243
 
546
- /**
547
- * The pack deviates from its own declaration (5.4.2). A proven mismatch, so it
548
- * fails: a case cannot be clean over a pack that is proven to be trimmed.
549
- */
244
+ /** The pack deviates from its own declaration (§5.4.2). A proven mismatch: `failed`. */
550
245
  export const PACK_DECLARATION_DEVIATES = "pack_declaration_deviates";
551
246
 
552
247
  /**
553
- * The pack and the UNSIGNED content handed in beside it do not line up (5.4.2
554
- * rules 3 and 4, proof#147, ratified by Phil 2026-09-22). A record the pack
555
- * declares `disclosed` for which no bytes reached this reader, or one it
556
- * declares `content_withheld` for which bytes reached this reader from the
557
- * side-content map alone.
248
+ * The pack and the unsigned content beside it do not line up (§5.4.2 rules 3
249
+ * and 4): a record declared `disclosed` with no bytes, or declared
250
+ * `content_withheld` with bytes from the side-content map alone.
558
251
  *
559
- * `incomplete` and never `failed`, which is the whole point of the kind. 5.4
560
- * grades a pack's declaration partly against content the READER was handed,
561
- * and whoever assembles a case fills that map in. Under
562
- * `PACK_DECLARATION_DEVIATES` an accuser could put an intact pack into a
563
- * `failed` case WITH NO KEY AT ALL: supply bytes for a record its holder
564
- * declared withheld, or drop the bytes of one it declared disclosed. The pack
565
- * was intact, its wrapper verified, and its holder had done nothing. This is
566
- * the mirror of proof#143, where a counterparty makes ITSELF look better by
567
- * choosing what it hands over.
568
- *
569
- * It is emitted BESIDE `PACK_DECLARATION_DEVIATES` where a pack does both,
570
- * because the two say different things about different records.
252
+ * `incomplete`, never `failed`: whoever assembles a case fills in that map, so
253
+ * otherwise an accuser could fail an intact pack with no key. Emitted beside
254
+ * `PACK_DECLARATION_DEVIATES` when a pack does both.
571
255
  */
572
256
  export const PACK_DECLARATION_CONTENT_DEPENDENT = "pack_declaration_content_dependent";
573
257
 
574
258
  /**
575
- * The pack carries a `declaration` this reader cannot read (5.4.3): an unknown
576
- * `declaration_version`, an absent or `null` one, a non-object, or one that
577
- * does not satisfy 5.4.1. `declaration_version` is chosen by the same party
578
- * that assembles the pack and has no accept-set of its own, so this is UNKNOWN
579
- * and never clean.
259
+ * The pack's `declaration` cannot be read (§5.4.3). Its version is chosen by
260
+ * the pack's assembler with no accept-set, so this is unknown, never clean.
580
261
  */
581
262
  export const PACK_DECLARATION_UNREADABLE = "pack_declaration_unreadable";
582
263
 
583
264
  /**
584
- * The pack carries no `declaration` member at all, so it never said what it
585
- * holds. `partial` is 5.4.3's own word for a claim nobody made: never
586
- * `verified`, because there is nothing to check the pack against, and never
587
- * `failed`, because nothing is wrong.
265
+ * The pack carries no `declaration`. `partial` (§5.4.3): nothing to check
266
+ * against, and nothing wrong.
588
267
  */
589
268
  export const PACK_UNDECLARED = "pack_undeclared";
590
269
 
591
- /**
592
- * The pack's `body_type` is not one this reader knows to carry `events[]`
593
- * (`CASE_BODY_TYPES`).
594
- */
270
+ /** The pack's `body_type` is not in `CASE_BODY_TYPES`. */
595
271
  export const PACK_BODY_TYPE_UNKNOWN = "pack_body_type_unknown";
596
272
 
597
- /**
598
- * The pack carries a body this reader knows and no records in it. A pack that
599
- * contributes nothing is named rather than passed over.
600
- */
273
+ /** The pack carries a known body with no records. */
601
274
  export const PACK_HOLDS_NO_RECORDS = "pack_holds_no_records";
602
275
 
603
276
  /**
604
- * The key the pack's wrapper names is not one this reader can read as a key
605
- * (`normalizedSigningKey` returns nothing for it), so who signed the pack is
606
- * UNKNOWN. The pack is not verified and none of its records are read, and
607
- * every one-sided record whose named key signed no verified pack reads
608
- * `COUNTERPART_ABSENT_SIGNER_UNKNOWN`.
609
- *
610
- * Found by constructing it, while enumerating this module's exits: the wrapper
611
- * key is the ONE input that separates the two counterpart kinds, and a key this
612
- * reader could not read used to drop out of that set in silence. The Python
613
- * `verify_evidence_bundle` accepts a wrapper `public_key` with ASCII
614
- * whitespace inside it or around it (`bytes.fromhex` skips it; this kernel's
615
- * verifier rejects it), so a counterparty that signed its own pack under
616
- * `" " + key` handed a Python reader a pack that verified, contributed no key,
617
- * and moved the subject from `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS` to
618
- * the innocent `COUNTERPART_ABSENT`. The same repair 4.1 made for a RECORD
619
- * whose envelope key it cannot read (proof#133, fourth hunt): a key a reader
620
- * holds and cannot read is named, never treated as absent. It is `incomplete`
621
- * rather than `malformed` because whether the spelling verifies is the envelope
622
- * layer's call (proof#135), and this reader only knows it cannot read it.
277
+ * The pack's wrapper key cannot be read (`normalizedSigningKey` returns
278
+ * nothing), so its signer is unknown. None of its records are read, and a
279
+ * one-sided record whose named key signed no verified pack reads
280
+ * `COUNTERPART_ABSENT_SIGNER_UNKNOWN`. A key a reader holds and cannot read is
281
+ * named, never treated as absent. `incomplete`: whether the spelling verifies
282
+ * is the envelope layer's call.
623
283
  */
624
284
  export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
625
285
 
626
286
  /**
627
- * The pack does not verify as a bundle: `verifyEvidenceBundle`, run here
628
- * offline over the pack and its side content, returned `failed` or
629
- * `malformed`. None of its records are read, because a record from it would
630
- * reach 4.1 without the verified envelope `HeldRecord` requires, and the key
631
- * its wrapper claims is never counted as a pack signer: an unverified wrapper
632
- * claiming the counterparty's key is exactly what an accusing party would
633
- * forge.
634
- *
635
- * `partial` is not this exception. `partial` means the wrapper signature and
636
- * `body_hash` held and part of the body could not be determined, which is what
637
- * an unknown body or withheld bytes produce, and those are named on their own.
638
- *
639
- * `failed`: a pack in the case is proven broken.
287
+ * The pack's `bundle.json` cannot be read as JSON: it does not parse, nests too deep, or
288
+ * names one member twice. The pack is not verified and none of its records are read, and
289
+ * every other pack in the case still is. `incomplete`: the signature could not be checked.
290
+ */
291
+ export const PACK_UNREADABLE = "pack_unreadable";
292
+
293
+ /**
294
+ * `verifyEvidenceBundle`, run offline here, returned `failed` or `malformed`.
295
+ * None of its records are read (§4.1 requires verified envelopes), and its
296
+ * wrapper key is never counted as a pack signer: an unverified wrapper claiming
297
+ * the counterparty's key is what an accuser would forge. `failed`.
640
298
  */
641
299
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
642
300
 
643
301
  /**
644
- * The pack's wrapper HOLDS and its CASE GRADE is not `verified`
645
- * (evidence-bundle 5.5.1 step 1 v1.20, A23; it replaces
646
- * `pack_bundle_not_verified`, which carried the bundle status alone). The case
647
- * grade is the pack grade of 5.4.5, taken twice: the SIGNED grade over the run
648
- * with the side map empty, and the FULL grade over the run with it. The signed
649
- * grade decides where it is `failed` or `malformed`; a failure only the full
650
- * grade reaches is `incomplete`, because it rests on bytes nobody signed;
651
- * otherwise the full grade. A body the verifier could not read is `malformed`,
652
- * a body that is not one of the two event-carrying bodies takes the signed
653
- * run's status, and a verifier that graded a different number of records than
654
- * the pack holds takes the signed status, or `malformed` where that status is
302
+ * The pack's wrapper holds and its case grade is not `verified`
303
+ * (evidence-bundle §5.5.1). The case grade is the §5.4.5 pack grade taken
304
+ * twice: signed (side map empty) and full (with it). The signed grade decides
305
+ * where it is `failed` or `malformed`; a failure only the full grade reaches is
306
+ * `incomplete`, since it rests on unsigned bytes; otherwise the full grade.
307
+ * An unreadable body is `malformed`; a non-event body takes the signed status;
308
+ * a record-count mismatch takes the signed status, or `malformed` where that is
655
309
  * `verified`.
656
310
  *
657
- * The exception carries `status`, the case grade; `causes`, the 5.4.5 cause
658
- * kinds of the grade that decided it (none in the first three branches); and
659
- * `reasons`, the verifier's reasons from the run with the side map empty. It
660
- * CONTRIBUTES the grade, and `incomplete` for `pending`, `not_found` or
661
- * `mixed`, so the case verdict never reads above its weakest pack.
311
+ * Carries `status`, `causes` (the deciding grade's cause kinds) and `reasons`
312
+ * (from the signed run). Contributes the grade, or `incomplete` for `pending`,
313
+ * `not_found` or `mixed`, so the case never reads above its weakest pack.
662
314
  */
663
315
  export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
664
316
 
665
317
  /**
666
- * A record in a pack whose wrapper holds, whose OWN envelope does not verify
667
- * (`EventResult.envelopeOk` is false). Not read by 4.1, and NAMED with the
668
- * verifier's reasons. `failed`: a pack its holder signed carries a record that
669
- * is proven not to verify. As a reason to drop the whole pack it let the
670
- * counterparty add one junk event to its own pack, re-seal it, and strip a
671
- * proven `message_hash` failure out of the case (gate 3 on proof#140).
318
+ * A record whose own envelope does not verify, in a pack whose wrapper holds.
319
+ * Not read by §4.1. `failed`. Scoped to the record so one junk event cannot
320
+ * drop the whole pack and a proven failure with it.
672
321
  */
673
322
  export const RECORD_ENVELOPE_DOES_NOT_VERIFY = "record_envelope_does_not_verify";
674
323
 
675
324
  /**
676
- * A record whose envelope verifies and which fails another check the bundle
677
- * verifier grades it on inside its pack (its inclusion proof, its join to a
678
- * checkpoint or stamp, a retired field name). NAMED with the verifier's
679
- * reasons, `failed`, and the record STAYS in the pile: a holder that could
680
- * remove its own tampered receipt from pairing by breaking that receipt's
681
- * inclusion proof would be choosing its own reading.
325
+ * A record whose envelope verifies but which fails another check in its pack
326
+ * (inclusion proof, checkpoint or stamp join, a retired field). `failed`, and
327
+ * the record stays in the pile, so breaking its own proof cannot remove a
328
+ * tampered receipt from pairing.
682
329
  */
683
330
  export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its_pack";
684
331
 
685
332
  /**
686
- * A record whose envelope verifies and whose disclosed content does not hash to
687
- * the hash it commits to, its `content_hash` or a hand-off record's
688
- * `message_hash` (evidence-bundle v1.19, A20) (`EventResult.contentOk` is false), with the
689
- * side map EMPTY: the content is inside the signed body. The same mismatch from
690
- * content handed in beside the pack is `RECORD_CONTENT_DEPENDENT` (proof#159).
691
- * `failed`, and the record STAYS in the pile: content beside a pack is unsigned and often
692
- * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
693
- * party erasing an honest verified pair by swapping it.
333
+ * A record whose disclosed content does not hash to its `content_hash` (or a
334
+ * hand-off record's `message_hash`) with the side map empty: the content is in
335
+ * the signed body. `failed`, and the record stays in the pile.
694
336
  */
695
337
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
696
338
 
697
339
  /**
698
- * The two readings proof#147 did not reach, given its shape (proof#159, ruled
699
- * by Phil 2026-09-22, evidence-bundle.md 5.5.4 v1.16, A17). A pack whose
700
- * bundle verdict is `failed` ONLY when read with the unsigned content handed
701
- * in beside it, and `failed` no longer when read with that map empty, is
702
- * `PACK_BUNDLE_CONTENT_DEPENDENT`; a record whose content mismatch comes from
703
- * that map alone is `RECORD_CONTENT_DEPENDENT`. Both are `incomplete`: the
704
- * reader established that the bytes it was handed are not the bytes the record
705
- * commits to, and nothing about the party that signed the record, because
706
- * whoever assembled the case chose those bytes with no key. Up to v1.15 both
707
- * readings carried `failed`, and one flipped nibble of accuser-supplied content
708
- * put a `failed` against an intact pack; under A17's headline rule that would
709
- * have become the case's headline.
710
- *
711
- * The test that separates the two is the one proof#159 states for the inverse:
712
- * every deviation a signature covers still reads `failed` with the side map
713
- * EMPTY. So the pack is verified twice, with the map and without it, and what
714
- * fails without it is `PACK_GRADE_NOT_VERIFIED`, `RECORD_CONTENT_MISMATCH`
715
- * (content inside the signed body) or `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`,
716
- * each still `failed`. Emptying the map is the most a party that signed
717
- * nothing can do to starve the check, and it cannot remove any of those.
340
+ * A pack whose bundle verdict is `failed` only with the unsigned side content,
341
+ * and a record whose content mismatch comes from that content alone
342
+ * (evidence-bundle §5.5.4). Both `incomplete`: whoever assembled the case chose
343
+ * those bytes with no key. Every deviation a signature covers still fails with
344
+ * the side map empty, so starving the check cannot hide one.
718
345
  */
719
346
  export const PACK_BUNDLE_CONTENT_DEPENDENT = "pack_bundle_content_dependent";
720
347
  export const RECORD_CONTENT_DEPENDENT = "record_content_dependent";
721
348
 
722
349
  /**
723
- * A record signed under a ratified profile that is under quarantine
724
- * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
725
- * `incomplete`: whether it verifies is what the kernels disagree about.
350
+ * A record under a quarantined profile (`CASE_QUARANTINED_PROFILES`). Not read.
351
+ * `incomplete`.
726
352
  */
727
353
  export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
728
354
 
@@ -730,18 +356,16 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
730
356
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
731
357
 
732
358
  /**
733
- * The three NOTEs (5.5.3 v1.20, extended proof#148): findings on the reading
734
- * with no contribution, printed in the scope sentence so a clean verdict
735
- * never hides any of them. `pack_undeclared` is a note only where the
736
- * reading holds no hand-off record; where it holds one, it is the exception
737
- * of that name.
359
+ * Notes (§5.5.3): findings with no verdict contribution, printed in the scope
360
+ * sentence so a clean verdict never hides them. `pack_undeclared` is a note
361
+ * only where the reading holds no hand-off record; otherwise it is the
362
+ * exception.
738
363
  */
739
364
  export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
740
365
  /**
741
- * A pack's wrapper carries a present `co_signatures` (evidence-bundle.md §2,
742
- * decided by Phil 2026-09-24 on proof#148). The field verifies nothing and
743
- * sits outside the wrapper's signed scope, so the case reading names it
744
- * rather than presenting it as evidence, and it contributes no verdict.
366
+ * A pack's wrapper carries `co_signatures` (evidence-bundle section 2). It verifies
367
+ * nothing and sits outside the signed scope, so it is named, not presented as
368
+ * evidence.
745
369
  */
746
370
  export const CO_SIGNATURES_PRESENT = "co_signatures_present";
747
371
  export const CASE_NOTE_KINDS = [
@@ -751,18 +375,17 @@ export const CASE_NOTE_KINDS = [
751
375
  ] as const;
752
376
  export type CaseNoteKind = (typeof CASE_NOTE_KINDS)[number];
753
377
 
754
- /**
755
- * Every exception this reader can report, as a closed set. A surface may render
756
- * them and a test may assert over them; nothing outside this array is emitted.
757
- */
378
+ /** Every exception this reader can report. Nothing outside this set is emitted. */
758
379
  export const CASE_EXCEPTION_KINDS = [
759
380
  COUNTERPART_ABSENT,
760
381
  COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS,
761
382
  COUNTERPART_ABSENT_SIGNER_UNKNOWN,
762
383
  COUNTERPART_AMBIGUOUS,
763
384
  COUNTERPART_MALFORMED,
385
+ COUNTERPART_NONCONFORMING,
764
386
  PAIR_FAILED,
765
387
  RECORD_MALFORMED,
388
+ RECORD_NONCONFORMING,
766
389
  RECORD_UNCLAIMED,
767
390
  RECORD_BYTES_WITHHELD,
768
391
  RECORD_BYTES_UNREADABLE,
@@ -772,6 +395,7 @@ export const CASE_EXCEPTION_KINDS = [
772
395
  PACK_UNDECLARED,
773
396
  PACK_BODY_TYPE_UNKNOWN,
774
397
  PACK_HOLDS_NO_RECORDS,
398
+ PACK_UNREADABLE,
775
399
  PACK_KEY_UNREADABLE,
776
400
  PACK_DOES_NOT_VERIFY,
777
401
  PACK_GRADE_NOT_VERIFIED,
@@ -787,354 +411,219 @@ export const CASE_EXCEPTION_KINDS = [
787
411
  export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
788
412
 
789
413
  /**
790
- * The status every record in every pack carries (evidence-bundle 5.5.4
791
- * v1.35, A43, proof#204), as a closed set. A record is listed for each element
792
- * of its pack's `body.events` as `packRecords` reads them, or, for a
793
- * `sanning.evidence.export/v2` pack, of the events of the inline source
794
- * bundle it wraps (`resolveTraceEvents`), and gets exactly one:
795
- *
796
- * - `read_clean`: the record went into the pile, no exception carries its
797
- * `packIndex` and `recordIndex`, and its pack's declaration check
798
- * names it by nothing (`declarationFindings` is empty). It says nothing
799
- * about its PACK: the pack's own grade and exceptions are on the pack, and a
800
- * surface MUST show that grade beside the status.
801
- * - `read_flagged`: the record went into the pile, and at least one
802
- * exception carries its `packIndex` and `recordIndex`, or its pack's
803
- * declaration check names its `eventId` by a deviation or a content
804
- * finding (`declarationFindings` is not empty; under an `eventId` the
805
- * pack carries twice, every record with that id). An exception about the
806
- * pack alone flags no record.
807
- * - `not_read`: the record never went into the pile. `reason` is the kind
808
- * of the exception that says why, on the record
809
- * (`record_profile_quarantined`, `record_envelope_does_not_verify`,
810
- * `record_bytes_withheld`, `record_bytes_unreadable`) or on its pack
811
- * (`pack_key_unreadable`, `pack_does_not_verify`,
812
- * `pack_body_type_unknown`, `pack_grade_not_verified` where the body
813
- * could not be read or the verifier graded a different number of records).
814
- * An export's wrapped records carry the reason of the exception their pack
815
- * actually got: `pack_body_type_unknown` where its wrapper holds, and
816
- * otherwise `pack_key_unreadable`, `pack_does_not_verify` or
817
- * `pack_grade_not_verified`.
818
- *
819
- * Before A43 the reading listed only the records it read, so a surface had to
820
- * work out which records were missing, and why, by a rule of its own.
414
+ * The status each record of each pack gets (evidence-bundle §5.5.4), one per
415
+ * element of `body.events` (or, for an export, its source bundle's events):
416
+ *
417
+ * - `read_clean`: in the pile, named by no exception and no declaration
418
+ * finding. Says nothing about its pack; a surface must show the pack grade
419
+ * beside it.
420
+ * - `read_flagged`: in the pile, and named by an exception or a declaration
421
+ * finding (every record sharing a duplicated `eventId`). A pack-only
422
+ * exception flags no record.
423
+ * - `not_read`: never in the pile. `reason` is the record or pack exception
424
+ * that says why; an export's wrapped records take their pack's exception.
821
425
  */
822
426
  export const CASE_RECORD_STATUSES = ["read_clean", "read_flagged", "not_read"] as const;
823
427
  export type CaseRecordStatus = (typeof CASE_RECORD_STATUSES)[number];
824
428
 
825
429
  /**
826
- * What each exception kind contributes to the case verdict. It is a TABLE and
827
- * not a chain of branches, so "which exceptions move the verdict" is one thing
828
- * a reader can read, and so that adding a kind without deciding what it means
829
- * is a missing entry rather than a quiet `verified`.
830
- *
831
- * Read the `incomplete` rows as "I could not tell", which is the distinction
832
- * this whole module turns on. None of them is `verified`: an exception never
833
- * contributes a clean verdict, which is what makes the invariant in `verdictOf`
834
- * hold.
835
- *
836
- * `PACK_GRADE_NOT_VERIFIED` is the one row that is not a constant: it
837
- * contributes the pack's case grade, which is never `verified` (the exception
838
- * is emitted only when it is not). `null` marks that row, and `readCase` fills
839
- * it from `status`.
430
+ * What each exception kind contributes to the case verdict, read off the claim it maps to
431
+ * (`KIND_CLAIMS` in `claims.ts`): a disproof is `failed`, anything not proven
432
+ * `incomplete`. A kind with no mapping throws rather than reading as a quiet `verified`.
433
+ * `null` marks `PACK_GRADE_NOT_VERIFIED`, which contributes the pack's case grade from
434
+ * `status`.
840
435
  */
841
- const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
842
- [COUNTERPART_ABSENT]: "incomplete",
843
- [COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS]: "incomplete",
844
- [COUNTERPART_ABSENT_SIGNER_UNKNOWN]: "incomplete",
845
- [COUNTERPART_AMBIGUOUS]: "incomplete",
846
- [COUNTERPART_MALFORMED]: "incomplete",
847
- [PAIR_FAILED]: "failed",
848
- [RECORD_MALFORMED]: "malformed",
849
- [RECORD_UNCLAIMED]: "incomplete",
850
- [RECORD_BYTES_WITHHELD]: "incomplete",
851
- [RECORD_BYTES_UNREADABLE]: "malformed",
852
- [PACK_DECLARATION_DEVIATES]: "failed",
853
- [PACK_DECLARATION_CONTENT_DEPENDENT]: "incomplete",
854
- [PACK_DECLARATION_UNREADABLE]: "incomplete",
855
- [PACK_UNDECLARED]: "partial",
856
- [PACK_BODY_TYPE_UNKNOWN]: "incomplete",
857
- [PACK_HOLDS_NO_RECORDS]: "incomplete",
858
- [PACK_KEY_UNREADABLE]: "incomplete",
859
- [PACK_DOES_NOT_VERIFY]: "failed",
860
- [PACK_GRADE_NOT_VERIFIED]: null,
861
- [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
862
- [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
863
- [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
864
- [RECORD_CONTENT_MISMATCH]: "failed",
865
- [RECORD_CONTENT_DEPENDENT]: "incomplete",
866
- [RECORD_PROFILE_QUARANTINED]: "incomplete",
867
- [CASE_HOLDS_NO_PACKS]: "partial",
868
- };
436
+ const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = Object.fromEntries(
437
+ CASE_EXCEPTION_KINDS.map((kind) => {
438
+ if (kind === PACK_GRADE_NOT_VERIFIED) return [kind, null];
439
+ const mapping = KIND_CLAIMS[kind];
440
+ if (mapping === undefined) throw new Error(`KeyError: '${kind}'`);
441
+ return [kind, mapping[1] === "disproven" ? "failed" : "incomplete"];
442
+ }),
443
+ ) as Record<CaseExceptionKind, CaseVerdict | null>;
869
444
 
870
445
  /**
871
- * The case grades `PACK_GRADE_NOT_VERIFIED` contributes as themselves. Every
872
- * other grade a pack can reach (`pending`, `not_found`, `mixed`, which a pack
873
- * grade passes through and a case verdict never issues) is `incomplete`.
874
- */
875
- const GRADE_CONTRIBUTES: ReadonlySet<string> = new Set([
876
- "partial",
877
- "incomplete",
878
- "failed",
879
- "malformed",
880
- ]);
881
-
882
- // ---- Inputs -----------------------------------------------------------------
883
-
884
- /**
885
- * One pack in a case, as a reader was handed it.
886
- *
887
- * `ref` is the reader's own name for the pack (a file name, an archive member)
888
- * and is never read from the pack itself: a value inside the pack is written by
889
- * the party that assembled it, and two packs are free to claim one name.
890
- *
891
- * A caller passes packs as it received them. `readCase` runs
892
- * `verifyEvidenceBundle` over each one itself, so `HeldRecord`'s precondition
893
- * (every envelope it compares has verified) is met by this module and cannot
894
- * be skipped by a caller. A pack that does not verify is
895
- * `PACK_DOES_NOT_VERIFY`.
446
+ * One pack in a case, as handed to the reader. `ref` is the reader's own name
447
+ * for it, never read from the pack, since two packs can claim one name.
448
+ * `readCase` verifies each pack itself, so the verified-envelope precondition
449
+ * of `HeldRecord` cannot be skipped by a caller.
896
450
  */
897
451
  export interface CasePack {
898
452
  ref: string;
899
453
  bundle: unknown;
900
454
  content?: SideContentMap | null;
455
+ /** Why the pack's `bundle.json` could not be read, where it could not; `bundle` is then ignored (`PACK_UNREADABLE`). */
456
+ unreadable?: string | null;
901
457
  }
902
458
 
903
- // ---- Outputs ----------------------------------------------------------------
904
-
905
- /** One named exception, as data. A surface renders its own prose or calls
906
- * `renderException`. */
459
+ /** One named exception, as data. A surface renders it or calls `renderException`. */
907
460
  export interface CaseException {
908
461
  kind: CaseExceptionKind;
909
- /**
910
- * Which pack this is about, by the reader's own name for it. Two packs can
911
- * carry one `ref`, so it is a label and never the join: `packIndex` is.
912
- */
462
+ /** The reader's name for the pack: a label, never the join (`packIndex` is). */
913
463
  packRef?: string;
914
- /**
915
- * Which pack this is about, by its position in `CaseReading.packs` (0
916
- * first), present wherever `packRef` is (A43). Unique in the reading.
917
- */
464
+ /** The pack's position in `CaseReading.packs`, wherever `packRef` is set. */
918
465
  packIndex?: number;
919
466
  /** Which record, by its envelope `event_id`. */
920
467
  eventId?: string;
921
468
  /**
922
- * Which record, by its position in its pack's `body.events` (0 first),
923
- * present wherever `eventId` is (A43). One pack can carry one `event_id`
924
- * twice, so `packIndex` and `recordIndex` together are the join to
925
- * `CaseReading.records`.
469
+ * The record's position in its pack's events, wherever `eventId` is set.
470
+ * With `packIndex`, the join to `CaseReading.records`, since one pack can
471
+ * carry an `event_id` twice.
926
472
  */
927
473
  recordIndex?: number;
928
- /**
929
- * The counterparty key the record named, for the two counterpart kinds.
930
- * Normalized (`normalizedSigningKey`), so a reader compares what this module
931
- * compared.
932
- */
474
+ /** The counterparty key the record named, normalized as this module compared it. */
933
475
  counterpartyKey?: string | null;
934
- /** Which checks a formed pair failed, in 4.1's fixed order. */
476
+ /** Which checks a formed pair failed, in §4.1's fixed order. */
935
477
  failedChecks?: string[];
936
478
  /**
937
- * How many of a thing the exception is about, where it is about a count. On
938
- * `COUNTERPART_ABSENT` it is set only where 4.1 set every candidate aside
939
- * (v1.17, A20), and it is how many were set aside.
479
+ * The count the exception is about. On `COUNTERPART_ABSENT`, set only where
480
+ * every candidate was set aside, and it is how many were.
940
481
  */
941
482
  count?: number;
942
483
  /** The pack's case grade, for `PACK_GRADE_NOT_VERIFIED`. */
943
484
  status?: CaseVerdict;
944
485
  /**
945
- * The 5.4.5 cause kinds of the grade that decided the case grade, for
946
- * `PACK_GRADE_NOT_VERIFIED`, in the grade's order. Empty where the grade was
947
- * not computed (an unreadable body, a body that is not one of the two
948
- * event-carrying ones, a verifier that graded too few records).
486
+ * For `PACK_GRADE_NOT_VERIFIED`, the §5.4.5 cause kinds of the deciding grade.
487
+ * Empty where no grade was computed.
949
488
  */
950
489
  causes?: PackGradeCauseKind[];
951
490
  /**
952
- * What the bundle verifier said, as it said it, for the kinds that carry a
953
- * verifier's grading: `PACK_GRADE_NOT_VERIFIED` and the three record kinds
954
- * graded on the record's own envelope and content.
491
+ * The bundle verifier's own reasons, for `PACK_GRADE_NOT_VERIFIED` and the
492
+ * record kinds graded on the record's envelope and content.
955
493
  */
956
494
  reasons?: string[];
495
+ /**
496
+ * For `PACK_GRADE_NOT_VERIFIED`, every problem behind the grade, each mapped to one
497
+ * claim and one state (`claims.ts`), at this pack's position. A grade that is not
498
+ * `failed` carries no disproof: one that only the unsigned side map reached reads
499
+ * `not_proven`.
500
+ */
501
+ problems?: Problem[];
957
502
  }
958
503
 
959
504
  /**
960
- * One record of one pack, with its status and, where it was read, 4.1's
961
- * reading of it.
962
- *
963
- * EVERY record of every pack gets one of these (A43), in pack order and then
964
- * in bundle order: the ones this reader read, including the ones that are not
965
- * hand-off records at all (`findings` carries `not_a_handoff_record`), and the
966
- * ones it did not, with `status` `not_read` and the `reason`. 4.1 (v1.9, A14)
967
- * names why: the one thing an attacker wants is for a record to be absent from
968
- * the output. An export's wrapped records are listed, `not_read`. A pack whose
969
- * body carries no `events` array, and wraps none, lists none, and its own
970
- * exception says why.
505
+ * One record of one pack, with its status and, where read, §4.1's reading.
506
+ * Every record of every pack is listed, in pack then bundle order, read or
507
+ * not, because the one thing an attacker wants is a record absent from the
508
+ * output. A pack with no `events` array lists none; its exception says why.
971
509
  */
972
510
  export interface CaseRecordReading {
973
511
  packRef: string;
974
- /** The pack, by its position in `CaseReading.packs` (A43). */
512
+ /** The pack's position in `CaseReading.packs`. */
975
513
  packIndex: number;
976
514
  eventId: string;
977
515
  /**
978
- * The record, by its position in the list of records its pack lists (A43):
979
- * `body.events`, or for a `sanning.evidence.export/v2` pack the events of the
980
- * source bundle it wraps.
516
+ * The record's position in its pack's events (for an export, its source
517
+ * bundle's events).
981
518
  */
982
519
  recordIndex: number;
983
- /** One of `CASE_RECORD_STATUSES` (A43). */
984
520
  status: CaseRecordStatus;
985
- /**
986
- * For `not_read`, the kind of the exception that says why; `null` for a
987
- * record that was read.
988
- */
521
+ /** For `not_read`, the exception kind that says why; else `null`. */
989
522
  reason: CaseExceptionKind | null;
990
523
  /**
991
- * The kinds its pack's declaration check names this record by, deviations
992
- * (`DECLARATION_DEVIATION_KINDS`) then content findings
993
- * (`DECLARATION_CONTENT_KINDS`), each in the check's own order. Matched on
994
- * `eventId`, so under an `event_id` the pack carries twice every record with
995
- * that id carries it. A record that went into the pile with any is
996
- * `read_flagged` (A43).
524
+ * The declaration check's deviations then content findings for this record's
525
+ * `eventId` (every record sharing a duplicated id). Any makes a read record
526
+ * `read_flagged`.
997
527
  */
998
528
  declarationFindings: string[];
999
- /** 4.1's verdict, or `null` where 4.1 gave none or the record was not read. */
529
+ /** §4.1's verdict, or `null` where it gave none or the record was not read. */
1000
530
  verdict: string | null;
1001
531
  findings: string[];
1002
532
  failedChecks: string[];
1003
533
  /**
1004
- * 4.1's candidate counterparts, each as `<pack ref>/<event_id>`. A LABEL for
1005
- * a person to read and never the join: two packs can carry one ref, and one
1006
- * pack one `event_id` twice (A43).
534
+ * §4.1's candidate counterparts as `<pack ref>/<event_id>`: labels for a
535
+ * person, never the join.
1007
536
  */
1008
537
  counterparts: string[];
1009
538
  /**
1010
- * The role the pack's v2 declaration signs for this record, `handoff` or
1011
- * `step`, or `null` where the pack's declaration is absent, unreadable or
1012
- * v1, or does not declare the record (5.5.1 v1.20).
539
+ * The role the pack's v2 declaration signs for this record, or `null` where
540
+ * the declaration is absent, unreadable, v1 or silent on it (§5.5.1).
1013
541
  */
1014
542
  role: DeclarationRole | null;
543
+ /**
544
+ * The `sanning.agent` reading of this record (agent-events.md section 10), read with
545
+ * every record of every pack in the case, or `null` where step 1 gives it no
546
+ * verdict or it was not read. Its findings move no verdict; a `nonconforming`
547
+ * or `partial` reading moves its pack's grade (section 5.4.5).
548
+ */
549
+ agent: AgentRecordReading | null;
1015
550
  }
1016
551
 
1017
- /**
1018
- * One NOTE on the reading (5.5.3 v1.20): something true a reader needs and
1019
- * nothing a reader could not tell, so it carries no contribution.
1020
- */
552
+ /** A note on the reading (§5.5.3): true and needed, with no contribution. */
1021
553
  export interface CaseNote {
1022
554
  kind: CaseNoteKind;
1023
- /** Which pack, by the reader's own name for it. */
1024
555
  packRef: string;
1025
- /** Which pack, by its position in `CaseReading.packs` (A43). */
1026
556
  packIndex: number;
1027
- /**
1028
- * For `CONTENT_WITHHELD_BY_DECLARATION`, how many of the pack's records its
1029
- * declaration withholds.
1030
- */
557
+ /** For `CONTENT_WITHHELD_BY_DECLARATION`, how many records are withheld. */
1031
558
  count?: number;
1032
559
  }
1033
560
 
1034
561
  /**
1035
- * One pack as the reading read it, in the order the reader was given them
1036
- * (5.5.4 v1.20). A surface names a pack by `signingKey`, the key that signed
1037
- * its wrapper, and never by an organization (D10).
562
+ * One pack as read, in the order given (§5.5.4). A surface names a pack by
563
+ * `signingKey`, never by an organization.
1038
564
  */
1039
565
  export interface CasePackReading {
1040
- /**
1041
- * The pack's position in the order the reader was given the packs, 0 first
1042
- * (A43): the one name for a pack that is unique in the reading. Every
1043
- * exception, note and record names its pack by it as `packIndex`.
1044
- */
566
+ /** The pack's position in the order given: its one unique name in the reading. */
1045
567
  index: number;
1046
- /** The reader's own name for the pack. Two packs can carry the same one. */
568
+ /** The reader's own name for the pack. Not unique. */
1047
569
  ref: string;
1048
570
  /** The wrapper key, normalized, or `null` where this reader could not read it. */
1049
571
  signingKey: string | null;
1050
- /**
1051
- * The case grade (5.5.1 step 1), or `null` where the wrapper did not hold or
1052
- * the key could not be read.
1053
- */
572
+ /** The case grade (§5.5.1), or `null` where the wrapper did not hold or the key is unreadable. */
1054
573
  grade: PackVerdict | null;
1055
- /**
1056
- * Records withheld by the pack's declaration (5.4.5): in scope for their
1057
- * signature and commitment only. Never counted as verified records.
1058
- */
574
+ /** Records withheld by the declaration (§5.4.5). Never counted as verified. */
1059
575
  recordsWithheld: number;
1060
- /**
1061
- * `sanning.declaration/v1`, `sanning.declaration/v2`, or `null` where the
1062
- * pack carries no declaration, one this reader does not know, or a body this
1063
- * reader does not read.
1064
- */
576
+ /** The declaration version, or `null` where absent, unknown or in an unread body. */
1065
577
  declarationVersion: string | null;
1066
578
  }
1067
579
 
1068
580
  export interface CaseReading {
1069
581
  verdict: CaseVerdict;
582
+ /** `changed` or `nonconforming` where `verdict` is `failed`. */
583
+ reasonKind?: ReasonKind;
1070
584
  /**
1071
- * Every exception, in a fixed order: the pack-level ones for each pack in the
1072
- * order the reader was given the packs, then the record-level ones in pack
1073
- * order and bundle order.
1074
- *
1075
- * **An empty array is the only shape a reader may read as clean**, and it is
1076
- * reachable only together with `verdict === "verified"`.
585
+ * Every problem behind the verdict, each mapped to one claim and one state, in
586
+ * exception order. Empty exactly where `verdict` is `verified`.
1077
587
  */
1078
- exceptions: CaseException[];
588
+ problems: Problem[];
1079
589
  /**
1080
- * One entry per record of every pack, read or not, in pack order and then
1081
- * bundle order, each with its status (A43).
590
+ * Every exception: pack-level ones in pack order, then record-level ones in
591
+ * pack and bundle order. Empty is the only clean shape, and only with
592
+ * `verdict === "verified"`.
1082
593
  */
594
+ exceptions: CaseException[];
595
+ /** One entry per record of every pack, read or not. */
1083
596
  records: CaseRecordReading[];
1084
597
  /**
1085
- * Every `[contributor, verdict]` the case verdict was computed from, in
1086
- * order. Carried so the verdict is auditable rather than asserted, and so two
1087
- * kernels compare the derivation and not only its answer.
598
+ * Every `[contributor, verdict]` the verdict was computed from, so the
599
+ * verdict is auditable rather than asserted.
1088
600
  */
1089
601
  contributions: [string, CaseVerdict][];
1090
- /** How many hand-off records the case held. Zero is its own exception. */
602
+ /** Hand-off records in the case. Zero is its own exception. */
1091
603
  handoffRecords: number;
1092
604
  /**
1093
- * Whether any witness behind any pack in this case was re-fetched on chain.
1094
- * It is **false** in every reading this function produces, and it is here to
1095
- * be READ rather than inferred.
1096
- *
1097
- * Gate 4 on proof#140, finding 3. A pack is `partial` only when `anchor_ref`
1098
- * is ABSENT, so the honest omission is named and the unverifiable assertion
1099
- * is not: a holder that writes any `anchor_ref` at all reads `verified`,
1100
- * because this reader makes no network call. That is the standing lens
1101
- * inverted, and the answer is not to refuse the assertion, which no offline
1102
- * reader can grade. It is to say what was not checked, so nobody reads a
1103
- * clean case as "a witness held". Re-fetching on chain stays the caller's
1104
- * job, exactly as `evidence-bundle.md` 5.5's does-not-claim list says.
1105
- *
1106
- * Derived from each pack's own `EvidenceBundleResult.onChainChecked` rather
1107
- * than written as a constant, so it stays true of what was actually run:
1108
- * this reader passes no gateways, and it is the ALL of an empty list of
1109
- * checked packs, which is false.
605
+ * Whether any witness was re-fetched on chain. Always false here: this reader
606
+ * makes no network call, so a pack with any `anchor_ref` reads `verified`
607
+ * without the witness being checked. Stated so nobody reads a clean case as
608
+ * "a witness held"; re-fetching is the caller's (evidence-bundle §5.5).
609
+ * Derived from each pack's `onChainChecked`, not written as a constant.
1110
610
  */
1111
611
  onChainChecked: boolean;
1112
- /** The NOTEs, in pack order (5.5.3 v1.20). No contribution. */
612
+ /** Notes, in pack order (§5.5.3). No contribution. */
1113
613
  notes: CaseNote[];
1114
- /** One reading per pack, in the order the reader was given them. */
614
+ /** One reading per pack, in the order given. */
1115
615
  packs: CasePackReading[];
1116
616
  }
1117
617
 
1118
- // ---- The reading ------------------------------------------------------------
1119
-
1120
618
  const bodyTypeOf = (bundle: unknown): unknown =>
1121
619
  typeof bundle === "object" && bundle !== null
1122
620
  ? (bundle as Record<string, unknown>)["body_type"]
1123
621
  : undefined;
1124
622
 
1125
623
  /**
1126
- * The key that SIGNED the pack wrapper, which is the key that signed the pack's
1127
- * declaration.
1128
- *
1129
- * The wrapper signature covers `body_hash` and `body` carries the
1130
- * `declaration` (`evidence-bundle.md` 5.1, 5.4.1), so this key is the party
1131
- * whose signed account of itself this pack is. Read from the ENVELOPE of the
1132
- * pack and never from `issuer`: `issuer` is a label and DL-60 keeps it out of
1133
- * every reading, while `public_key` is the value the signature binds.
1134
- *
1135
- * It means something only for a pack whose wrapper HOLDS, which is why
1136
- * `readCase` counts it only after its own `verifyEvidenceBundle` call over the
1137
- * pack returned `signatureOk` and `bodyHashOk`.
624
+ * The key that signed the pack wrapper, and so the pack's declaration
625
+ * (evidence-bundle §5.1, §5.4.1). Read from `public_key`, never `issuer`, which
626
+ * is a label. Meaningful only once the wrapper is verified to hold.
1138
627
  */
1139
628
  const holderKeyOf = (bundle: unknown): unknown =>
1140
629
  typeof bundle === "object" && bundle !== null
@@ -1142,41 +631,32 @@ const holderKeyOf = (bundle: unknown): unknown =>
1142
631
  : undefined;
1143
632
 
1144
633
  /**
1145
- * Provenance, and nothing else: the wrapper signature over `body_hash` verified
1146
- * and `body_hash` is the hash of the body (evidence-bundle.md 5.5.1 step 1).
1147
- * Nothing inside the body is part of this answer.
634
+ * Provenance only: the wrapper signature verified and `body_hash` matches the
635
+ * body (evidence-bundle §5.5.1). Under a version this verifier does not know, the
636
+ * signature alone ties the pack to its key, and the body is not read.
1148
637
  */
1149
638
  const wrapperHolds = (result: EvidenceBundleResult): boolean =>
1150
- result.signatureOk && result.bodyHashOk;
639
+ result.signatureOk && (result.bodyHashOk || result.versionUnknown === true);
1151
640
 
1152
641
  /**
1153
- * Why the pack reads what it reads, as the verifier said it: its bundle errors,
1154
- * then each stamp's, then each checkpoint's, then each event's behind the
1155
- * event's own id.
1156
- *
1157
- * The event errors are here as well as on the records' own exceptions on
1158
- * purpose. A pack whose only defect is one record reads `failed` with no
1159
- * bundle-level error at all, and an exception that named the verdict and no
1160
- * reason would send a reader looking for a defect in the pack as a whole.
1161
- */
1162
- /**
1163
- * The reasons a reading carries only because of the side map, in the order the
1164
- * verifier wrote them: what the content-dependent kinds are about, and nothing
1165
- * a signature covers.
642
+ * Reasons present only with the side map: what the content-dependent kinds are
643
+ * about, and nothing a signature covers.
1166
644
  */
1167
645
  const onlyIn = (withMap: readonly string[], withoutMap: readonly string[]): string[] =>
1168
646
  withMap.filter((reason) => !withoutMap.includes(reason));
1169
647
 
1170
- /**
1171
- * Whether a side-content map holds any key. Python's `if pack.content` is
1172
- * the twin: `None` and an empty map both mean the two verify runs are one run.
1173
- */
648
+ /** `null`, `undefined` and an empty map all mean the two verify runs are one. */
1174
649
  function sideMapHasKeys(content: CasePack["content"]): boolean {
1175
650
  if (content === null || content === undefined) return false;
1176
651
  if (content instanceof Map) return content.size > 0;
1177
652
  return Object.keys(content as object).length > 0;
1178
653
  }
1179
654
 
655
+ /**
656
+ * The verifier's reasons for a pack: bundle, then stamps, checkpoints and
657
+ * events (prefixed with the event id). Event errors are included because a pack
658
+ * whose only defect is one record has no bundle-level error.
659
+ */
1180
660
  const bundleReasons = (result: EvidenceBundleResult): string[] => [
1181
661
  ...result.errors,
1182
662
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -1185,9 +665,8 @@ const bundleReasons = (result: EvidenceBundleResult): string[] => [
1185
665
  ];
1186
666
 
1187
667
  /**
1188
- * The `spec_version` of the envelope of `body.events[index]`, as written.
1189
- * `packRecords` and the bundle verifier both walk `body.events` in order, so
1190
- * `index` is the same record in both.
668
+ * The `spec_version` of `body.events[index]`'s envelope. `packRecords` and the
669
+ * verifier walk events in the same order.
1191
670
  */
1192
671
  function recordSpecVersion(bundle: unknown, index: number): unknown {
1193
672
  const at = (v: unknown, k: string | number): unknown =>
@@ -1197,8 +676,8 @@ function recordSpecVersion(bundle: unknown, index: number): unknown {
1197
676
  }
1198
677
 
1199
678
  /**
1200
- * `specVersion` is a quarantined profile, on the same token boundary the
1201
- * envelope layer's own accept-check uses (`specVersionSupported`).
679
+ * Whether `specVersion` is quarantined, on the token boundary
680
+ * `specVersionSupported` uses.
1202
681
  */
1203
682
  function underQuarantine(specVersion: unknown): boolean {
1204
683
  if (typeof specVersion !== "string") return false;
@@ -1211,46 +690,32 @@ function underQuarantine(specVersion: unknown): boolean {
1211
690
  }
1212
691
 
1213
692
  /**
1214
- * The case verdict, from the contributions alone.
693
+ * The case verdict, from the contributions alone: `failed` where any contribution is,
694
+ * else `incomplete` where any is, else `verified`. A case reads its worst pack, and every
695
+ * exception is still named beside it.
1215
696
  *
1216
- * **A proven failure keeps the headline** (`evidence-bundle.md` 5.5.4 v1.16,
1217
- * A17, proof#158): if any contribution is `failed`, the case is `failed`,
1218
- * whatever else contributes, and every other exception stays named beside it.
1219
- * Otherwise one outcome is that outcome, and more than one is `mixed`
1220
- * (anchor#198, criterion 4), so `mixed` never holds a `failed`. `malformed`
1221
- * does NOT keep the headline: it says a record cannot be read, not that a
1222
- * check ran and did not pass. `mixed` is in the 3 vocabulary and coins no word.
1223
- *
1224
- * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
1225
- * reachable ONLY when `exceptions` is empty. Every exception kind carries a
1226
- * non-`verified` contribution (`EXCEPTION_VERDICT`), so this falls out of the
1227
- * table rather than being enforced by a second rule that could drift from it.
1228
- * The assertion is here anyway, because that is the property every one of the
1229
- * seventeen findings on this program violated.
697
+ * Invariant: `verified` only when `exceptions` is empty. It follows from
698
+ * `EXCEPTION_VERDICT` and is asserted anyway.
1230
699
  */
1231
700
  function verdictOf(
1232
701
  exceptions: readonly CaseException[],
1233
702
  contributions: readonly [string, CaseVerdict][],
1234
703
  holdsPacks = true,
1235
704
  ): CaseVerdict {
1236
- // A reading that holds at least one pack and no contribution at all is
1237
- // `verified` (5.5.4 v1.20, A23): every pack in it graded `verified` and
1238
- // nothing in it was an exception. Every exception contributes, so no
1239
- // contribution means no exception. A reading with no packs always carries
1240
- // `case_holds_no_packs`, and is defended here anyway: an empty aggregate over
1241
- // nothing reading as `verified` is the defect class itself.
705
+ // No contribution means every pack graded `verified` and no exception. A
706
+ // reading with no packs is `incomplete`, never `verified` over nothing.
1242
707
  if (contributions.length === 0) {
1243
708
  if (exceptions.length > 0) {
1244
709
  throw new Error(`exceptions with no contribution: ${exceptions.map((e) => e.kind).join(", ")}`);
1245
710
  }
1246
- return holdsPacks ? "verified" : "partial";
711
+ return holdsPacks ? "verified" : "incomplete";
1247
712
  }
1248
713
  const outcomes = new Set(contributions.map(([, v]) => v));
1249
714
  const verdict: CaseVerdict = outcomes.has("failed")
1250
715
  ? "failed"
1251
- : outcomes.size === 1
1252
- ? [...outcomes][0]
1253
- : "mixed";
716
+ : outcomes.has("incomplete")
717
+ ? "incomplete"
718
+ : "verified";
1254
719
  if (verdict === "verified" && exceptions.length > 0) {
1255
720
  throw new Error(
1256
721
  `verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`,
@@ -1259,38 +724,224 @@ function verdictOf(
1259
724
  return verdict;
1260
725
  }
1261
726
 
727
+ function at(pack: number | null, record: number | null = null): ProblemItem[] {
728
+ return [[pack, record]];
729
+ }
730
+
731
+ // The envelope faults a record's pack already names with their own claim, so the case does
732
+ // not also call the envelope one whose signature fails.
733
+ const ENVELOPE_FAULTS_OF_THE_PACK: readonly string[] = [
734
+ "payload_unbound",
735
+ "not_an_envelope",
736
+ "key_unreadable",
737
+ "spec_version_unknown",
738
+ "nonconforming",
739
+ ];
740
+
741
+ /** A pack's own problems (read at position 0) at its position in the case. */
742
+ function moved(problems: readonly Problem[], position: number): Problem[] {
743
+ return problems.map((p) => withItems(p, p.items.map(([, ...rest]) => [position, ...rest] as ProblemItem)));
744
+ }
745
+
1262
746
  /**
1263
- * Read a whole case: several packs in, one verdict and its exceptions out.
1264
- *
1265
- * The order of the work, and why it is this order:
1266
- *
1267
- * 1. **Each pack is read into records**, through `packRecords`, which is the
1268
- * one implementation of "what does this pack hold". A pack whose `body_type`
1269
- * this reader does not know, or which holds no records, is NAMED here and
1270
- * its records are not silently zero.
1271
- * 2. **Each pack is graded against its own declaration**, through
1272
- * `checkDeclaration`, which is the one implementation of 5.4. A pack proven
1273
- * to deviate from its own claim fails the case.
1274
- * 3. **Every record every pack supplied goes into ONE pile**, with no partition
1275
- * by pack, by issuer or by organization, and the pile is read by
1276
- * `readHandoffs`, which is the one implementation of 4.1. This is where
1277
- * matching happens, and it is on keys and hashes alone.
1278
- * 4. **Each reading becomes an exception or a clean contribution**, and the two
1279
- * counterpart kinds are separated by asking whether a pack SIGNED by the
1280
- * counterparty's key is in the case, a key read from every pack's wrapper
1281
- * before any exit can skip it.
1282
- *
1283
- * A record whose bytes the pack did not supply never reaches the pile, because
1284
- * there is nothing to put in it. That is exactly the shape this module has to
1285
- * refuse to pass over in silence, so it is named at step 1 and carries
1286
- * `incomplete`: the reader genuinely cannot tell whether the record it cannot
1287
- * see is the counterpart that is missing.
747
+ * Every exception, mapped to its claim and state, in exception order. A pack grade
748
+ * carries its own problems; a pack grade with none (a defence) reads as its status.
749
+ */
750
+ export function caseProblems(exceptions: readonly CaseException[]): Problem[] {
751
+ const out: Problem[] = [];
752
+ for (const e of exceptions) {
753
+ let items: ProblemItem[];
754
+ if (e.recordIndex !== undefined) items = at(e.packIndex ?? null, e.recordIndex);
755
+ else if (e.packIndex !== undefined) items = at(e.packIndex);
756
+ else items = at(null);
757
+ if (e.kind === PACK_GRADE_NOT_VERIFIED) {
758
+ if (e.problems !== undefined && e.problems.length > 0) out.push(...e.problems);
759
+ else if (e.status === "failed") out.push(problem("bundle_does_not_verify", items));
760
+ else out.push(problem("bundle_not_checked", items));
761
+ continue;
762
+ }
763
+ if (e.kind === PAIR_FAILED && e.failedChecks !== undefined && e.failedChecks.length > 0) {
764
+ // One problem per check that failed, so each is said in its own words.
765
+ for (const c of e.failedChecks) out.push(problem(e.kind, items, { rule: c }));
766
+ continue;
767
+ }
768
+ out.push(
769
+ problem(e.kind, items, {
770
+ key: e.kind.startsWith("counterpart_absent") ? (e.counterpartyKey ?? null) : null,
771
+ }),
772
+ );
773
+ }
774
+ return out;
775
+ }
776
+
777
+ /**
778
+ * The reading, with its problems and the verdict they give, which must be the verdict
779
+ * the contributions give: two derivations of one answer, held to agree.
780
+ */
781
+ function withProblems(reading: Omit<CaseReading, "problems" | "reasonKind">): CaseReading {
782
+ const problems = caseProblems(reading.exceptions);
783
+ const outcome = claimsVerdictOf(claimsOf(problems));
784
+ if (outcome.status !== reading.verdict) {
785
+ throw new Error(
786
+ `two derivations of one verdict disagree: ${outcome.status} and ${reading.verdict}`,
787
+ );
788
+ }
789
+ return {
790
+ ...reading,
791
+ ...(outcome.reason_kind === undefined ? {} : { reasonKind: outcome.reason_kind }),
792
+ problems,
793
+ };
794
+ }
795
+
796
+ /** One pack's grade in a case, and what it rests on. */
797
+ interface PackGrading {
798
+ grade: PackVerdict;
799
+ problems: Problem[];
800
+ causes: PackGradeCauseKind[];
801
+ check: DeclarationCheck | null;
802
+ full: PackGrade | null;
803
+ records: ReturnType<typeof packRecords>;
804
+ ungraded: boolean;
805
+ knownBody: boolean;
806
+ }
807
+
808
+ /**
809
+ * The pack's grade, read as `verify --logs` reads it: no disproof rests on bytes nobody
810
+ * signed.
811
+ */
812
+ async function gradeInCase(
813
+ pack: CasePack,
814
+ verified: EvidenceBundleResult,
815
+ signed: EvidenceBundleResult,
816
+ position: number,
817
+ ): Promise<PackGrading> {
818
+ const bodyType = bodyTypeOf(pack.bundle);
819
+ const records = packRecords(pack.bundle, pack.content);
820
+ // Per-record gradings are joined to `records` by index. A pack whose
821
+ // `body_hash` does not cover its body reads `failed` with no events, so a
822
+ // count mismatch is graded, never indexed into.
823
+ const ungraded =
824
+ verified.events.length !== records.length || signed.events.length !== records.length;
825
+ const knownBody = (CASE_BODY_TYPES as readonly unknown[]).includes(bodyType);
826
+
827
+ // The pack's grade propagates: the case never reads above its weakest pack.
828
+ let check: DeclarationCheck | null = null;
829
+ let full: PackGrade | null = null;
830
+ let causes: PackGradeCauseKind[] = [];
831
+ let grade: PackVerdict;
832
+ let packProblems: Problem[] = bundleProblems(signed, position);
833
+ if (signed.unreadable === true) {
834
+ // A body that cannot be read under a wrapper whose time breaks its format fails.
835
+ grade = signed.status === "failed" ? "failed" : "incomplete";
836
+ } else if (!knownBody) {
837
+ grade = signed.status as PackVerdict;
838
+ } else if (ungraded) {
839
+ grade = signed.status === "verified" ? "incomplete" : (signed.status as PackVerdict);
840
+ if (signed.status === "verified") packProblems = [problem("bundle_not_checked", at(position))];
841
+ } else {
842
+ // The declaration's chain rule reads the same signed chain links for both
843
+ // checks (evidence-bundle §5.4.2).
844
+ const chainLinks = await readChainLinks(pack.bundle);
845
+ check = checkDeclaration(pack.bundle, pack.content, chainLinks);
846
+ const completeness = completenessNotes(pack.bundle);
847
+ full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
848
+ const bareGrade = gradePack(
849
+ signed,
850
+ accountDisclosure(pack.bundle, {}),
851
+ completeness,
852
+ checkDeclaration(pack.bundle, null, chainLinks),
853
+ );
854
+ // No disproof here rests on unsigned bytes: each disproof the pack read with the
855
+ // bytes beside it makes and the signed reading does not is not proven, as `verify`
856
+ // reads it, and everything else is the reading with those bytes, so a pack's logs/
857
+ // count in `case` as they do in `verify --logs`.
858
+ packProblems = moved(restOnSigned(full.problems, bareGrade.problems), position);
859
+ grade = claimsVerdictOf(claimsOf(packProblems)).status;
860
+ causes = full.causes.map((c) => c.kind);
861
+ }
862
+ return { grade, problems: packProblems, causes, check, full, records, ungraded, knownBody };
863
+ }
864
+
865
+ /**
866
+ * Each pack that reads as a pack, verified twice: with the side map, and with
867
+ * it empty. What fails only in the first rests on bytes nobody
868
+ * signed. One run where there is no map. Offline, no gateways.
869
+ */
870
+ async function verifyPacks(
871
+ packs: readonly CasePack[],
872
+ ): Promise<Map<number, [EvidenceBundleResult, EvidenceBundleResult]>> {
873
+ const runs = new Map<number, [EvidenceBundleResult, EvidenceBundleResult]>();
874
+ for (const [position, pack] of packs.entries()) {
875
+ if (typeof pack.unreadable === "string") continue;
876
+ const verified = await verifyEvidenceBundle(pack.bundle, {
877
+ content: (pack.content ?? undefined) as
878
+ | Record<string, Uint8Array | string>
879
+ | ReadonlyMap<string, Uint8Array | string>
880
+ | undefined,
881
+ });
882
+ const signed = sideMapHasKeys(pack.content)
883
+ ? await verifyEvidenceBundle(pack.bundle, {})
884
+ : verified;
885
+ runs.set(position, [verified, signed]);
886
+ }
887
+ return runs;
888
+ }
889
+
890
+ /**
891
+ * Read the agent records of every pack as one pile and put each reading on
892
+ * both runs of its pack, in place of the reading over its own pack alone
893
+ * (agent-events.md section 10 step 7): pairing, chains and closing lists span packs.
894
+ * A record takes part where its pack's wrapper holds, its pack's records are
895
+ * all graded, and its envelope verified with its record bytes bound to it.
896
+ */
897
+ async function readAgentRecordsAcross(
898
+ packs: readonly CasePack[],
899
+ runs: Map<number, [EvidenceBundleResult, EvidenceBundleResult]>,
900
+ ): Promise<void> {
901
+ const items: AgentHeldRecord[] = [];
902
+ const where: [number, number][] = [];
903
+ for (const [position, [verified, signed]] of runs) {
904
+ if (!wrapperHolds(verified)) continue;
905
+ const records = packRecords(packs[position]!.bundle, packs[position]!.content);
906
+ if (verified.events.length !== records.length || signed.events.length !== records.length) continue;
907
+ for (const [index, record] of records.entries()) {
908
+ const graded = verified.events[index]!;
909
+ graded.agent = null;
910
+ signed.events[index]!.agent = null;
911
+ if (graded.envelopeOk && graded.payloadBindingOk === true && record.record !== null) {
912
+ items.push({ event_id: record.eventId, public_key: record.publicKey, record: record.record });
913
+ where.push([position, index]);
914
+ }
915
+ }
916
+ }
917
+ const readings = await readAgentRecords(items);
918
+ for (const [i, [position, index]] of where.entries()) {
919
+ const [verified, signed] = runs.get(position)!;
920
+ verified.events[index]!.agent = readings[i]!;
921
+ signed.events[index]!.agent = readings[i]!;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Reads a case: packs in, one verdict and its exceptions out.
927
+ *
928
+ * 1. Each pack is read into records (`packRecords`); an unknown body or no
929
+ * records is named.
930
+ * 2. Each pack is graded against its own declaration (`checkDeclaration`).
931
+ * 3. Every record goes into one pile, unpartitioned, read by `readHandoffs`.
932
+ * 4. Each reading becomes an exception or a clean contribution. The
933
+ * counterpart kinds are told apart by whether a pack signed by the
934
+ * counterparty's key is in the case, read from every wrapper before any exit.
935
+ *
936
+ * Before step 1 the agent records of every pack are read as one pile
937
+ * (agent-events.md section 10), and each pack is graded with those readings
938
+ * (evidence-bundle.md section 5.4.5, section 5.5.1).
1288
939
  */
1289
940
  export async function readCase(
1290
941
  packs: readonly CasePack[],
1291
942
  ): Promise<CaseReading> {
1292
- // A `null` slot is held for each pack that carries no declaration, and
1293
- // settled once the pile says whether the reading holds a hand-off record.
943
+ // A `null` slot is held for each undeclared pack until the pile says whether
944
+ // the reading holds a hand-off record.
1294
945
  const exceptions: (CaseException | null)[] = [];
1295
946
  const contributions: ([string, CaseVerdict] | null)[] = [];
1296
947
 
@@ -1299,14 +950,10 @@ export async function readCase(
1299
950
  fields: Omit<CaseException, "kind"> = {},
1300
951
  ) => {
1301
952
  exceptions.push({ kind, ...fields });
1302
- // `null` is PACK_GRADE_NOT_VERIFIED: the pack's case grade, never
1303
- // verified, and `incomplete` for a word a pack grade passes through and a
1304
- // case verdict never issues (5.5.1 step 1 v1.20).
953
+ // `null` is PACK_GRADE_NOT_VERIFIED: contribute the pack's case grade, never
954
+ // verified (section 5.5.1 step 1).
1305
955
  let contribution = EXCEPTION_VERDICT[kind];
1306
- if (contribution === null) {
1307
- const status = fields.status as string;
1308
- contribution = GRADE_CONTRIBUTES.has(status) ? (status as CaseVerdict) : "incomplete";
1309
- }
956
+ if (contribution === null) contribution = fields.status as CaseVerdict;
1310
957
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
1311
958
  };
1312
959
 
@@ -1314,7 +961,7 @@ export async function readCase(
1314
961
  note(CASE_HOLDS_NO_PACKS);
1315
962
  const named = exceptions as CaseException[];
1316
963
  const contributed = contributions as [string, CaseVerdict][];
1317
- return {
964
+ return withProblems({
1318
965
  verdict: verdictOf(named, contributed, false),
1319
966
  exceptions: named,
1320
967
  records: [],
@@ -1323,29 +970,22 @@ export async function readCase(
1323
970
  onChainChecked: false,
1324
971
  notes: [],
1325
972
  packs: [],
1326
- };
973
+ });
1327
974
  }
1328
975
 
1329
- // Step 1 and 2, pack by pack, in the order the reader was given them.
1330
- // Whether every pack's witness was re-fetched on chain, ALL over the packs
1331
- // this reader verified. It passes no gateways, so this stays false and the
1332
- // reading says so (see `CaseReading.onChainChecked`).
976
+ // Steps 1 and 2, pack by pack. No gateways, so `witnessChecked` stays false.
1333
977
  const witnessChecked: boolean[] = [];
1334
978
  const held: HeldRecord[] = [];
1335
- // The pack and the `event_id` behind each pile member, kept beside the pile
1336
- // rather than parsed back out of a reference string. A reference is for a
1337
- // human to read; a join rebuilt by splitting one is a join on a value a
1338
- // holder can put a separator in.
979
+ // Pack and `event_id` per pile member, kept beside the pile rather than
980
+ // parsed from a reference string a holder could put a separator in.
1339
981
  const provenance: {
1340
982
  position: number;
1341
983
  packRef: string;
1342
984
  eventId: string;
1343
985
  recordIndex: number;
1344
986
  }[] = [];
1345
- // Every record of every pack, in pack order and bundle order (A43): where it
1346
- // went into the pile, its place there (`heldAt`), and where it did not, the
1347
- // kind of the exception that says why. Nothing is listed only where the pack
1348
- // body carries no `events` array, and that pack's own exception says why.
987
+ // Every record of every pack, in order: its pile position, or the exception
988
+ // kind that kept it out.
1349
989
  const slots: {
1350
990
  position: number;
1351
991
  packRef: string;
@@ -1364,71 +1004,75 @@ export async function readCase(
1364
1004
  slots.push({ position, packRef, recordIndex, eventId: record.eventId, heldAt: null, reason }),
1365
1005
  );
1366
1006
  };
1367
- // The kinds each pack's declaration check names each record by, keyed by the
1368
- // pack's POSITION and then by `eventId` (A43). The check names a record by
1369
- // `event_id` alone, so under an id the pack carries twice every record with
1370
- // that id is named: failing closed, rather than guessing which of the two the
1371
- // check meant.
1007
+ // Declaration-check findings per pack position, then `eventId`. A duplicated
1008
+ // id names every record with it: fail closed rather than guess.
1372
1009
  const declarationFindings = new Map<number, Map<string, string[]>>();
1373
- // Keyed by the pack's POSITION and never by its `ref`. `ref` is the reader's
1374
- // own name for a pack, and nothing stops two packs in one archive carrying
1375
- // the same one: keyed by ref, the second pack's claims silently replaced the
1376
- // first's and records from one pack were graded against another pack's
1377
- // declaration. Found by a constructed archive rather than by reading, which
1378
- // is the only way this class of defect is ever found.
1010
+ // Keyed by pack position, never `ref`: two packs can carry one ref, and a
1011
+ // record must never be graded against another pack's declaration.
1379
1012
  const claimsByPack = new Map<number, DeclarationClaims>();
1380
1013
 
1381
- // Every key that SIGNED A PACK in this case: the wrapper key, which is the
1382
- // key that signs the pack's declaration. This is the ONE input that separates
1383
- // the two counterpart kinds (see `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS`),
1384
- // and it replaces a set of every key that signed a RECORD anywhere in the
1385
- // case.
1386
- //
1387
- // The record-key set was wrong twice over, and both times in the direction
1388
- // that matters. It let the ACCUSER manufacture the damning line: one pack,
1389
- // plus one unrelated record the counterparty signed at some other time, read
1390
- // "the counterparty's OWN records ARE here" when the counterparty had handed
1391
- // over nothing (F4). And it let the COUNTERPARTY choose the innocent line: it
1392
- // was filled inside the record loop, which the `body_type` and no-records
1393
- // exits `continue` past, so a counterparty that wrapped its own pack in a body
1394
- // this reader does not read took its keys out of the set (F3), the third
1395
- // instance of a route already closed once for withheld `event_bytes`.
1396
- //
1397
- // A wrapper key closes both. The accuser cannot forge one without the
1398
- // counterparty's private key, and it is read HERE, first, before any exit
1399
- // below, from the pack envelope and never from the body, so no body type, no
1400
- // disclosure choice and no record content decides whether it is read.
1014
+ // Every key that signed a pack wrapper (and so its declaration): the one
1015
+ // input that separates the counterpart kinds. Wrapper keys, not record keys:
1016
+ // an accuser cannot forge one, and it is read before any exit, from the
1017
+ // envelope, so no body type or disclosure choice decides whether it counts.
1401
1018
  const holderKeys = new Set<string>();
1402
- // One reading per pack, in the order the reader was given them (5.5.4 v1.20).
1403
1019
  const packReadings: CasePackReading[] = [];
1404
- // The NOTEs (5.5.3 v1.20), with the pack position they are about.
1405
1020
  const caseNotes: { position: number; note: CaseNote }[] = [];
1406
- // The role each pack's readable v2 declaration signs for each record, keyed
1407
- // by the pack's POSITION for the reason `claimsByPack` is.
1021
+ // Keyed by pack position, as `claimsByPack` is.
1408
1022
  const rolesByPack = new Map<number, Map<string, DeclarationRole>>();
1409
- // The undeclared packs' slots, settled after the pile is read.
1023
+ // Undeclared packs' held slots, settled after the pile is read.
1410
1024
  const undeclaredSlots: {
1411
1025
  exceptionAt: number;
1412
1026
  contributionAt: number;
1413
1027
  position: number;
1414
1028
  ref: string;
1415
1029
  }[] = [];
1416
- // How many packs this reader could not tie to a key: an unreadable wrapper
1417
- // key, or a wrapper that does not hold. While it is not zero, "no pack in this
1418
- // case is signed by the named key" is not established, so the innocent
1419
- // one-sided kind is not available (COUNTERPART_ABSENT_SIGNER_UNKNOWN).
1030
+ // Packs not tied to a key (unreadable key, or wrapper does not hold). While
1031
+ // non-zero, the innocent `COUNTERPART_ABSENT` is not established.
1420
1032
  let unattributed = 0;
1421
1033
 
1034
+ const runs = await verifyPacks(packs);
1035
+ await readAgentRecordsAcross(packs, runs);
1036
+ // The agent reading of each record that went into the pile, by pack and record position.
1037
+ const agentReadings = new Map<string, AgentRecordReading | null>();
1038
+
1422
1039
  for (let position = 0; position < packs.length; position += 1) {
1423
1040
  const pack = packs[position];
1041
+ if (typeof pack.unreadable === "string") {
1042
+ note(PACK_UNREADABLE, { packRef: pack.ref, packIndex: position, reasons: [pack.unreadable] });
1043
+ unattributed += 1;
1044
+ packReadings.push({
1045
+ index: position,
1046
+ ref: pack.ref,
1047
+ signingKey: null,
1048
+ grade: null,
1049
+ recordsWithheld: 0,
1050
+ declarationVersion: null,
1051
+ });
1052
+ continue;
1053
+ }
1424
1054
  const holder = normalizedSigningKey(holderKeyOf(pack.bundle));
1425
1055
  if (holder === null) {
1426
- // Named, never skipped: a pack whose key this reader cannot read is a
1427
- // pack whose signer is UNKNOWN, and not a pack nobody signed. Checked
1428
- // BEFORE verification, because the two bundle verifiers disagree on some
1429
- // of these spellings (proof#135) and the reading must not.
1056
+ // Signer unknown, not unsigned. Checked before verification so verifier
1057
+ // differences over key spelling cannot change the reading.
1430
1058
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref, packIndex: position });
1431
1059
  unattributed += 1;
1060
+ // `signed` is not proven on that claim only: a pack that can be read is still graded,
1061
+ // so a forged record inside it, or a `body_hash` that does not hold, fails it. Its
1062
+ // records do not join the pile, since no key ties them to a party.
1063
+ const [keyless, keylessSigned] = runs.get(position)!;
1064
+ const keylessGrade = await gradeInCase(pack, keyless, keylessSigned, position);
1065
+ const inside = keylessGrade.problems.filter((p) => p.kind !== PACK_KEY_UNREADABLE);
1066
+ if (inside.length > 0 && keylessSigned.unreadable !== true) {
1067
+ note(PACK_GRADE_NOT_VERIFIED, {
1068
+ packRef: pack.ref,
1069
+ packIndex: position,
1070
+ status: claimsVerdictOf(claimsOf(inside)).status as CaseVerdict,
1071
+ causes: keylessGrade.causes,
1072
+ reasons: bundleReasons(keylessSigned),
1073
+ problems: inside,
1074
+ });
1075
+ }
1432
1076
  unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_KEY_UNREADABLE);
1433
1077
  packReadings.push({
1434
1078
  index: position,
@@ -1441,27 +1085,10 @@ export async function readCase(
1441
1085
  continue;
1442
1086
  }
1443
1087
 
1444
- // The precondition `HeldRecord` states, met HERE rather than trusted to a
1445
- // caller: every key this module compares is an envelope `public_key`, and
1446
- // "unforgeable" is a property of a VERIFIED envelope. Offline, no
1447
- // gateways: this reader makes no network call. This kernel's verifier
1448
- // reads envelopes under exactly CASE_RECORD_PROFILES (pinned by a test).
1449
- const verified = await verifyEvidenceBundle(pack.bundle, {
1450
- content: (pack.content ?? undefined) as
1451
- | Record<string, Uint8Array | string>
1452
- | ReadonlyMap<string, Uint8Array | string>
1453
- | undefined,
1454
- });
1088
+ const [verified, signed] = runs.get(position)!;
1455
1089
  witnessChecked.push(verified.onChainChecked);
1456
- // The same pack with the side map EMPTY (proof#159): what fails here is
1457
- // what a signature covers, and what fails only above rests on bytes nobody
1458
- // signed. Skipped where there is no map, because the two runs are then the
1459
- // same run.
1460
- const signed = sideMapHasKeys(pack.content)
1461
- ? await verifyEvidenceBundle(pack.bundle, {})
1462
- : verified;
1463
1090
 
1464
- // WHOSE PACK IS IT. The wrapper, and only the wrapper.
1091
+ // Whose pack it is: the wrapper, and only the wrapper.
1465
1092
  if (!wrapperHolds(verified)) {
1466
1093
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref, packIndex: position });
1467
1094
  unattributed += 1;
@@ -1479,77 +1106,20 @@ export async function readCase(
1479
1106
  holderKeys.add(holder);
1480
1107
 
1481
1108
  if (coSignaturesPresent(pack.bundle)) {
1482
- // A NOTE, with no contribution (5.5.3 v1.20, proof#148): the wrapper's
1483
- // co_signatures verifies nothing and sits outside its signed scope, so
1484
- // the case reading names it rather than moving anything on its account.
1109
+ // A note, no contribution: `co_signatures` verifies nothing.
1485
1110
  caseNotes.push({
1486
1111
  position,
1487
1112
  note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref, packIndex: position },
1488
1113
  });
1489
1114
  }
1490
1115
 
1491
- const bodyType = bodyTypeOf(pack.bundle);
1492
- const records = packRecords(pack.bundle, pack.content);
1493
- // The per-record gradings below are joined to `records` BY INDEX, so a
1494
- // verifier that graded a different number of records than the pack holds
1495
- // leaves this loop with no grading to read. That is not hypothetical: a
1496
- // pack whose `body_hash` does not cover its body reads `failed` with
1497
- // `events` EMPTY while `packRecords` reads every record in it, and only
1498
- // `wrapperHolds` above stands between that pack and this loop. A reader
1499
- // must never crash on a pack a party can build, so the gap is graded rather
1500
- // than indexed into, and the pack's records are dropped exactly as a
1501
- // `malformed` pack's are (gate 4 on proof#140, finding 3).
1502
- const ungraded =
1503
- verified.events.length !== records.length || signed.events.length !== records.length;
1504
- const knownBody = (CASE_BODY_TYPES as readonly unknown[]).includes(bodyType);
1505
-
1506
- // WHAT IS WRONG INSIDE IT, from here on. First the pack's own GRADE, which
1507
- // propagates: the case never reads above its weakest pack (5.5.1 step 1
1508
- // v1.20, A23; up to v1.19 it was the bundle status).
1509
- let check: DeclarationCheck | null = null;
1510
- let full: PackGrade | null = null;
1511
- let causes: PackGradeCauseKind[] = [];
1512
- let grade: PackVerdict;
1513
- if (signed.status === "malformed") {
1514
- grade = "malformed";
1515
- } else if (!knownBody) {
1516
- grade = signed.status as PackVerdict;
1517
- } else if (ungraded) {
1518
- grade = signed.status === "verified" ? "malformed" : (signed.status as PackVerdict);
1519
- } else {
1520
- // Rule 11 reads the chain links of the same pack, and the reader reads
1521
- // only the signed body, so the bare check below takes the same reading
1522
- // (evidence-bundle.md 5.4.2 v1.36, A45).
1523
- const chainLinks = await readChainLinks(pack.bundle);
1524
- check = checkDeclaration(pack.bundle, pack.content, chainLinks);
1525
- const completeness = completenessNotes(pack.bundle);
1526
- full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1527
- const bareGrade = gradePack(
1528
- signed,
1529
- accountDisclosure(pack.bundle, {}),
1530
- completeness,
1531
- checkDeclaration(pack.bundle, null, chainLinks),
1532
- );
1533
- // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1534
- // where it is `failed` or `malformed`; a failure only the side map
1535
- // produced is `incomplete`; otherwise the full grade.
1536
- if (bareGrade.verdict === "failed" || bareGrade.verdict === "malformed") {
1537
- grade = bareGrade.verdict;
1538
- causes = bareGrade.causes.map((c) => c.kind);
1539
- } else if (full.verdict === "failed" || full.verdict === "malformed") {
1540
- grade = "incomplete";
1541
- causes = full.causes.map((c) => c.kind);
1542
- } else {
1543
- grade = full.verdict;
1544
- causes = full.causes.map((c) => c.kind);
1545
- }
1546
- }
1116
+ const graded = await gradeInCase(pack, verified, signed, position);
1117
+ const { grade, check, full, causes, records, ungraded, knownBody } = graded;
1118
+ const packProblems = graded.problems;
1547
1119
  if (grade !== "verified") {
1548
1120
  const reasons = bundleReasons(signed);
1549
- if (knownBody && ungraded && signed.status !== "malformed") {
1550
- // Said, because nothing else says it: the verifier's own reasons are
1551
- // about the bundle, and this pack's records are dropped for a reason
1552
- // of this reader's.
1121
+ if (knownBody && ungraded && signed.unreadable !== true) {
1122
+ // The verifier's reasons are about the bundle; this one is ours.
1553
1123
  reasons.push(
1554
1124
  `this verifier graded ${verified.events.length} of the ${records.length} ` +
1555
1125
  "record(s) the pack holds, so none of them is read",
@@ -1561,6 +1131,7 @@ export async function readCase(
1561
1131
  status: grade as CaseVerdict,
1562
1132
  causes,
1563
1133
  reasons,
1134
+ problems: packProblems,
1564
1135
  });
1565
1136
  }
1566
1137
  if (verified.status === "failed" && signed.status !== "failed") {
@@ -1580,9 +1151,8 @@ export async function readCase(
1580
1151
  declarationVersion: knownBody ? declarationVersionOf(pack.bundle) : null,
1581
1152
  });
1582
1153
  if (recordsWithheld > 0) {
1583
- // A NOTE, with no contribution (5.5.3 v1.20): the holder signed that
1584
- // these records' content does not travel, and the scope sentence counts
1585
- // them, so a clean verdict never hides them.
1154
+ // A note, no contribution: the holder signed that this content does not
1155
+ // travel, and the scope sentence counts it.
1586
1156
  caseNotes.push({
1587
1157
  position,
1588
1158
  note: {
@@ -1593,29 +1163,21 @@ export async function readCase(
1593
1163
  },
1594
1164
  });
1595
1165
  }
1596
- // The body is one the verifier could not read, so it graded no record, and
1597
- // nothing below has a grading to read. Named above.
1598
- if (signed.status === "malformed") {
1166
+ // The verifier graded no record; named above.
1167
+ if (signed.unreadable === true) {
1599
1168
  unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_GRADE_NOT_VERIFIED);
1600
1169
  continue;
1601
1170
  }
1602
1171
 
1603
1172
  if (!knownBody) {
1604
- // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
1605
- // records sit under `body.source_bundle`, and reading them is a change to
1606
- // which packs this reader grades, so it is a decision of its own and not
1607
- // a repair (evidence-bundle.md 5.5.1 step 1 says so too). What this exit
1608
- // costs is that pack's RECORDS, and it is named every time. It no longer
1609
- // costs the pack's KEY: that was read above.
1173
+ // Not unwrapped (evidence-bundle §5.5.1). Costs the pack's records, never
1174
+ // its key, which was read above.
1610
1175
  note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref, packIndex: position });
1611
- // Listed, and not read: the records a `sanning.evidence.export/v2` wraps
1612
- // are listed from its inline source bundle, so a reading never says "0
1613
- // not read" of a pack that holds records (A43).
1176
+ // Listed, not read, so a pack holding records never reads "0 not read".
1614
1177
  unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_BODY_TYPE_UNKNOWN);
1615
1178
  continue;
1616
1179
  }
1617
- // Graded above (`malformed` where the verifier called it `verified`), and
1618
- // none of its records are read.
1180
+ // Graded above; none of its records are read.
1619
1181
  if (ungraded) {
1620
1182
  unread(position, pack.ref, records, PACK_GRADE_NOT_VERIFIED);
1621
1183
  continue;
@@ -1637,10 +1199,8 @@ export async function readCase(
1637
1199
  }
1638
1200
  declarationFindings.set(position, namedByCheck);
1639
1201
  if (checked.contentFindings.length > 0) {
1640
- // BESIDE the deviation exception and never instead of it: a pack can do
1641
- // both, over different records, and the two say different things. This
1642
- // one is `incomplete` because whoever assembled the case chose the bytes
1643
- // it turns on (proof#147).
1202
+ // Beside the deviation exception, never instead: they concern different
1203
+ // records. `incomplete` because the case assembler chose the bytes.
1644
1204
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
1645
1205
  packRef: pack.ref,
1646
1206
  packIndex: position,
@@ -1648,34 +1208,29 @@ export async function readCase(
1648
1208
  });
1649
1209
  }
1650
1210
  if (checked.verdict === "failed") {
1651
- note(PACK_DECLARATION_DEVIATES, {
1652
- packRef: pack.ref,
1653
- packIndex: position,
1654
- count: checked.deviations.length,
1655
- });
1211
+ // A declaration that does not parse deviates from nothing: the pack grade names it as
1212
+ // a declaration that breaks its own format.
1213
+ if (
1214
+ checked.deviations.length === 0 ||
1215
+ checked.deviations.some((d) => d.kind !== "declaration_malformed")
1216
+ ) {
1217
+ note(PACK_DECLARATION_DEVIATES, {
1218
+ packRef: pack.ref,
1219
+ packIndex: position,
1220
+ count: checked.deviations.length,
1221
+ });
1222
+ }
1656
1223
  } else if (checked.contentFindings.length > 0) {
1657
- // Already named above. `partial` here is not "the reader cannot read the
1658
- // claim": the claim was read, and part of it rests on bytes nobody
1659
- // signed.
1224
+ // Named above: the claim was read, and part of it rests on unsigned bytes.
1660
1225
  } else if (checked.verdict === "verified") {
1661
- // A pack that agrees with its own claim contributes NOTHING, rather than
1662
- // a `verified` of its own. The case verdict is over the hand-off
1663
- // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
1664
- // pack-level clean contribution would put `verified` into the set on
1665
- // every case, so one broken pair beside two honest declarations would
1666
- // read `mixed` and the failure would be the quieter half of a two-word
1667
- // answer. A pack-level PROBLEM still contributes, through its exception.
1226
+ // A clean pack contributes nothing, so the case verdict reflects hand-off
1227
+ // outcomes: a `verified` per pack would turn one broken pair into `mixed`.
1668
1228
  } else if (checked.present) {
1669
- // `partial` with a declaration present is 5.4.3's "a claim this reader
1670
- // cannot read", which `gradePack` already carries to `incomplete`. The
1671
- // same posture here, for the same reason: the field has no accept-set and
1672
- // the holder writes it.
1229
+ // A claim this reader cannot read (§5.4.3); the holder writes the field.
1673
1230
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref, packIndex: position });
1674
1231
  } else {
1675
- // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1676
- // A23, I4): `partial` where the reading holds a hand-off record, and a
1677
- // NOTE where it holds none. That is not known until the pile is read, so
1678
- // the slot is held HERE, in pack order, and settled below.
1232
+ // `partial` if the reading holds a hand-off record, else a note (§5.5.1).
1233
+ // Not known until the pile is read, so hold the slot.
1679
1234
  undeclaredSlots.push({
1680
1235
  exceptionAt: exceptions.length,
1681
1236
  contributionAt: contributions.length,
@@ -1686,25 +1241,29 @@ export async function readCase(
1686
1241
  contributions.push(null);
1687
1242
  }
1688
1243
 
1689
- // Each record on its OWN envelope. `packRecords` and the verifier both walk
1690
- // `body.events` in order and emit one entry per element, so the index is
1691
- // the join, exactly as `held`, `provenance` and `readings` below.
1244
+ // Each record on its own envelope, joined by index.
1692
1245
  for (let index = 0; index < records.length; index += 1) {
1693
1246
  const record = records[index];
1694
1247
  const graded = verified.events[index];
1695
1248
  const bare = signed.events[index];
1696
- // Which pack and which record, by position: the two names that are
1697
- // unique in the reading whatever the reader called the pack and whatever
1698
- // the holder wrote as `event_id` (A43).
1249
+ // Pack and record by position: unique whatever `ref` or `event_id` say.
1699
1250
  const at = {
1700
1251
  packRef: pack.ref,
1701
1252
  packIndex: position,
1702
1253
  eventId: record.eventId,
1703
1254
  recordIndex: index,
1704
1255
  };
1705
- // Not read: listed with the kind of the exception that says why.
1256
+ // Not read: listed with the kind of the exception that says why. Signed bytes that
1257
+ // bind and are not JSON, and an envelope that cannot be read, names a version this
1258
+ // verifier does not know or breaks its format under a signature that holds, are their
1259
+ // pack's own problems, so no exception is noted here.
1706
1260
  const skip = (kind: CaseExceptionKind, fields: Omit<CaseException, "kind"> = {}) => {
1707
- note(kind, { ...at, ...fields });
1261
+ if (
1262
+ !(kind === RECORD_BYTES_UNREADABLE && bare.recordNotJson === true) &&
1263
+ !(kind === RECORD_ENVELOPE_DOES_NOT_VERIFY && ENVELOPE_FAULTS_OF_THE_PACK.includes(graded.envelopeFault ?? ""))
1264
+ ) {
1265
+ note(kind, { ...at, ...fields });
1266
+ }
1708
1267
  slots.push({ position, packRef: pack.ref, recordIndex: index, eventId: record.eventId, heldAt: null, reason: kind });
1709
1268
  };
1710
1269
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
@@ -1723,9 +1282,8 @@ export async function readCase(
1723
1282
  skip(RECORD_BYTES_UNREADABLE);
1724
1283
  continue;
1725
1284
  }
1726
- // Named, and the record stays in the pile (see the kinds). A mismatch
1727
- // the signed body carries on its own is `failed`; one that appears only
1728
- // with the side map is `incomplete` (proof#159).
1285
+ // Named, and the record stays in the pile. A mismatch in the signed body
1286
+ // is `failed`; one only with the side map is `incomplete`.
1729
1287
  if (bare.contentOk === false) {
1730
1288
  note(RECORD_CONTENT_MISMATCH, {
1731
1289
  ...at,
@@ -1737,11 +1295,13 @@ export async function readCase(
1737
1295
  reasons: onlyIn(graded.errors, bare.errors),
1738
1296
  });
1739
1297
  }
1740
- // Every other check on the SIGNED reading, so side content can neither
1741
- // add a failure here nor take one away.
1298
+ // Every other check on the signed reading, so side content can neither
1299
+ // add a failure here nor remove one. A stamp the pack does not hold, and signed
1300
+ // bytes that are not JSON, are their pack's own problems (not proven, and a
1301
+ // producer's error), never a record that does not match its pack.
1742
1302
  if (
1743
- !(bare.inclusionOk && bare.checkpointBound) ||
1744
- (!bare.ok && bare.contentOk !== false)
1303
+ !(bare.stampAbsent === true || bare.recordNotJson === true) &&
1304
+ (!(bare.inclusionOk && bare.checkpointBound) || (!bare.ok && bare.contentOk !== false))
1745
1305
  ) {
1746
1306
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1747
1307
  ...at,
@@ -1756,19 +1316,19 @@ export async function readCase(
1756
1316
  heldAt: held.length,
1757
1317
  reason: null,
1758
1318
  });
1319
+ const agent = graded.agent;
1320
+ agentReadings.set(`${position}:${index}`, agent);
1759
1321
  held.push({
1760
1322
  publicKey: record.publicKey,
1761
1323
  record: record.record,
1762
1324
  ref: `${pack.ref}/${record.eventId}`,
1325
+ agentNonconforming: agent !== null && agent.verdict === "failed",
1763
1326
  });
1764
1327
  provenance.push({ position, packRef: pack.ref, eventId: record.eventId, recordIndex: index });
1765
1328
  }
1766
1329
  }
1767
1330
 
1768
- // Step 3. ONE pile, no partition. This is the line anchor#198 turns on and
1769
- // the line DL-60 constrains: there is nothing here that reads a tenant, an
1770
- // issuer or an organization, so a pair across two organizations' packs is
1771
- // read exactly as a pair inside one.
1331
+ // Step 3: one pile, no partition by tenant, issuer or organization.
1772
1332
  const readings: HandoffReading[] = readHandoffs(held);
1773
1333
 
1774
1334
  // Step 4.
@@ -1776,23 +1336,12 @@ export async function readCase(
1776
1336
  for (let index = 0; index < readings.length; index += 1) {
1777
1337
  const reading = readings[index];
1778
1338
  const { position, packRef, eventId, recordIndex } = provenance[index];
1779
- // The record this reading is about, by position (A43).
1780
1339
  const at = { packRef, packIndex: position, eventId, recordIndex };
1781
- // proof#137's door, named. Read from the ONE reader of the declaration
1782
- // (`declarationClaims`), never from a second parse here. Only a READABLE
1783
- // declaration can make a record unclaimed: where the claim itself is absent
1784
- // or unreadable, that pack already carries its own exception and naming
1785
- // every record in it again would report one defect many times.
1786
- //
1787
- // ABOVE the two `continue`s below, deliberately. This check asks what the
1788
- // PACK said about the record, which is independent of whether 4.1 governs
1789
- // the record and of whether the record is well formed. Behind the
1790
- // `not_a_handoff_record` exit it was a check the audited party switched off
1791
- // by respelling two fields, and behind the `malformed` exit it was one a
1792
- // party switched off by breaking its own record.
1793
- //
1794
- // v1's ALONE (5.5.1 v1.20, A21). A v2 pack's claim check is rule 9, which
1795
- // the declaration check already ran on the signed body.
1340
+ // Read from `declarationClaims`, never a second parse. Only a readable
1341
+ // declaration makes a record unclaimed; otherwise the pack's own exception
1342
+ // covers it. Runs above both `continue`s below, so neither respelling a
1343
+ // record out of §4.1 nor breaking it switches the check off. v1 only: a v2
1344
+ // pack's claim check already ran inside the declaration check.
1796
1345
  const claims = claimsByPack.get(position);
1797
1346
  if (
1798
1347
  claims !== undefined &&
@@ -1804,33 +1353,32 @@ export async function readCase(
1804
1353
  }
1805
1354
 
1806
1355
  if (reading.findings.includes(NOT_A_HANDOFF_FINDING)) {
1807
- // 4.1 (v1.9, A14): information, not absence. A record 4.1 does not govern
1808
- // is not a hand-off exception, and it is still in `records` and still
1809
- // subject to the claim check above.
1356
+ // Not a hand-off exception; still listed and still claim-checked above.
1810
1357
  continue;
1811
1358
  }
1812
1359
  handoffRecords += 1;
1813
1360
 
1814
- if (reading.verdict === "malformed") {
1815
- // Whose defect is it? `recordIsMalformed` is 4.1's own predicate, CALLED
1816
- // and not restated, and it answers about THIS record. A `malformed`
1817
- // aggregate over a record that is itself well formed is a malformed
1818
- // CANDIDATE, so it is a different kind, it does not `continue`, and the
1819
- // counterpart, ambiguity and `failedChecks` reporting below all keep
1820
- // running over it.
1361
+ if (reading.findings.includes("malformed_record")) {
1362
+ // `recordIsMalformed` answers about this record. A malformed aggregate
1363
+ // over a well-formed record is a malformed candidate, and reporting
1364
+ // continues for it.
1821
1365
  if (recordIsMalformed(held[index])) {
1822
1366
  note(RECORD_MALFORMED, at);
1823
1367
  continue;
1824
1368
  }
1825
1369
  note(COUNTERPART_MALFORMED, at);
1370
+ } else if (reading.findings.includes("nonconforming_record")) {
1371
+ // The same question for the other defect: this record's own, or a candidate's.
1372
+ if (recordIsNonconforming(held[index])) {
1373
+ note(RECORD_NONCONFORMING, at);
1374
+ continue;
1375
+ }
1376
+ note(COUNTERPART_NONCONFORMING, at);
1826
1377
  }
1827
1378
 
1828
1379
  if (reading.findings.includes(COUNTERPART_ABSENT)) {
1829
- // `held`, `provenance` and `readings` are three views of one list in one
1830
- // order, built together and never filtered, so the index is the join.
1831
- // Deliberately not a lookup by reference string: a reference is for a
1832
- // person to read, and a join rebuilt by splitting one is a join on a
1833
- // value a holder can put a separator inside.
1380
+ // `held`, `provenance` and `readings` share one order, never filtered, so
1381
+ // the index is the join.
1834
1382
  const subject = held[index];
1835
1383
  const event = (subject.record as Record<string, unknown>)["event"];
1836
1384
  const named = normalizedSigningKey(
@@ -1838,20 +1386,11 @@ export async function readCase(
1838
1386
  ? (event as Record<string, unknown>)["counterparty_public_key"]
1839
1387
  : undefined,
1840
1388
  );
1841
- // A VERIFIED pack SIGNED by the named key is in the case, and nothing
1842
- // else. Records signed by that key which some other party holds are not
1843
- // that party's pack. Where some pack could not be tied to a key, "no
1844
- // pack is signed by the named key" is not established and the reading
1845
- // says it could not tell.
1846
- //
1847
- // EXCEPT where 4.1 set every candidate aside (v1.17, A20). There the
1848
- // absence is a finding about the candidates the reader DID read, each
1849
- // paired `verified` with another record the subject's own signer made,
1850
- // and not a finding about any pack. 4.1 and evidence-bundle A16 name the
1851
- // neutral kind for it, and the damning one would put the audited party's
1852
- // own unfaithful record on the counterparty's pack as a statement
1853
- // against it. `count` is how many candidates were set aside, which is
1854
- // also what tells the rendered sentence which absence this is.
1389
+ // The damning kind needs a verified pack signed by the named key; with
1390
+ // any unattributed pack, the reading says it could not tell. Where every
1391
+ // candidate was set aside (each paired with another record by the
1392
+ // subject's own signer), the absence is about those candidates, not any
1393
+ // pack, so the neutral kind carries `count`.
1855
1394
  const graded = reading.candidateReadings;
1856
1395
  const setAside =
1857
1396
  graded.length > 0 && graded.every((c) => c.accountedFor !== null)
@@ -1880,13 +1419,9 @@ export async function readCase(
1880
1419
  count: reading.candidateReadings.filter((c) => c.accountedFor === null).length,
1881
1420
  });
1882
1421
  }
1883
- // ON `failedChecks` AND NOT ON THE VERDICT WORD. 4.1 keeps the UNION of
1884
- // every candidate's failed checks precisely so a junk record cannot strip a
1885
- // named failure out of the report (proof#133 ruling 6), and reading the
1886
- // verdict instead threw that union away: `malformed` outranks `failed`, so
1887
- // one junk candidate took the branch below and a proven `message_hash`
1888
- // failure disappeared from the case. A named failed check is a named failed
1889
- // check whatever the aggregate word is.
1422
+ // On `failedChecks`, not the verdict word: `malformed` outranks `failed`,
1423
+ // and §4.1 keeps the union of failed checks so a junk candidate cannot hide
1424
+ // a proven failure.
1890
1425
  if (reading.failedChecks.length > 0) {
1891
1426
  note(PAIR_FAILED, {
1892
1427
  ...at,
@@ -1897,11 +1432,8 @@ export async function readCase(
1897
1432
  }
1898
1433
  }
1899
1434
 
1900
- // A reading with no hand-off record in it is not an exception (5.5.3 v1.20,
1901
- // A23, D6). The undeclared packs, settled: the exception `pack_undeclared`,
1902
- // contributing `partial`, in the slot it was given in pack order where the
1903
- // reading holds a hand-off record; a NOTE with no contribution where it holds
1904
- // none.
1435
+ // Undeclared packs: the `pack_undeclared` exception, contributing `incomplete`,
1436
+ // where the reading holds a hand-off record; otherwise a note (§5.5.3).
1905
1437
  for (const slot of undeclaredSlots) {
1906
1438
  if (handoffRecords > 0) {
1907
1439
  exceptions[slot.exceptionAt] = {
@@ -1919,18 +1451,14 @@ export async function readCase(
1919
1451
  }
1920
1452
  const named = exceptions.filter((e): e is CaseException => e !== null);
1921
1453
  const contributed = contributions.filter((c): c is [string, CaseVerdict] => c !== null);
1922
- // Pack order, stable. A pack carries at most one note: one with no
1923
- // declaration withholds nothing by one.
1454
+ // Pack order, stable.
1924
1455
  const notes = caseNotes
1925
1456
  .map((n, i) => ({ ...n, i }))
1926
1457
  .sort((a, b) => a.position - b.position || a.i - b.i)
1927
1458
  .map((n) => n.note);
1928
1459
 
1929
- // Every record of every pack, with its status (A43). A record that went
1930
- // into the pile is `read_flagged` where any exception names it by pack and
1931
- // record position, or its pack's declaration check names it (by deviation
1932
- // or by content finding), and `read_clean` where neither does; every other
1933
- // record is `not_read`, with the kind of the exception that says why.
1460
+ // Read records are `read_flagged` when an exception or declaration finding
1461
+ // names them, else `read_clean`; the rest are `not_read`.
1934
1462
  const flagged = new Set(
1935
1463
  named
1936
1464
  .filter((e) => e.packIndex !== undefined && e.recordIndex !== undefined)
@@ -1955,6 +1483,7 @@ export async function readCase(
1955
1483
  failedChecks: [],
1956
1484
  counterparts: [],
1957
1485
  role: null,
1486
+ agent: null,
1958
1487
  };
1959
1488
  }
1960
1489
  const reading = readings[slot.heldAt];
@@ -1971,10 +1500,11 @@ export async function readCase(
1971
1500
  failedChecks: [...reading.failedChecks],
1972
1501
  counterparts: [...reading.candidates],
1973
1502
  role: rolesByPack.get(slot.position)?.get(slot.eventId) ?? null,
1503
+ agent: agentReadings.get(`${slot.position}:${slot.recordIndex}`) ?? null,
1974
1504
  };
1975
1505
  });
1976
1506
 
1977
- return {
1507
+ return withProblems({
1978
1508
  verdict: verdictOf(named, contributed),
1979
1509
  exceptions: named,
1980
1510
  records,
@@ -1983,15 +1513,12 @@ export async function readCase(
1983
1513
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1984
1514
  notes,
1985
1515
  packs: packReadings,
1986
- };
1516
+ });
1987
1517
  }
1988
1518
 
1989
1519
  /**
1990
- * The records a pack LISTS where this reader does not read them one by one
1991
- * (A43): the events `resolveTraceEvents` presents, which unwraps a
1992
- * `sanning.evidence.export/v2` to its inline source bundle, and otherwise
1993
- * `body.events` as `packRecords` reads it. Listing is not reading: every record
1994
- * listed here is `not_read`.
1520
+ * The records a pack lists when this reader does not read them one by one,
1521
+ * unwrapping an export to its source bundle. Every one is `not_read`.
1995
1522
  */
1996
1523
  function listedRecords(bundle: unknown, content: CasePack["content"]): PackRecord[] {
1997
1524
  const events = resolveTraceEvents(bundle);
@@ -1999,10 +1526,7 @@ function listedRecords(bundle: unknown, content: CasePack["content"]): PackRecor
1999
1526
  return packRecords({ body: { events } }, content);
2000
1527
  }
2001
1528
 
2002
- /**
2003
- * `body.declaration.declaration_version` where it is one this reader knows,
2004
- * and `null` otherwise.
2005
- */
1529
+ /** The pack's known `declaration_version`, or `null`. */
2006
1530
  function declarationVersionOf(bundle: unknown): string | null {
2007
1531
  const at = (v: unknown, k: string): unknown =>
2008
1532
  typeof v === "object" && v !== null && !Array.isArray(v)
@@ -2018,18 +1542,13 @@ function declarationVersionOf(bundle: unknown): string | null {
2018
1542
  : null;
2019
1543
  }
2020
1544
 
2021
- // ---- Presentation (optional) ------------------------------------------------
2022
-
2023
- /**
2024
- * The house wording for one exception.
2025
- *
2026
- * Offered, not imposed, exactly as `renderDeviation` is: what must not vary
2027
- * between surfaces is the verdict and the set of exceptions, and those come
2028
- * from `readCase`. Both kernels return this same text for the same exception.
2029
- */
2030
1545
  const because = (reasons: readonly string[] | undefined): string =>
2031
1546
  reasons !== undefined && reasons.length > 0 ? `: ${reasons.join("; ")}` : "";
2032
1547
 
1548
+ /**
1549
+ * The house wording for one exception. Optional: the verdict and the set of
1550
+ * exceptions are what must not vary between surfaces.
1551
+ */
2033
1552
  export function renderException(exception: CaseException): string {
2034
1553
  const where = `${exception.packRef}/${exception.eventId}`;
2035
1554
  switch (exception.kind) {
@@ -2052,7 +1571,7 @@ export function renderException(exception: CaseException): string {
2052
1571
  case COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS:
2053
1572
  return (
2054
1573
  `${where} has no counterpart among the records this verifier read, ` +
2055
- "and a pack signed by the key it names IS in this case and holds " +
1574
+ "and a pack signed by the key it names is in this case and holds " +
2056
1575
  "no counterpart this verifier could read"
2057
1576
  );
2058
1577
  case COUNTERPART_ABSENT_SIGNER_UNKNOWN:
@@ -2060,7 +1579,7 @@ export function renderException(exception: CaseException): string {
2060
1579
  `${where} has no counterpart among the records this verifier read, ` +
2061
1580
  "and a pack in this case could not be tied to a signing key, so " +
2062
1581
  "whether a pack signed by the key it names is in this case is " +
2063
- "UNKNOWN"
1582
+ "unknown"
2064
1583
  );
2065
1584
  case COUNTERPART_AMBIGUOUS:
2066
1585
  return (
@@ -2068,65 +1587,77 @@ export function renderException(exception: CaseException): string {
2068
1587
  "reading is the worst of them and no one match was chosen"
2069
1588
  );
2070
1589
  case PAIR_FAILED:
2071
- return `${where} FAILED on ${(exception.failedChecks ?? []).join(", ")}`;
1590
+ return `${where} failed on ${(exception.failedChecks ?? []).join(", ")}`;
2072
1591
  case RECORD_MALFORMED:
2073
1592
  return `${where} is not a well formed hand-off record`;
2074
1593
  case COUNTERPART_MALFORMED:
2075
1594
  return (
2076
1595
  `${where} is well formed and a candidate counterpart of it is not, ` +
2077
- "so what this record pairs with is UNKNOWN"
1596
+ "so what this record pairs with is unknown"
1597
+ );
1598
+ case RECORD_NONCONFORMING:
1599
+ return (
1600
+ `${where} is signed and intact and does not follow its vocabulary, ` +
1601
+ "so nothing is proven altered and it cannot be paired"
1602
+ );
1603
+ case COUNTERPART_NONCONFORMING:
1604
+ return (
1605
+ `${where} conforms and a candidate counterpart of it does not, ` +
1606
+ "so what this record pairs with is unknown"
2078
1607
  );
2079
1608
  case RECORD_UNCLAIMED:
2080
1609
  return (
2081
1610
  `${where} is a record its own pack declares and claims in no ` +
2082
1611
  "hand-off pair, so whether the holder meant to hand it over is " +
2083
- "UNKNOWN"
1612
+ "unknown"
2084
1613
  );
2085
1614
  case RECORD_BYTES_WITHHELD:
2086
- return `${where} travels with no record bytes, so what that record IS is UNKNOWN`;
1615
+ return `${where} travels with no record bytes, so what that record is, is unknown`;
2087
1616
  case RECORD_BYTES_UNREADABLE:
2088
1617
  return `${where} travels with record bytes this verifier cannot read`;
2089
1618
  case PACK_DECLARATION_DEVIATES:
2090
- // "read together with" and not "deviates", flat: 5.4 grades the pack
2091
- // against the content the reader was handed, and content beside a pack is
2092
- // unsigned. Whoever assembles a case can swap it, so the sentence names
2093
- // what was compared rather than only the pack.
1619
+ // Names what was compared: the side content is unsigned and swappable.
2094
1620
  return (
2095
1621
  `${exception.packRef}, read together with any content handed in ` +
2096
- `beside it, DEVIATES from its own declaration, in ` +
1622
+ `beside it, deviates from its own declaration, in ` +
2097
1623
  `${exception.count} way(s)`
2098
1624
  );
2099
1625
  case PACK_DECLARATION_CONTENT_DEPENDENT:
2100
- // Says what this verifier could not establish, and names the content
2101
- // rather than the pack's holder: the bytes are unsigned and whoever
2102
- // assembled the case chose them.
1626
+ // Names what could not be established and the content, never the holder:
1627
+ // the bytes are unsigned and the case assembler chose them.
2103
1628
  return (
2104
1629
  `${exception.packRef} agrees with its own declaration everywhere its ` +
2105
1630
  `signature reaches, and in ${exception.count} way(s) it does not line ` +
2106
1631
  "up with the unsigned content handed in beside it, which anyone could " +
2107
- "have supplied, so whether it holds what it declares is UNKNOWN"
1632
+ "have supplied, so whether it holds what it declares is unknown"
2108
1633
  );
2109
1634
  case PACK_DECLARATION_UNREADABLE:
2110
1635
  return (
2111
1636
  `${exception.packRef} says what it holds in a declaration this ` +
2112
- "verifier cannot read, so whether it is complete is UNKNOWN"
1637
+ "verifier cannot read, so whether it is complete is unknown"
2113
1638
  );
2114
1639
  case PACK_UNDECLARED:
2115
1640
  return (
2116
1641
  `${exception.packRef} does not say what it holds, so whether it is ` +
2117
- "complete is UNKNOWN"
1642
+ "complete is unknown"
2118
1643
  );
2119
1644
  case PACK_BODY_TYPE_UNKNOWN:
2120
1645
  return (
2121
1646
  `${exception.packRef} is a pack body this verifier does not read, so ` +
2122
- "what it holds is UNKNOWN"
1647
+ "what it holds is unknown"
2123
1648
  );
2124
1649
  case PACK_HOLDS_NO_RECORDS:
2125
1650
  return `${exception.packRef} holds no records this verifier can read`;
1651
+ case PACK_UNREADABLE:
1652
+ return (
1653
+ `${exception.packRef} cannot be read: its bundle.json ` +
1654
+ `${(exception.reasons ?? []).join("; ")}, so who signed it is unknown and none of its ` +
1655
+ "records are read"
1656
+ );
2126
1657
  case PACK_KEY_UNREADABLE:
2127
1658
  return (
2128
1659
  `${exception.packRef} names a signing key this verifier cannot ` +
2129
- "read, so who signed it is UNKNOWN and none of its records are read"
1660
+ "read, so who signed it is unknown and none of its records are read"
2130
1661
  );
2131
1662
  case PACK_DOES_NOT_VERIFY:
2132
1663
  return (
@@ -2135,18 +1666,17 @@ export function renderException(exception: CaseException): string {
2135
1666
  "names is not counted as its signer"
2136
1667
  );
2137
1668
  case PACK_GRADE_NOT_VERIFIED: {
2138
- let head = `${exception.packRef} grades ${(exception.status ?? "").toUpperCase()} as a pack`;
1669
+ let head = `${exception.packRef} grades ${exception.status} as a pack`;
2139
1670
  if (exception.causes !== undefined && exception.causes.length > 0) {
2140
1671
  head += ` (${exception.causes.join(", ")})`;
2141
1672
  }
2142
- if (exception.status === "malformed") head += ", so none of its records are read";
2143
1673
  return head + because(exception.reasons);
2144
1674
  }
2145
1675
  case PACK_BUNDLE_CONTENT_DEPENDENT:
2146
1676
  return (
2147
- `${exception.packRef} reads FAILED as a bundle only together with ` +
1677
+ `${exception.packRef} reads failed as a bundle only together with ` +
2148
1678
  "the unsigned content handed in beside it, which anyone could have " +
2149
- "supplied, so whether that failure is the pack's is UNKNOWN" +
1679
+ "supplied, so whether that failure is the pack's is unknown" +
2150
1680
  because(exception.reasons)
2151
1681
  );
2152
1682
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
@@ -2171,14 +1701,14 @@ export function renderException(exception: CaseException): string {
2171
1701
  return (
2172
1702
  `${where} commits to content that the unsigned content handed in ` +
2173
1703
  "beside its pack does not match, and anyone could have supplied " +
2174
- "that content, so what it says about the record is UNKNOWN; the " +
1704
+ "that content, so what it says about the record is unknown; the " +
2175
1705
  "record is still read as a hand-off record" +
2176
1706
  because(exception.reasons)
2177
1707
  );
2178
1708
  case RECORD_PROFILE_QUARANTINED:
2179
1709
  return (
2180
1710
  `${where} is signed under a profile this case reader does not read ` +
2181
- "while its admission is undecided, so what it says is UNKNOWN"
1711
+ "while its admission is undecided, so what it says is unknown"
2182
1712
  );
2183
1713
  case CASE_HOLDS_NO_PACKS:
2184
1714
  return "this case holds no packs";
@@ -2187,12 +1717,13 @@ export function renderException(exception: CaseException): string {
2187
1717
  }
2188
1718
  }
2189
1719
 
2190
- /** The house wording for one NOTE. Offered, not imposed, as `renderException` is. */
1720
+ /** The house wording for one note. */
2191
1721
  export function renderCaseNote(note: CaseNote): string {
2192
1722
  switch (note.kind) {
2193
1723
  case CONTENT_WITHHELD_BY_DECLARATION:
2194
1724
  return (
2195
- `${note.packRef} withholds the content of ${note.count} record(s) by ` +
1725
+ `${note.packRef} withholds the content of ${note.count} ` +
1726
+ `${note.count === 1 ? "record" : "records"} by ` +
2196
1727
  "its own signed declaration: their signatures and commitments were " +
2197
1728
  "checked, their content was not"
2198
1729
  );
@@ -2209,18 +1740,11 @@ export function renderCaseNote(note: CaseNote): string {
2209
1740
  }
2210
1741
 
2211
1742
  /**
2212
- * The scope sentence, printed directly under the headline (evidence-bundle
2213
- * 5.5.6 v1.20, A26), byte for byte the text `render_case_summary` returns.
2214
- *
2215
- * The packs in the reading; the distinct keys, compared as keys, that signed
2216
- * the wrapper of a pack whose wrapper held; the hand-off records; the
2217
- * exceptions; and that the reading was made offline. Where any pack holds
2218
- * records withheld by its declaration, their total; where any pack is noted
2219
- * `pack_undeclared`, how many. Those two lines are part of what the headline
2220
- * MEANS, and a surface MUST print them with it.
2221
- *
2222
- * What was not checked on the public record is no longer a clause here. It is
2223
- * the witness line (`renderWitnessLine`), printed with every headline.
1743
+ * The scope sentence printed under the headline (evidence-bundle §5.5.6): packs,
1744
+ * distinct signing keys of packs whose wrapper held, hand-off records,
1745
+ * exceptions, and that it was read offline, plus withheld and undeclared
1746
+ * counts where non-zero. Those counts are part of what the headline means, and
1747
+ * a surface must print them with it.
2224
1748
  */
2225
1749
  export function renderCaseSummary(reading: CaseReading): string {
2226
1750
  const keys = new Set(reading.packs.filter((p) => p.grade !== null).map((p) => p.signingKey));
@@ -2241,93 +1765,99 @@ export function renderCaseSummary(reading: CaseReading): string {
2241
1765
  return lines.join(" ");
2242
1766
  }
2243
1767
 
2244
- // ---- The headline and the witness line (evidence-bundle 5.5.6 v1.20) -------
2245
-
2246
1768
  /**
2247
- * The one display title of each of the eight 3 words. A surface prints the
2248
- * title, never the word, and never a title of its own (#173, A18). The wording
2249
- * is Will's to change through the glossary (DL-71); what each MEANS is pinned
2250
- * in the spec.
1769
+ * The one display title of each of the three section 3 verdicts. A surface prints the
1770
+ * title, never the word, and never a title of its own.
2251
1771
  */
2252
- export const VERDICT_DISPLAY_TITLES: Readonly<Record<string, string>> = {
2253
- verified: "Verified",
2254
- partial: "Partial",
2255
- pending: "Pending",
2256
- incomplete: "Incomplete",
2257
- failed: "Failed",
2258
- malformed: "Not readable",
2259
- not_found: "Not found",
2260
- mixed: "Mixed",
2261
- };
1772
+ export const VERDICT_DISPLAY_TITLES: Readonly<Record<string, string>> = { ...VERDICT_TITLES };
2262
1773
 
2263
1774
  /**
2264
- * What a reader may rely on, for each of the six words a case reading reaches
2265
- * (5.5.6 v1.20, A26; the `verified` text at v1.28, A35), the same for every
2266
- * drop. `pending` and `not_found` never head a case reading: they are
2267
- * witness states. `verified` is the base sentence, printed alone where the
2268
- * drop holds no hand-off record; a caller reads `caseReliance` for
2269
- * `verified`, never this mapping directly.
1775
+ * What a reader may rely on, for each verdict a case reading reaches (section 5.5.6), the
1776
+ * same for every drop, keyed by the verdict and, for `failed`, its reason kind. Call
1777
+ * `caseReliance` rather than reading `verified` here directly.
2270
1778
  */
2271
- export const CASE_RELIANCE: Readonly<Record<CaseVerdict, string>> = {
1779
+ export const CASE_RELIANCE: Readonly<Record<CaseVerdict | "failed_nonconforming", string>> = {
2272
1780
  verified:
2273
1781
  "Every pack in this drop is genuine, unaltered, and matches its own " +
2274
1782
  "signed declaration. Every record in it was signed by its holder's " +
2275
1783
  "key. This was read offline, on this device. It does not say this " +
2276
1784
  "drop holds everything that was recorded.",
2277
- partial:
2278
- "Nothing here was proven wrong, and a check could not be completed: a " +
2279
- "pack names no witness, or a pack in a hand-off reading does not say " +
2280
- "what it holds. The exceptions say which.",
2281
1785
  incomplete:
2282
1786
  "Nothing here was proven wrong, and part of it could not be checked: " +
2283
1787
  "the other side of a hand-off is not in this drop, bytes were not " +
2284
- "handed over, or a claim could not be read. Each exception says what " +
2285
- "is missing and, where a key is known, whose pack would supply it.",
1788
+ "handed over or could not be read, a pack names no witness, or a " +
1789
+ "claim could not be read. Do not rely on the parts that are not " +
1790
+ "proven. Each exception says what is missing and, where a key is " +
1791
+ "known, whose pack would supply it.",
2286
1792
  failed:
2287
1793
  "A check ran and did not pass. Do not rely on this drop until the " +
2288
1794
  "named failure is explained. The failure is named on the pack or " +
2289
1795
  "record it is about, and every other exception is listed beside it.",
2290
- malformed:
2291
- "Part of this drop could not be read as evidence, and nothing was " +
2292
- "proven wrong. The exceptions name what could not be read.",
2293
- mixed:
2294
- "The readings here differ and none of them failed: some are verified " +
2295
- "and some could not be checked. Read each pack's row and each " +
2296
- "exception before relying on any of it.",
1796
+ failed_nonconforming:
1797
+ "A record in this drop is signed and unaltered and does not follow " +
1798
+ "the vocabulary it names: its producer wrote something the " +
1799
+ "specification does not allow. Nothing was proven altered. The " +
1800
+ "exceptions name the record and the rule it breaks.",
2297
1801
  };
2298
1802
 
2299
1803
  /**
2300
- * The clause a `verified` drop that holds a hand-off record gains, inserted
2301
- * after `CASE_RELIANCE`'s first sentence (5.5.6, A35). Never printed alone,
2302
- * and never where `handoffRecords` is zero: most drops hold one agent's
2303
- * pack and no hand-off to say anything about.
1804
+ * Added to a `verified` reliance line after its first sentence, only where the
1805
+ * drop holds a hand-off record.
2304
1806
  */
2305
1807
  export const CASE_RELIANCE_HANDOFF_CLAUSE =
2306
1808
  "Every hand-off with both sides in this drop is intact.";
2307
1809
 
2308
1810
  /**
2309
- * What a reader may rely on for one case reading (5.5.6 v1.28, A35), byte
2310
- * for byte the text `case_reliance`'s Python sibling returns.
2311
- *
2312
- * For every word but `verified` this is `CASE_RELIANCE` unchanged. For
2313
- * `verified`, the hand-off clause is inserted after the first sentence
2314
- * where `handoffRecords` is not zero, and left out where it is zero: the
2315
- * base sentence never claims a hand-off the drop does not hold.
1811
+ * Added after the reliance line's first sentence, for every verdict, where the
1812
+ * drop holds `environment: "dev"` envelopes: `dev` if all are, `mixed` if some.
1813
+ */
1814
+ export const CASE_RELIANCE_SANDBOX: Readonly<Record<EnvironmentMarker, string>> = {
1815
+ dev:
1816
+ "The records in this drop are sandbox records, signed environment " +
1817
+ "dev: they are not production evidence.",
1818
+ mixed:
1819
+ "Some records in this drop are sandbox records, signed environment " +
1820
+ "dev: those are not production evidence.",
1821
+ };
1822
+
1823
+ /**
1824
+ * What a reader may rely on for one case reading (§5.5.6). The sandbox
1825
+ * sentence, if any, follows the first sentence and precedes the hand-off
1826
+ * clause, which a `verified` reading gains only with `handoffRecords > 0`, so
1827
+ * the line never claims a hand-off the drop does not hold.
2316
1828
  */
2317
- export function caseReliance(verdict: CaseVerdict, handoffRecords: number): string {
1829
+ export function caseReliance(
1830
+ verdict: CaseVerdict,
1831
+ handoffRecords: number,
1832
+ environmentMarker: EnvironmentMarker | null = null,
1833
+ reasonKind: ReasonKind | null = null,
1834
+ ): string {
1835
+ let first: string;
1836
+ let rest: string;
2318
1837
  if (verdict === "verified" && handoffRecords > 0) {
2319
- return (
2320
- "Every pack in this drop is genuine, unaltered, and matches its own " +
2321
- "signed declaration. Every hand-off with both sides in this drop is " +
2322
- "intact. Every record in it was signed by its holder's key. This " +
2323
- "was read offline, on this device. It does not say this drop holds " +
2324
- "everything that was recorded."
2325
- );
1838
+ first = "Every pack in this drop is genuine, unaltered, and matches its own signed declaration.";
1839
+ rest =
1840
+ "Every hand-off with both sides in this drop is intact. Every record " +
1841
+ "in it was signed by its holder's key. This was read offline, on this " +
1842
+ "device. It does not say this drop holds everything that was recorded.";
1843
+ } else {
1844
+ const key =
1845
+ verdict === "failed" && reasonKind === "nonconforming" ? "failed_nonconforming" : verdict;
1846
+ const base = CASE_RELIANCE[key];
1847
+ const cut = base.indexOf(". ");
1848
+ first = base.slice(0, cut + 1);
1849
+ rest = base.slice(cut + 2);
2326
1850
  }
2327
- return CASE_RELIANCE[verdict];
1851
+ // An untyped caller can pass any value; only `dev` and `mixed` add a sentence.
1852
+ const sandbox =
1853
+ environmentMarker === "dev" || environmentMarker === "mixed"
1854
+ ? CASE_RELIANCE_SANDBOX[environmentMarker]
1855
+ : null;
1856
+ if (sandbox === null) return `${first} ${rest}`;
1857
+ return `${first} ${sandbox} ${rest}`;
2328
1858
  }
2329
1859
 
2330
- /** The three witness states, and how each is printed (5.5.6, D7). No other. */
1860
+ /** The three witness states and how each is printed (§5.5.6). No others. */
2331
1861
  export const WITNESS_STATES = {
2332
1862
  confirmed: "Confirmed on the public record",
2333
1863
  not_checked: "Not checked",
@@ -2336,41 +1866,37 @@ export const WITNESS_STATES = {
2336
1866
  export type WitnessState = keyof typeof WITNESS_STATES;
2337
1867
 
2338
1868
  /**
2339
- * The witness line: a lookup of the public record, reported beside the
2340
- * headline and never in it (D7). It never raises or lowers the headline and
2341
- * never changes its title. `reasons` says why a `not_checked` line did not
2342
- * settle, in a fixed order; `services` names each service that answered, as
2343
- * the URL the lookup used: named, never trusted (6).
1869
+ * The result of a public-record lookup, reported beside the headline, never
1870
+ * in it, and never moving it. `reasons` says why `not_checked` did not settle;
1871
+ * `services` names each gateway that answered: named, never trusted.
2344
1872
  */
2345
1873
  export interface WitnessLine {
2346
1874
  state: WitnessState;
2347
1875
  reasons: string[];
2348
1876
  services: string[];
2349
1877
  /**
2350
- * Witness times a pack caches that no lookup settled, as `[blockHeight,
2351
- * blockTime]`, distinct and in time order (5.6.6, v1.25, A30). Printed labelled
2352
- * not checked, and never as a time a reader may rely on. Absent reads as none.
1878
+ * Cached witness times no lookup settled, as `[blockHeight, blockTime]`,
1879
+ * distinct and in time order (§5.6.6). Printed as not checked, never as a
1880
+ * time a reader may rely on.
2353
1881
  */
2354
1882
  cached?: [number, number][];
2355
1883
  /**
2356
- * Witness times the public record returned where they matched a pack's
2357
- * cache, distinct and in time order. The gateway's answer, named with the
2358
- * service, never the cache's value. Absent reads as none.
1884
+ * Witness times the public record returned that matched the cache, distinct
1885
+ * and in time order. Always the gateway's answer, never the cache's value.
2359
1886
  */
2360
1887
  recorded?: [number, number][];
2361
1888
  }
2362
1889
 
2363
- /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
1890
+ /** The line where no lookup ran: every reading this module produces on its own. */
2364
1891
  export const WITNESS_NOT_LOOKED_UP: Readonly<WitnessLine> = Object.freeze({
2365
1892
  state: "not_checked",
2366
1893
  reasons: ["no lookup was run"],
2367
1894
  services: [],
2368
1895
  });
2369
1896
 
2370
- /** The witness line as both kernels print it. */
2371
- /** How a witness time a pack caches is printed where nothing checked it (5.5.6, 5.6.6, v1.25, A30). */
1897
+ /** A cached witness time no lookup checked (§5.6.6). */
2372
1898
  export const WITNESS_TIME_CACHED = "the pack dates the witness, not checked";
2373
- /** How a witness time the public record returned is printed where it matched the cache. */
1899
+ /** A witness time the public record returned that matched the cache. */
2374
1900
  export const WITNESS_TIME_RECORDED = "the public record dates the witness";
2375
1901
 
2376
1902
  function witnessTimes(times: readonly [number, number][]): string {