@sanning/proof 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +339 -822
  9. package/dist/case.js +667 -1160
  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 +814 -1328
  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 +131 -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 +573 -581
  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/handoff.ts CHANGED
@@ -1,155 +1,33 @@
1
- // The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.17).
1
+ // The hand-off pairing rule of specs/envelope-spec.md §4.1: which records are
2
+ // candidates to be the counterpart of a given one, how candidates are ordered,
3
+ // and what a formed pair grades to. The Python half is
4
+ // src/sanning_proof/handoff.py; both must answer identically on identical bytes.
2
5
  //
3
- // Two agents exchange a message and each side records what it held. This module
4
- // is the TypeScript half of the reference reading of those two records: which
5
- // records are candidates to be the counterpart of a given one, how a candidate
6
- // set of more than one is ordered, and what a formed pair grades to. The
7
- // Python half is src/sanning_proof/handoff.py, and the two have to answer
8
- // identically on identical bytes.
6
+ // Every field a record carries is written by the party being audited, so:
9
7
  //
10
- // WHY IT IS WRITTEN THIS WAY (proof#132, ratified by Phil 2026-09-21)
11
- //
12
- // §4.1 as ratified at v1.7 formed a pair from three conjuncts, all three chosen
13
- // by the party being audited and none of them graded: `context.case_id`, the
14
- // mutual naming of each other's key in `event.counterparty_public_key`, and the
15
- // two record types. A receiving side that had tampered with a message could
16
- // change ONE of them, form no pair at all, and have its deviation surface as
17
- // two absences, which §4.1 obliges a reader to treat as innocent.
18
- //
19
- // The amended rule turns on the one thing no party can choose for another:
20
- //
21
- // - Candidates come from who SIGNED. The candidates for a `handoff.sent` are
22
- // the `handoff.received` records signed by the key it named, read from each
23
- // envelope's own `public_key`, which is the binding of §2 and cannot be
24
- // forged. Apart from which of the two types it is, nothing a candidate
25
- // asserts about itself takes it out of that set.
26
- // - Narrowing orders and annotates, and never reduces what is graded.
27
- // `context.case_id` and `event.sequence` put the candidate a reader is most
28
- // likely to want first in the report and remove none of them from grading.
29
- // A narrowing that could drop a candidate was itself a laundering path:
30
- // publish the tampered receipt with one of those two fields changed beside
31
- // a clean decoy that carries them, and the step that "leaves at least one
32
- // candidate" leaves the decoy and drops the tamper.
33
- // - More than one candidate is a named finding, never a free choice: a reader
34
- // permitted to take one match can be handed an honest decoy beside a
35
- // tampered record. The per-candidate readings travel with the report, so
36
- // naming the record and the check that failed survives a junk record that
37
- // outranks it.
38
- // - A record missing a REQUIRED field of its own type is `malformed`, so a
39
- // pair that commits to nothing never reaches the check that would have
40
- // compared absent to absent and found them equal.
41
- // - A pair whose two envelope signing keys are equal does not verify.
42
- // - Every hand-off record a reader holds is read, not only the ones that pair.
43
- // - A formed pair verifies only when each record names the other's signer
44
- // (v1.16, A21), or it fails `counterparty_named`. Up to 0.11.0 grading
45
- // never read whom either record names.
46
- //
47
- // THE THREE ATTACKS THE ADVERSARIAL GATE LANDED (proof#133, rulings 1 to 3),
48
- // and they are one root cause: candidacy and the self-hand-off check compared
49
- // the envelope `public_key` as a STRING, and nothing constrains its spelling,
50
- // so the party being audited chose it.
51
- //
52
- // - A key is compared as a KEY (`normalizedSigningKey`). The record side of a
53
- // key is pinned to 64-char lowercase hex and the envelope side is pinned
54
- // nowhere, so a receiving side that spelled its own envelope key in upper
55
- // case decided whether its own record was a candidate at all: one character
56
- // class on one field put a tampered receipt beside a clean decoy back at
57
- // `verified` with no ambiguity named, and read a self-hand-off signed by
58
- // one key as a transfer between two parties. The normalization sits on the
59
- // `HeldRecord` boundary so a reader cannot skip it. Whether the ENVELOPE
60
- // layer starts rejecting a non-lowercase `public_key` is a
61
- // wire-compatibility question and a founder call (proof#135); nothing here
62
- // changes what an envelope accepts.
63
- // - The reserved namespace bites on any spelling a reader would take for a
64
- // hand-off record (`isHandoffNamespace`). An exact `handoff.` prefix let
65
- // `HANDOFF.RECEIVED`, `sanning.handoff.received`, `handoff-received` and a
66
- // non-string `event_type` disappear from the reading entirely, not even as
67
- // malformed, which composed with a decoy reads `verified` over proven
68
- // tampering from one edit.
69
- //
70
- // THE TWO THE FOURTH HUNT LANDED ON THAT REPAIR (proof#133, rulings 1 and 2),
71
- // and they are one root cause too: each test above was an allow-list over a
72
- // value the audited party writes. Candidacy turned on the spelling of a key and
73
- // scope turned on the spelling of a name, so narrowing the allow-list only
74
- // moved the boundary and the next spelling walked around it.
75
- //
76
- // - Scope turns on the STRUCTURE (`isHandoffNamespace`). A record carrying
77
- // `event.counterparty_public_key`, the field §4.1 defines and no other
78
- // section does, is read whatever its `event_type` says, which closes the
79
- // `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`,
80
- // `handoffs`, a zero-width space, a BOM and a non-breaking space in one
81
- // rule rather than by chasing Unicode. The folded name stays as an
82
- // additional way in for a record that omits the field.
83
- // - A key that does not parse makes the record `malformed`, never absent
84
- // (`recordIsMalformed`). The rule it replaces let such a record drop out
85
- // of candidacy in silence, and the other side then read
86
- // `counterpart_absent`, which §4.1 obliges a reader to call innocent.
87
- // Nine whitespace spellings of a real key produce a VERIFIED envelope in
88
- // exactly that state in the Python kernel, so the first attack came back
89
- // whole THROUGH the repair. Nothing here changes what an envelope accepts,
90
- // and the rule holds either way proof#135 goes.
91
- //
92
- // WHAT THE FIFTH HUNT LANDED ON THAT REPAIR, AND WHY THIS ONE IS DIFFERENT
93
- // (proof#136, ratified by Phil 2026-09-22). Scope was two clauses and both
94
- // operands are written by the party being audited: an exact test for the key
95
- // name `counterparty_public_key`, and an ASCII fold of `event_type`. One code
96
- // point on each (`counterparty_public_k\u0435y` with a Cyrillic `\u0435`,
97
- // `hando\ufb00.received` with a ligature) put the record in scope by NEITHER
98
- // route, so it had no reading at all and the pile read `verified`.
99
- //
100
- // - Scope stops existing. `readHandoffs` returns a reading for EVERY record
101
- // it is given, in the order given, and a record that is not a conformant
102
- // hand-off reads with `NOT_A_HANDOFF_FINDING` rather than producing
103
- // silence. Any rule with a silent branch is a rule the audited party
104
- // steers into: five repairs narrowed which values reached the branch and
105
- // five hunts found a value outside it, so the branch is removed rather
106
- // than narrowed a sixth time.
107
- // - The declaration names the hand-offs (`isConformantHandoffRecord`, read
108
- // by `./declaration.ts`). The gain was that a record could be unreadable
109
- // NOW and usable LATER; a claimed side that is not a conformant hand-off
110
- // record now fails at the pack layer.
111
- // - Missing and explicit null name one thing (`namedCase`). Python's missing
112
- // value is `None` and this kernel's is `undefined`, so a candidate
113
- // carrying `"case_id": null` was "same case" in one kernel and not in the
114
- // other: same verdict, different candidate ORDER, on a value the audited
115
- // party writes.
116
- //
117
- // No verdict word is coined: `verified`, `failed` and `malformed` are already in
118
- // use, and `counterpart_absent`, `counterpart_ambiguous` and
119
- // `not_a_handoff_record` are findings reported beside a verdict rather than
120
- // verdicts.
121
- //
122
- // THE COST OF GRADING EVERY CANDIDATE, AND THE ACCOUNTED-FOR RULE THAT PAYS
123
- // MOST OF IT (envelope-spec.md 4.1 v1.17, amendment A20). Grading every
124
- // candidate means the records of a party's OTHER exchanges are candidates too,
125
- // and they fail. Up to v1.16 that put `verified` out of reach for any two agents
126
- // that exchanged more than one message in one direction, and the declaration
127
- // named as the belt cannot be one: evidence-bundle.md 5.5 forbids the reading
128
- // to consult `handoffs[]` to pair anything. So a candidate that grades `failed`
129
- // is SET ASIDE from the subject's verdict, failed checks and ambiguity when,
130
- // and only when, the reader holds another record `o` that accounts for it
131
- // (`accountedFor` below): `o` is signed by the subject's own key and is the
132
- // subject's type; `o` names the candidate's signer and the candidate names the
133
- // subject's key back; `o`'s sequence differs from the subject's; and the
134
- // candidate grades `verified` against `o`. The candidate stays in `candidates`
135
- // and `candidateReadings`, with `accountedFor` set. Setting one aside needs a
136
- // verified pair with a record the OTHER side signed, so a tampered record
137
- // stays live. Fan-in, fan-out, and one pair in two cases at one sequence still
138
- // read `failed`: the Python half's module docstring says why.
139
-
140
- export const CANDIDATE_NAMESPACE = "handoff.";
141
- export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
8
+ // - Candidates come from who signed: the `agent.handoff.received` records signed by
9
+ // the key an `agent.handoff.sent` names, read from each envelope's own `public_key`.
10
+ // Keys are compared as keys (`normalizedSigningKey`), never as strings.
11
+ // - Ordering (`case_id`, then `sequence`) never removes a candidate. A filter
12
+ // would let a clean decoy shadow a tampered receipt.
13
+ // - More than one live candidate is the finding `counterpart_ambiguous`.
14
+ // - A record whose envelope key does not parse is `malformed`, never absent:
15
+ // absence reads as innocent. An `agent.handoff.*` record missing a required
16
+ // field, or one the agent reader reads `nonconforming`, is `nonconforming`.
17
+ // - A pair signed by one key on both ends fails `self_handoff`, and each
18
+ // record must name the other's signer or it fails `counterparty_named`.
19
+ // - Every record gets a reading. There is no silent branch for a spelling to
20
+ // steer into: a record that is not a hand-off reads `NOT_A_HANDOFF_FINDING`.
21
+ // - A failed candidate is set aside (`accountedFor`) only when the reader
22
+ // holds a verified pair proving it belongs to another exchange.
23
+
24
+ export const CANDIDATE_NAMESPACE = "agent.handoff.";
25
+ export const DEFINED_EVENT_TYPES = ["agent.handoff.sent", "agent.handoff.received"] as const;
142
26
 
143
27
  /**
144
- * The finding a record that is not a hand-off record at all reads with.
145
- *
146
- * A FINDING and not a verdict: the ratified verdict words are closed
147
- * (anchor#188) and this reading names an outcome beside one, exactly as
148
- * `counterpart_absent`, `counterpart_ambiguous` and `malformed_record` do. It
149
- * exists because 4.1 had a SILENT branch, and a rule with a silent branch is a
150
- * rule the party being audited steers into (proof#136). A record TRYING to be
151
- * a hand-off and getting it wrong reads `malformed`; a record that is not a
152
- * hand-off at all reads this. Both are readings. Neither is silence.
28
+ * The finding for a record that is not a hand-off record at all. A finding
29
+ * beside a verdict, not a verdict: a record that tries to be a hand-off and
30
+ * gets it wrong reads `malformed` or `nonconforming` instead. Neither is silence.
153
31
  */
154
32
  export const NOT_A_HANDOFF_FINDING = "not_a_handoff_record";
155
33
  const OUTCOMES = ["accepted", "rejected", "failed"];
@@ -157,30 +35,37 @@ const ID_PATTERN = /^[A-Za-z0-9_.:-]+$/;
157
35
  const HEX64 = /^[0-9a-fA-F]{64}$/;
158
36
  const LOWER_HEX64 = /^[0-9a-f]{64}$/;
159
37
 
160
- // The order a reader names the checks a formed pair failed, fixed so two
161
- // kernels report one list rather than two orderings of it.
38
+ // Fixed so two kernels report one list rather than two orderings of it.
162
39
  const CHECK_ORDER = ["message_hash", "sequence", "self_handoff", "counterparty_named"] as const;
163
- const VERDICT_RANK: Record<string, number> = { verified: 0, failed: 1, malformed: 2 };
40
+ // The verdict a defect gives a record (evidence-bundle.md section 3): a record that cannot be
41
+ // read as a hand-off is `incomplete` (its claim is not proven, unreadable), and one that
42
+ // breaks its vocabulary is `failed` with nothing proven altered.
43
+ const DEFECT_VERDICT = { malformed: "incomplete", nonconforming: "failed" } as const;
44
+
45
+ /**
46
+ * Which candidate a subject reads as, worst last. A proven check failure outranks a
47
+ * record that breaks its vocabulary (`failed` with no check named), and a record that
48
+ * cannot be read outranks both, so one junk candidate is named as itself
49
+ * (`counterpart_malformed`) and never as a failed pair.
50
+ */
51
+ function rank(verdict: HandoffVerdict, failedChecks: readonly string[]): number {
52
+ if (verdict === "incomplete") return 3;
53
+ if (verdict === "failed") return failedChecks.length > 0 ? 2 : 1;
54
+ return 0;
55
+ }
164
56
 
165
- // ASCII whitespace, listed rather than delegated: `String.trim()` and Python's
166
- // `str.strip()` do not strip the same set (`U+FEFF` is whitespace to one and
167
- // not to the other), and a fold the two kernels disagree about is a divergence
168
- // on identical bytes (proof#133 review, ruling 3).
57
+ // Listed rather than delegated: `String.trim()` and Python's `str.strip()`
58
+ // disagree on `U+FEFF`, and the kernels must fold identically.
169
59
  const ASCII_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\v", "\f"]);
170
60
 
171
61
  /**
172
- * An envelope `public_key` as a KEY rather than as a string: the 32 key bytes
173
- * written as lowercase hex, or `null` where the value is not 64 characters of
174
- * hex and so names no key this reading can compare.
62
+ * An envelope `public_key` as a key: the 32 key bytes as lowercase hex, or
63
+ * `null` where the value is not 64 hex characters.
175
64
  *
176
- * Case is the whole point: §2 states lowercase as a PRODUCER convention, the
177
- * envelope layer accepts either case in every kernel, and the party being
178
- * audited is the producer (proof#133 adversarial gate, ruling 1). Nothing
179
- * wider is folded: whitespace inside the value is not stripped, because
180
- * Python's `bytes.fromhex` skips ASCII whitespace and this kernel's charset
181
- * check does not, a live divergence at the ENVELOPE layer and a separate
182
- * founder call (proof#135). A reading rule that silently repaired it would
183
- * hide it.
65
+ * Case is folded because the envelope layer accepts either case and the
66
+ * audited party is the producer. Whitespace is not stripped: Python's
67
+ * `bytes.fromhex` skips it and this kernel does not, and repairing that here
68
+ * would hide the divergence.
184
69
  */
185
70
  export function normalizedSigningKey(value: unknown): string | null {
186
71
  if (typeof value !== "string" || !HEX64.test(value)) return null;
@@ -190,20 +75,9 @@ export function normalizedSigningKey(value: unknown): string | null {
190
75
  }
191
76
 
192
77
  /**
193
- * Whether two envelope keys are the SAME KEY. Two values that name a key are
194
- * equal when their key bytes are equal.
195
- *
196
- * Where either side names no key, the question cannot be answered from key
197
- * bytes and the answer is the conservative one: the two are reported as one
198
- * key, so the pair reads `failed[self_handoff]` rather than clean. Exact
199
- * string equality was the answer here and it was the wrong way round: one key
200
- * that signed both ends, spelled `abcd...` on one envelope and `abcd ...` on
201
- * the other, is one party, and string equality called it two and read the
202
- * self-hand-off `verified` (proof#133 fourth hunt, finding 1). A record whose
203
- * own envelope key this reading cannot read is `malformed` under
204
- * `recordIsMalformed` and a formed pair reaches `malformed` before it reaches
205
- * this line, so the two rules are belt and braces rather than one rule stated
206
- * twice.
78
+ * Whether two envelope keys are the same key. Where either side names no key,
79
+ * the answer is the conservative one (same key), so the pair reads
80
+ * `failed[self_handoff]` rather than clean.
207
81
  */
208
82
  export function sameSigningKey(left: unknown, right: unknown): boolean {
209
83
  const leftKey = normalizedSigningKey(left);
@@ -213,11 +87,9 @@ export function sameSigningKey(left: unknown, right: unknown): boolean {
213
87
  }
214
88
 
215
89
  /**
216
- * The spelling of an `event_type`, folded for the namespace test only: ASCII
217
- * whitespace off each end, ASCII upper case down to lower case, and `-` and
218
- * `_` read as the `.` separator. Every step is spelled out rather than
219
- * delegated to `toLowerCase()` or `trim()`, which do not agree with Python on
220
- * non-ASCII input.
90
+ * An `event_type` folded for the namespace test only: ASCII whitespace
91
+ * trimmed, ASCII lower-cased, `-` and `_` read as `.`. Spelled out because
92
+ * `toLowerCase()` and `trim()` disagree with Python on non-ASCII input.
221
93
  */
222
94
  function foldEventType(value: string): string {
223
95
  const characters = [...value];
@@ -234,7 +106,7 @@ function foldEventType(value: string): string {
234
106
  .join("");
235
107
  }
236
108
 
237
- export type HandoffVerdict = "verified" | "failed" | "malformed";
109
+ export type HandoffVerdict = "verified" | "failed" | "incomplete";
238
110
 
239
111
  export interface HandoffRecord {
240
112
  event_type?: unknown;
@@ -245,67 +117,36 @@ export interface HandoffRecord {
245
117
  }
246
118
 
247
119
  /**
248
- * One hand-off record as a reader holds it: the committed record, plus the
249
- * `public_key` of the envelope that carries it. The key comes from the ENVELOPE
250
- * and never from the record, which is the whole point of the amended rule.
120
+ * One hand-off record as a reader holds it: the committed record plus the
121
+ * `public_key` of the envelope that carries it. The key comes from the
122
+ * envelope, never from the record.
251
123
  *
252
- * PRECONDITION: THE ENVELOPE MUST ALREADY HAVE VERIFIED. "Unforgeable" is a
253
- * property of a VERIFIED envelope and of nothing else. The key on an envelope
254
- * whose signature has not been checked is an assertion like any other, and
255
- * every reading in this module is then worth exactly as much as that
256
- * assertion. A reader builds a `HeldRecord` only from an envelope that
257
- * `verifyEnvelope` returned `ok` for, bound to the canonical bytes of this
258
- * record. Nothing in this module can check that for itself, which is why it is
259
- * stated here (proof#133 adversarial gate, ruling 3), and why candidacy and
260
- * the self-hand-off check read `normalizedSigningKey(publicKey)` rather than
261
- * the spelling the audited party chose.
124
+ * Precondition: build a `HeldRecord` only from an envelope `verifyEnvelope`
125
+ * returned `ok` for. An unverified key is an assertion like any other, and this
126
+ * module cannot check it.
262
127
  */
263
128
  export interface HeldRecord {
264
129
  publicKey: string;
265
130
  record: HandoffRecord;
266
131
  ref: string;
132
+ /**
133
+ * The agent reader read this record `failed` (agent-events.md section
134
+ * 10). Set only by a reader that read the record with the pile it is held in
135
+ * (the case reading); the section 4.1 reading then takes the record as
136
+ * nonconforming, so its counterpart reads its candidate as nonconforming.
137
+ */
138
+ agentNonconforming?: boolean;
267
139
  }
268
140
 
269
141
  /**
270
- * Whether a reader reads this record as a hand-off record at all.
271
- *
272
- * §4.1 says a reader reads EVERY hand-off record it holds, and an exact
273
- * `handoff.` prefix did not deliver that: `HANDOFF.RECEIVED`,
274
- * `sanning.handoff.received`, `handoff-received` and a non-string `event_type`
275
- * all vanished from the reading entirely, not even as malformed, so a receipt
276
- * typed one character outside the prefix was never read and, beside a clean decoy,
277
- * published `verified` over proven tampering (proof#133 adversarial gate,
278
- * ruling 2).
279
- *
280
- * The rule is checkable rather than a judgement, and it is exactly two
281
- * clauses. The FIRST is the one the adversary cannot evade: the record's
282
- * `event` carries `counterparty_public_key`, the field §4.1 defines and no
283
- * other section does, WHATEVER its `event_type` says. The second keeps a
284
- * record that omits the field in scope: an `event_type` that is a string whose
285
- * folded form has `handoff` as one of its `.`-separated segments, which leaves
286
- * `asset_missing` and the other families alone.
287
- *
288
- * The order is the fix, not decoration. Every earlier version of this test was
289
- * an ALLOW-LIST OVER A NAME THE AUDITED PARTY WRITES, so each repair narrowed
290
- * the class and the next spelling walked around it: an exact `handoff.` prefix
291
- * fell to `HANDOFF.RECEIVED`, and the ASCII fold that replaced it fell to the
292
- * `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`, `handoffs`, a
293
- * zero-width space, a BOM and a non-breaking space, each of which left a
294
- * tampered receipt with no reading at all beside a clean decoy (proof#133
295
- * fourth hunt, finding 2). The structural field closes all of them in one rule
296
- * rather than by chasing Unicode, because the attack needs the field: a
297
- * receipt that drops `counterparty_public_key` says nothing about who it came
298
- * from.
142
+ * Whether a record is in the hand-off namespace: its `event` carries
143
+ * `counterparty_public_key` (the field §4.1 defines), whatever its `event_type`
144
+ * says, or its folded `event_type` has `handoff` as a `.`-separated segment.
299
145
  *
300
- * Anything in scope that is not exactly `handoff.sent` or `handoff.received`
301
- * is `malformed`, so a spelling is never a way out.
302
- *
303
- * THIS IS NO LONGER A GATE ON BEING READ (proof#136). Both clauses are written
304
- * by the party being audited, and one code point on each put a record in scope
305
- * by neither, which produced silence. `readHandoffs` reads every record it
306
- * holds, and this test now decides only WHICH reading a record gets: in scope
307
- * and wrong reads `malformed`, out of scope reads `NOT_A_HANDOFF_FINDING`. A
308
- * respelling buys a different line rather than no line.
146
+ * The structural clause comes first because a name is written by the audited
147
+ * party and Unicode respellings walk around any allow-list. This decides only
148
+ * which reading a record gets: in scope and wrong reads `malformed`, out of
149
+ * scope reads `NOT_A_HANDOFF_FINDING`.
309
150
  */
310
151
  export function isHandoffNamespace(held: HeldRecord): boolean {
311
152
  if ("counterparty_public_key" in section(held.record, "event")) return true;
@@ -314,24 +155,17 @@ export function isHandoffNamespace(held: HeldRecord): boolean {
314
155
  }
315
156
 
316
157
  /**
317
- * What one candidate graded to, kept beside the aggregate. 4.1 requires a
318
- * reader to name the record and which check failed, and `malformed` outranks
319
- * `failed`, so one junk record signed by the counterparty key would otherwise
320
- * turn a named `message_hash` failure into a `malformed` with nothing named
321
- * (proof#133 review, ruling 6).
158
+ * What one candidate graded to, kept beside the aggregate, so a junk record
159
+ * that outranks it (`malformed` over `failed`) cannot hide a named failure.
322
160
  */
323
161
  export interface CandidateReading {
324
162
  ref: string;
325
163
  verdict: HandoffVerdict;
326
164
  failedChecks: string[];
327
165
  /**
328
- * The ref of the record that ACCOUNTS FOR this candidate, or `null` (4.1
329
- * v1.17, A20). Set only on a candidate that graded `failed` and that the
330
- * reader holds a verified pair for elsewhere; such a candidate is still
331
- * reported here and takes no part in the subject's verdict, failed checks or
332
- * ambiguity. Every reading this kernel returns carries it. It is optional in
333
- * the type only so that code which builds a `CandidateReading` itself keeps
334
- * compiling: the field is an addition.
166
+ * The ref of the record that accounts for this failed candidate, or `null`.
167
+ * An accounted-for candidate takes no part in the subject's verdict, failed
168
+ * checks or ambiguity. Optional only so caller-built readings compile.
335
169
  */
336
170
  accountedFor?: string | null;
337
171
  }
@@ -345,10 +179,8 @@ export interface HandoffReading {
345
179
  /** Ordered, never filtered. */
346
180
  candidates: string[];
347
181
  /**
348
- * One entry per candidate, in the same order, and nothing left out of it. A
349
- * candidate another record accounts for is here with `accountedFor` set, and
350
- * where every candidate is accounted for the reading is `counterpart_absent`
351
- * with the candidates still listed.
182
+ * One entry per candidate, in the same order. Where every candidate is
183
+ * accounted for, the reading is `counterpart_absent` with them still listed.
352
184
  */
353
185
  candidateReadings: CandidateReading[];
354
186
  }
@@ -368,46 +200,50 @@ function isSafeSequence(value: unknown): boolean {
368
200
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
369
201
  }
370
202
 
371
- // Code POINTS, not UTF-16 units. `"\u{1F600}".length` is 2 in JavaScript and
372
- // `len()` of the same character is 1 in Python, so a length bound counted in
373
- // units makes the two kernels disagree about identical bytes (proof#133
374
- // review, ruling 3).
203
+ // Code points, not UTF-16 units, so a length bound matches Python's `len()`.
375
204
  function codePoints(value: string): number {
376
205
  return [...value].length;
377
206
  }
378
207
 
379
- // `===` rather than `==`, and the Python half now matches: `True == 1` holds in
380
- // Python and `true === 1` does not in JavaScript, which let a record carrying
381
- // `"sequence": true` stay in one kernel's reading and drop out of the other's.
208
+ // Strict: Python's half also rejects `True == 1`, so `"sequence": true` cannot
209
+ // read differently in the two kernels.
382
210
  function sameNumber(left: unknown, right: unknown): boolean {
383
211
  return left === right;
384
212
  }
385
213
 
386
214
  /**
387
- * A record missing a REQUIRED field of its own type, or carrying one that is
388
- * not the type §4.1 states, is malformed and fails. Stated as a rule because
389
- * §4.1 had none: a pair where both sides omitted `message_hash` compared absent
390
- * to absent, found them equal, and graded intact (proof#132).
215
+ * What is wrong with a record section 4.1 governs, read on its own, or `null`.
391
216
  *
392
- * A record whose ENVELOPE key this reading cannot read is malformed too, and
393
- * that one is a repair rather than an omission. The rule used to be "a value
394
- * that is not 64 characters of hex names no key and matches nothing", so such a
395
- * record dropped out of candidacy in silence and the subject read
396
- * `counterpart_absent`, which §4.1 obliges a reader to call innocent. Nine
397
- * whitespace spellings of a real key produce a VERIFIED envelope in exactly
398
- * that state in the Python kernel, because `bytes.fromhex` skips ASCII
399
- * whitespace, which reopened the whole of the first attack through the repair
400
- * rather than around it (proof#133 fourth hunt, finding 1). A record a reader
401
- * HOLDS, whose own key it cannot read, is a defect in the record and not an
402
- * absence of one. This holds whatever the envelope layer decides about its hex
403
- * charset (proof#135).
217
+ * `malformed` where it cannot be read as a hand-off record: its envelope key
218
+ * does not parse (an unreadable key would drop it out of candidacy and the
219
+ * other side would read an innocent `counterpart_absent`), or its type is a
220
+ * respelling of the two names. `nonconforming` where it is one of the two types
221
+ * and a required field is missing or mistyped (two records that both omit
222
+ * `message_hash` would otherwise compare equal), or the agent reader read it
223
+ * `failed` (`agentNonconforming`).
404
224
  */
405
- export function recordIsMalformed(held: HeldRecord): boolean {
406
- if (normalizedSigningKey(held.publicKey) === null) return true;
225
+ export function recordDefect(held: HeldRecord): "malformed" | "nonconforming" | null {
226
+ if (normalizedSigningKey(held.publicKey) === null) return "malformed";
407
227
  const type = eventType(held);
408
228
  if (typeof type !== "string" || !(DEFINED_EVENT_TYPES as readonly string[]).includes(type)) {
409
- return true; // including any other handoff.* name, which A11 forbids
229
+ return "malformed"; // including any other handoff.* name
410
230
  }
231
+ if (held.agentNonconforming === true || breaksItsType(held, type)) return "nonconforming";
232
+ return null;
233
+ }
234
+
235
+ /** `recordDefect` is `malformed`. */
236
+ export function recordIsMalformed(held: HeldRecord): boolean {
237
+ return recordDefect(held) === "malformed";
238
+ }
239
+
240
+ /** `recordDefect` is `nonconforming`. */
241
+ export function recordIsNonconforming(held: HeldRecord): boolean {
242
+ return recordDefect(held) === "nonconforming";
243
+ }
244
+
245
+ /** A record of one of the two types missing a field section 4.1 requires, or carrying one it does not allow. */
246
+ function breaksItsType(held: HeldRecord, type: string): boolean {
411
247
  const raw = held.record;
412
248
  for (const key of ["event", "context"] as const) {
413
249
  const value = raw[key];
@@ -433,18 +269,15 @@ export function recordIsMalformed(held: HeldRecord): boolean {
433
269
  }
434
270
  if (!isSafeSequence(event.sequence)) return true;
435
271
 
436
- if (type === "handoff.received") {
272
+ if (type === "agent.handoff.received") {
437
273
  if (typeof event.outcome !== "string" || !OUTCOMES.includes(event.outcome)) return true;
438
274
  } else if ("outcome" in event) {
439
- return true; // outcome is a handoff.received field only
275
+ return true; // outcome is an agent.handoff.received field only
440
276
  }
441
277
 
442
- // The OPTIONAL fields are optional: absence is never malformed, and a present
443
- // one still has to be what §4.1 says it is.
444
- //
445
- // `message_bytes_hash` is NOT checked here. Its own row in 4.1 says a reader
446
- // MUST NOT match, pair or grade on it, and calling a record malformed for it
447
- // is grading on it (proof#133 review, ruling 5).
278
+ // Optional fields: absence is never a defect, but a present one must have
279
+ // its §4.1 type. `message_bytes_hash` is not checked: §4.1 says a reader must
280
+ // not grade on it.
448
281
  if ("case_id" in context) {
449
282
  const value = context.case_id;
450
283
  if (typeof value !== "string" || codePoints(value) > 128 || !ID_PATTERN.test(value))
@@ -463,27 +296,16 @@ export function recordIsMalformed(held: HeldRecord): boolean {
463
296
  }
464
297
 
465
298
  function complement(type: unknown): string {
466
- return type === "handoff.sent" ? "handoff.received" : "handoff.sent";
299
+ return type === "agent.handoff.sent" ? "agent.handoff.received" : "agent.handoff.sent";
467
300
  }
468
301
 
469
302
  /**
470
- * Who signed, and nothing a candidate says about itself.
471
- *
472
- * The complementary record type is the one conjunct that stays hard, and the
473
- * reason is the honest two-way case rather than a preference: an honest reply is
474
- * a `handoff.sent` signed by the other agent, naming this one, under the same
475
- * case identifier and at the same sequence number, which is byte-for-byte the
476
- * shape of a receiving side that mislabels its receipt. Admitting same-type
477
- * candidates would grade an honest reply as a failed hand-off. §4.1 names that
478
- * residual path, and the declaration (evidence-bundle.md §5.4) is its belt.
303
+ * The candidates for a subject, chosen by who signed. The complementary type is
304
+ * required because an honest reply has exactly the shape of a mislabelled
305
+ * receipt; the declaration (evidence-bundle.md §5.4) covers that residual path.
479
306
  */
480
307
  function candidatesFor(subject: HeldRecord, held: readonly HeldRecord[]): HeldRecord[] {
481
- // KEYS, never spellings of a key. Comparing the raw strings handed the
482
- // audited party the set itself: an envelope key spelled in upper case names
483
- // the same key, the record side is pinned to lowercase hex and the envelope
484
- // side is pinned nowhere, so one character class took the tampered receipt
485
- // out of the set and left the clean decoy in it (proof#133 adversarial
486
- // gate, ruling 1).
308
+ // Keys, never spellings of a key: the envelope side's case is unconstrained.
487
309
  const wanted = normalizedSigningKey(section(subject.record, "event").counterparty_public_key);
488
310
  if (wanted === null) return []; // the subject names no key
489
311
  const wantedType = complement(eventType(subject));
@@ -496,19 +318,9 @@ function candidatesFor(subject: HeldRecord, held: readonly HeldRecord[]): HeldRe
496
318
  }
497
319
 
498
320
  /**
499
- * Order a candidate set. It REMOVES NOTHING.
500
- *
501
- * `context.case_id` and then `event.sequence` put the candidate a reader is most
502
- * likely to want at the front of the report, and both are written by the party
503
- * being audited, so neither decides what gets graded. A version of this function
504
- * that filtered, "applying a step only where it leaves at least one candidate",
505
- * was itself a laundering path: the receiving side publishes the tampered
506
- * receipt with its `case_id` or its `sequence` changed and a clean decoy that
507
- * carries the subject's values, the surviving decoy keeps the step alive, the
508
- * tamper drops out of the set, and the reading is `verified` with no ambiguity
509
- * named (proof#133 review, ruling 1).
510
- *
511
- * The sort is total and deterministic, so the two kernels report one order.
321
+ * Order a candidate set by `case_id`, then `sequence`. Removes nothing: both
322
+ * fields are written by the audited party. The sort is total so the two kernels
323
+ * report one order.
512
324
  */
513
325
  function order(subject: HeldRecord, candidates: HeldRecord[]): HeldRecord[] {
514
326
  const wantedCase = namedCase(section(subject.record, "context"));
@@ -529,16 +341,8 @@ function order(subject: HeldRecord, candidates: HeldRecord[]): HeldRecord[] {
529
341
  }
530
342
 
531
343
  /**
532
- * Which case a record names, for the ordering step only.
533
- *
534
- * An ABSENT `context.case_id` and one written as `null` name the same thing,
535
- * which is no case. Python's missing value is `None` and this kernel's is
536
- * `undefined`, so a candidate carrying `"case_id": null` was "same case" in
537
- * one kernel and not in the other when the subject lawfully omits the OPTIONAL
538
- * key: same verdict, DIFFERENT candidate order, and the cross-kernel gate's
539
- * own contract is that the reading it compares carries the candidates in
540
- * order. The party being audited writes that `null` (proof#133 fifth hunt,
541
- * closed by proof#136).
344
+ * Which case a record names, for ordering only. Absent and `null` both mean no
345
+ * case, so Python's `None` and this kernel's `undefined` order identically.
542
346
  */
543
347
  function namedCase(context: Record<string, unknown>): unknown {
544
348
  const value = context.case_id;
@@ -546,11 +350,8 @@ function namedCase(context: Record<string, unknown>): unknown {
546
350
  }
547
351
 
548
352
  /**
549
- * Whether two records name the same case, for the ordering step only. Two
550
- * records that name NO case are in the same position, and a case identifier is
551
- * a string (4.1), so anything else names no case a reader can compare. Stated
552
- * in one rule in both kernels so `None` and `undefined` cannot mean two
553
- * things.
353
+ * Whether two records name the same case, for ordering only. Two records naming
354
+ * no case match; a non-string names no case.
554
355
  */
555
356
  function isSameCase(left: unknown, right: unknown): boolean {
556
357
  if (left === null && right === null) return true;
@@ -561,13 +362,10 @@ function gradePair(
561
362
  subject: HeldRecord,
562
363
  counterpart: HeldRecord,
563
364
  ): { verdict: HandoffVerdict; checks: string[] } {
564
- if (recordIsMalformed(subject) || recordIsMalformed(counterpart)) {
565
- return { verdict: "malformed", checks: [] };
566
- }
365
+ const defects = [recordDefect(subject), recordDefect(counterpart)];
366
+ if (defects.includes("malformed")) return { verdict: DEFECT_VERDICT.malformed, checks: [] };
367
+ if (defects.includes("nonconforming")) return { verdict: DEFECT_VERDICT.nonconforming, checks: [] };
567
368
  const failed = new Set<string>();
568
- // The same KEY, whatever the two envelopes spelled it as: one key signing
569
- // both ends spelled upper case on one side and lower on the other read as a
570
- // transfer between two parties (proof#133 adversarial gate, ruling 1).
571
369
  if (sameSigningKey(subject.publicKey, counterpart.publicKey)) failed.add("self_handoff");
572
370
  const subjectEvent = section(subject.record, "event");
573
371
  const counterpartEvent = section(counterpart.record, "event");
@@ -575,36 +373,24 @@ function gradePair(
575
373
  const counterpartHash = String(counterpartEvent.message_hash ?? "").toLowerCase();
576
374
  if (subjectHash !== counterpartHash) failed.add("message_hash");
577
375
  if (!sameNumber(subjectEvent.sequence, counterpartEvent.sequence)) failed.add("sequence");
578
- // Each record names the OTHER's signer (v1.16, A21). Candidacy reads the key
579
- // the subject names and nothing the candidate says, so up to and including
580
- // 0.11.0 a sent record addressed to C paired `verified` with B's receipt of
581
- // it, and a receipt naming C as its sender paired `verified` with A's send.
582
- // Both halves of the naming are graded, each against the envelope key the
583
- // other record was SIGNED with, compared as a key. The Python half is
584
- // `_grade_pair`.
376
+ // Each record must name the other's signer, compared as a key against the
377
+ // envelope key the other was signed with. Candidacy alone reads only what the
378
+ // subject names.
585
379
  const [sent, received] =
586
- eventType(subject) === "handoff.sent" ? [subject, counterpart] : [counterpart, subject];
380
+ eventType(subject) === "agent.handoff.sent" ? [subject, counterpart] : [counterpart, subject];
587
381
  const sentNames = normalizedSigningKey(section(sent.record, "event").counterparty_public_key);
588
382
  const receivedNames = normalizedSigningKey(
589
383
  section(received.record, "event").counterparty_public_key,
590
384
  );
591
- // The send names who received it.
592
385
  if (sentNames !== normalizedSigningKey(received.publicKey)) failed.add("counterparty_named");
593
- // The receipt names who sent it.
594
386
  if (receivedNames !== normalizedSigningKey(sent.publicKey)) failed.add("counterparty_named");
595
387
  const checks = CHECK_ORDER.filter((name) => failed.has(name));
596
388
  return { verdict: checks.length > 0 ? "failed" : "verified", checks: [...checks] };
597
389
  }
598
390
 
599
391
  /**
600
- * Read ONE record against everything the reader holds.
601
- *
602
- * Every record gets a reading, and the two findings are how the distinction a
603
- * reader needs survives (proof#136): a record that is not a hand-off record at
604
- * all reads `NOT_A_HANDOFF_FINDING` with no verdict, and a record 4.1 DOES
605
- * govern and that is wrong reads `malformed`. The scope test lives HERE and
606
- * not in `readHandoffs`, so the two entry points cannot answer differently
607
- * about one record.
392
+ * Read one record against everything the reader holds. The scope test lives
393
+ * here, not in `readHandoffs`, so the two entry points cannot disagree.
608
394
  */
609
395
  export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): HandoffReading {
610
396
  if (!isHandoffNamespace(subject)) {
@@ -617,12 +403,13 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
617
403
  candidateReadings: [],
618
404
  };
619
405
  }
620
- if (recordIsMalformed(subject)) {
406
+ const defect = recordDefect(subject);
407
+ if (defect !== null) {
621
408
  return {
622
409
  ref: subject.ref,
623
- verdict: "malformed",
410
+ verdict: DEFECT_VERDICT[defect],
624
411
  failedChecks: [],
625
- findings: ["malformed_record"],
412
+ findings: [`${defect}_record`],
626
413
  candidates: [],
627
414
  candidateReadings: [],
628
415
  };
@@ -640,21 +427,18 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
640
427
  };
641
428
  }
642
429
 
643
- // EVERY candidate is graded. Ordering above chose where each one is reported
644
- // and took none of them out of this list.
430
+ // Every candidate is graded; ordering only chose where each is reported.
645
431
  const graded: CandidateReading[] = candidates.map((candidate) => {
646
432
  const { verdict, checks } = gradePair(subject, candidate);
647
- const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
433
+ // Only a pair that failed a check can be another exchange's record.
434
+ const by =
435
+ verdict === "failed" && checks.length > 0 ? accountedFor(subject, candidate, held) : null;
648
436
  return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
649
437
  });
650
438
 
651
- // The verdict, the failed checks and the ambiguity are taken over the
652
- // candidates nothing accounts for. The others stay in the report.
653
439
  const live = graded.filter((g) => g.accountedFor === null);
654
440
  if (live.length === 0) {
655
- // Every candidate is another exchange's record, each proven so by a
656
- // verified pair with a record the subject's own key signed: an absence,
657
- // reported with the candidates it looked at.
441
+ // Every candidate belongs to another exchange: an absence.
658
442
  return {
659
443
  ref: subject.ref,
660
444
  verdict: null,
@@ -664,23 +448,18 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
664
448
  candidateReadings: graded,
665
449
  };
666
450
  }
667
- const worst = Math.max(...live.map((g) => VERDICT_RANK[g.verdict]));
668
- const verdict = (Object.keys(VERDICT_RANK).find(
669
- (name) => VERDICT_RANK[name] === worst,
670
- ) as HandoffVerdict)!;
671
-
672
- // Over every live candidate, not only the ones at the winning verdict:
673
- // `malformed` outranks `failed`, so a single junk record would otherwise
674
- // strip the named `message_hash` failure out of the report (proof#133
675
- // review, ruling 6).
451
+ const worst = Math.max(...live.map((g) => rank(g.verdict, g.failedChecks)));
452
+ const verdict: HandoffVerdict = (["verified", "failed", "failed", "incomplete"] as const)[worst]!;
453
+
454
+ // Over every live candidate: `malformed` outranks `failed`, and one junk
455
+ // record must not strip a named failure from the report.
676
456
  const failed = new Set<string>();
677
457
  for (const g of live) for (const check of g.failedChecks) failed.add(check);
678
458
 
679
459
  const findings: string[] = [];
680
- // Never a free choice: a reader permitted to take one match can be handed an
681
- // honest decoy beside a tampered record.
682
460
  if (live.length > 1) findings.push("counterpart_ambiguous");
683
- if (verdict === "malformed") findings.push("malformed_record");
461
+ if (worst === 3) findings.push("malformed_record");
462
+ else if (worst === 1) findings.push("nonconforming_record");
684
463
 
685
464
  return {
686
465
  ref: subject.ref,
@@ -693,23 +472,18 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
693
472
  }
694
473
 
695
474
  /**
696
- * The ref of the record that accounts for a FAILED candidate, or `null` (4.1
697
- * v1.17, A20). The candidate is another exchange's record, and not a deviation
698
- * on this one, only where the reader holds a record `o`, other than the
699
- * subject, for which all four hold:
475
+ * The ref of the record that accounts for a failed candidate, or `null`. That
476
+ * is a record `o`, other than the subject, where:
700
477
  *
701
- * 1. `o` is signed by the SUBJECT's own key, and is the subject's type;
478
+ * 1. `o` is signed by the subject's key and is the subject's type;
702
479
  * 2. `o` names the candidate's signer, and the candidate names the subject's
703
480
  * key back;
704
481
  * 3. `o`'s `sequence` differs from the subject's;
705
482
  * 4. the candidate grades `verified` against `o`.
706
483
  *
707
- * Condition 1 is what the audited party cannot supply: a verified pair needs
708
- * the OTHER side's signature over the digest and the number. Condition 3 is
709
- * the position guard (proof#129): a second record at the subject's own number
710
- * is a second claim about the subject's message. The first `o` in the order
711
- * the records are held is reported, so two kernels name one record. The
712
- * Python half, `_accounted_for`, carries the full reasoning.
484
+ * Condition 1 is what the audited party cannot supply: a verified pair needs the
485
+ * other side's signature. Condition 3 stops a second claim about the subject's
486
+ * own message. The first `o` in held order wins, so both kernels name one record.
713
487
  */
714
488
  function accountedFor(
715
489
  subject: HeldRecord,
@@ -718,68 +492,42 @@ function accountedFor(
718
492
  ): string | null {
719
493
  const own = normalizedSigningKey(subject.publicKey);
720
494
  const theirs = normalizedSigningKey(candidate.publicKey);
721
- // Condition 2: the candidate names the subject's key back.
495
+ // Condition 2, the candidate's half.
722
496
  if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
723
497
  return null;
724
498
  }
725
499
  const subjectSequence = section(subject.record, "event").sequence;
726
500
  for (const other of held) {
727
501
  if (other === subject) continue;
728
- // Condition 1: signed by the subject's own key, of the subject's own type.
502
+ // Condition 1.
729
503
  if (normalizedSigningKey(other.publicKey) !== own) continue;
730
504
  if (eventType(other) !== eventType(subject)) continue;
731
- // Condition 2: o names the candidate's signer.
505
+ // Condition 2, o's half.
732
506
  if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
733
507
  continue;
734
508
  }
735
- // Condition 3: another position than the subject's.
509
+ // Condition 3.
736
510
  if (sameNumber(section(other.record, "event").sequence, subjectSequence)) continue;
737
- // Condition 4: a verified pair.
511
+ // Condition 4.
738
512
  if (gradePair(other, candidate).verdict === "verified") return other.ref;
739
513
  }
740
514
  return null;
741
515
  }
742
516
 
743
517
  /**
744
- * EVERY record the reader holds gets a reading, in the order given.
745
- *
746
- * This function does not filter, and the branch that used to decide whether a
747
- * record was read at all is gone (proof#136, ratified by Phil 2026-09-22).
748
- * Scope was two clauses, an exact test for the key name
749
- * `counterparty_public_key` and an ASCII fold of `event_type`, and both
750
- * operands are written by the party being audited: one code point on each put
751
- * a record in scope by NEITHER route, so it had no reading at all and the pile
752
- * read clean. Five repairs narrowed which values reached that silent branch
753
- * and five hunts found a value outside it, so the branch is removed rather
754
- * than narrowed again.
755
- *
756
- * The distinction a reader needs survives as two findings rather than as a
757
- * reading and a silence: a record TRYING to be a hand-off and getting it wrong
758
- * reads `malformed` with `malformed_record`, and a record that is not a
759
- * hand-off record at all reads `NOT_A_HANDOFF_FINDING` with no verdict.
760
- * `isHandoffNamespace` decides which of the two, in `readHandoff`, and no
761
- * longer whether a record is read.
762
- *
763
- * THE COST, stated rather than left to be found: a reader given a mixed pile
764
- * produces a line per record, including for records that have nothing to do
765
- * with hand-offs. 4.1 says so too.
518
+ * Read every held record, in the order given. Nothing is filtered: a record
519
+ * outside the hand-off namespace reads `NOT_A_HANDOFF_FINDING`, so a mixed pile
520
+ * produces a line per record.
766
521
  */
767
522
  export function readHandoffs(held: readonly HeldRecord[]): HandoffReading[] {
768
523
  return held.map((record) => readHandoff(record, held));
769
524
  }
770
525
 
771
526
  /**
772
- * Whether this record is a conformant hand-off record of 4.1: it is read as a
773
- * hand-off record at all (`isHandoffNamespace`) and it is not `malformed`
774
- * (`recordIsMalformed`), which between them mean it is exactly a
775
- * `handoff.sent` or a `handoff.received` carrying every REQUIRED field of its
776
- * own type, under an envelope key this reading can read.
777
- *
778
- * `./declaration.ts` reads this for evidence-bundle.md 5.4: a claimed
779
- * `sent_event_id` or `received_event_id` naming a record that is not one of
780
- * these is a DEVIATION, so a record cannot be unreadable at the record layer
781
- * and usable at the pack layer at the same time (proof#136).
527
+ * Whether a record is a conformant hand-off: in the namespace, with no
528
+ * defect. `./declaration.ts` uses it for evidence-bundle.md section 5.4, so a record
529
+ * cannot be unreadable here and usable at the pack layer.
782
530
  */
783
531
  export function isConformantHandoffRecord(held: HeldRecord): boolean {
784
- return isHandoffNamespace(held) && !recordIsMalformed(held);
532
+ return isHandoffNamespace(held) && recordDefect(held) === null;
785
533
  }