@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
@@ -1,275 +1,115 @@
1
1
  /**
2
- * A file part's BYTES, and what a receiver holding them commits to.
3
- *
4
- * `envelope-spec.md` section 4.1 (v1.13, amendment A18, proof#145, ratified by
5
- * Phil 2026-09-22). The Python half is `src/sanning_proof/message_parts.py` and
6
- * the two are held together by `cross-kernel/run_parts.sh`.
7
- *
8
- * THE DEFECT THIS CLOSES. Section 4.1 said "Binary and file parts are committed
9
- * by their own content hash inside that canonical object" and stopped, which
10
- * decided half of "an altered part fails". Measured against both SDKs
11
- * (anchor#219, attack 1, in both languages): a transport that keeps a part's
12
- * DESCRIPTOR and swaps its BYTES produces `accepted`. No exception, no finding,
13
- * and nothing in either record: the descriptor's hash and the bytes' hash sit
14
- * side by side and no rule ever compared them, so both sides commit to the same
15
- * message object and the pair reads `verified`. Swapping the bytes AND the
16
- * descriptor was caught all along. This is the sixth instance of one class in
17
- * this section, "a deviation produces nothing and a reader is obliged to call
18
- * it clean", and the repair is the same shape as A13's and A14's: remove the
19
- * silent branch rather than narrow it.
20
- *
21
- * THE RULE, which is the smallest additive one that closes it:
22
- *
23
- * * a file part is `kind: "file"` plus `content_hash` (64 hex characters,
24
- * either case, the SHA-256 of the part's exact bytes) and `content_length`
25
- * (a safe integer), and the bytes are NOT in the message;
26
- * * a receiver handed a part's bytes MUST hash them, and where the digest is
27
- * not the descriptor's, or the byte count is not the descriptor's, it
28
- * commits to the message AS IT ACTUALLY HELD IT: that part's
29
- * `content_hash` and `content_length` replaced by the held bytes' own,
30
- * nothing else touched. Its `handoff.received` then commits to a different
31
- * message object from the sender's, so the formed pair FAILS on
32
- * `message_hash`, a failure section 4.1 already names, and the receiving
33
- * side writes `outcome: "rejected"`. That is the ONE reading that moves,
34
- * and it moves only for a receiver that holds the bytes;
35
- * * every other deviation is named and none is silent. Three of them are
36
- * REFUSALS of one supply, so a receiver's own decoding defect never becomes
37
- * permanent evidence against an honest sender;
38
- * * an inline part is ACCEPTED, and it is committed by the MESSAGE ITSELF
39
- * (proof#157, ratified by Phil 2026-09-22). `message_hash` is SHA-256 over
40
- * the canonical bytes of the message object, so a part carrying its content
41
- * inside the message is already committed: alter that content and the
42
- * message object changes, the hash changes, and the pair fails. None of the
43
- * swapped-bytes attack applies, because that attack works only where a
44
- * descriptor's hash and bytes travelling BESIDE the message are two
45
- * separate things with nothing comparing them. Inline is not the risky
46
- * shape, it is the safest one. Where an inline part ALSO carries a
47
- * `content_hash` or a `content_length`, the receiver checks BOTH against
48
- * the inline content and names a disagreement: one commitment governs, and
49
- * a second one present and disagreeing is itself a finding.
50
- *
51
- * WHY THE HELD MESSAGE RATHER THAN A FINDING ON THE RECEIVING SIDE. A finding
52
- * only the receiving side's own reader sees is the shape of every laundering
53
- * path this section has closed: the party being audited publishes the pack, so
54
- * a deviation that lives outside the committed record is a deviation that party
55
- * chooses whether anyone reads. `message_hash` is the one value both sides
56
- * publish and a reader already grades.
57
- *
58
- * WHAT DOES NOT MOVE. A receiver that never holds a part's bytes checks
59
- * nothing, substitutes nothing and reads exactly as it did before this
60
- * amendment: every file part is `PART_UNCHECKED`, and section 4.1 forbids
61
- * treating an unchecked part as a checked one. No envelope field moves, no
62
- * `spec_version` moves, no verdict word and no record-layer finding is coined.
63
- *
64
- * A REFUSAL IS SCOPED TO ITS PART, AND THE RECORD IS STILL WRITTEN (proof#156
65
- * gate). The first cut of this module refused GLOBALLY: one refusal anywhere
66
- * set `heldMessage` to null and dropped every substitution. That made the
67
- * attack A18 exists to close launderable (a genuine swap on part 0 plus any
68
- * refusal-triggering supply on part 1 wrote nothing at all), let a SENDER deny
69
- * the receiver a record with an upper-case `content_hash` or a `"bytes": null`
70
- * on an otherwise conformant descriptor, and contradicted section 4.1's own
71
- * paragraph saying an uncheckable part is NAMED in the receiver's reading. So
72
- * a problem with one part names that part, leaves the rest of the reading
73
- * intact, and `heldMessage` is never null. A message-level problem is reserved
74
- * for a message-level condition, and even then it produces a named result:
75
- * `PARTS_NOT_AN_ARRAY`, and the entry-scoped `BYTES_FOR_UNKNOWN_PART`.
76
- *
77
- * WHAT THE CALLER STILL OWES, AND THE ONE FUNCTION THAT STATES IT.
78
- * `substituted` decides whether a hand-off is `rejected`; it does NOT decide
79
- * whether one is clean. A part that was refused reads `PART_UNCHECKED`,
80
- * section 4.1 forbids a reader treating an unchecked part as a checked one,
81
- * and a surface reporting a hand-off states how many parts were checked. A
82
- * caller that wrote `accepted` on "nothing was substituted" alone would be
83
- * calling a part it never established clean, which is the class this whole
84
- * section keeps repairing.
85
- *
86
- * The sharp version, measured (proof#156 second gate, F5): deliver a tampered
87
- * copy of a part ALONE and it is `part_mismatched`, substituted, and the pair
88
- * fails on `message_hash`. Deliver the tampered copy AND the true one and it
89
- * is `conflicting_part_bytes`, `part_unchecked`, nothing substituted, a
90
- * `heldMessage` byte-identical to the sender's, and the pair VERIFIES. Adding
91
- * data suppresses detection, and the refusal that names it lives only in
92
- * `refusals`, which is a finding only the receiving side's own reader sees:
93
- * the exact shape the paragraph above refuses to create.
94
- * `PART_ENCODING_MISMATCH` has the same property.
95
- *
96
- * Until that round the obligation was PROSE IN THIS COMMENT with no mechanism,
97
- * which is a rule a caller has to read to obey. `uncheckedHeldParts` is the
98
- * mechanism's additive half: it names the parts the receiver HELD bytes for
99
- * and did not check, so a caller writes `accepted` only where `substituted` is
100
- * empty AND that list is empty, instead of re-deriving it from the refusals.
101
- * It cannot make the mistake IMPOSSIBLE, because this module reads and does
102
- * not write the record. What the record layer does with the list was
103
- * proof#162, and Phil ratified it on 2026-09-22: `outcome: "accepted"` keeps
104
- * meaning what it means today, and the count of held-but-unchecked parts
105
- * rides in an ADDITIVE `context` key, REQUIRED when it is not zero. Its force
106
- * is against SDKs through conformance vectors rather than against records
107
- * through readers, since only the receiver knows what bytes it held, so THIS
108
- * LIST IS WHAT EVERY SDK BUILT ON THE RULE WILL COUNT. A part missing from it
109
- * is a part no record will ever mention (anchor#191 is the first caller).
110
- *
111
- * WHAT THIS MODULE DOES NOT DO. It does not canonicalize and it does not hash
112
- * the message. It returns the message a receiver commits to, and the caller
113
- * hashes that with the rule section 4.1 already states, so this module has no
114
- * failure mode of its own for a message too deep or not serializable and there
115
- * is no branch here where one kernel throws and the other returns.
2
+ * File parts of a hand-off message, and the message a receiver holding their
3
+ * bytes commits to (envelope-spec.md §4.1). Conformance:
4
+ * `test-vectors/handoff/parts/`.
5
+ *
6
+ * A file part is `kind: "file"` plus `content_hash` (SHA-256 of the exact
7
+ * bytes, 64 hex in either case) and `content_length` (a safe integer), with
8
+ * the bytes outside the message. A receiver handed a part's bytes must hash
9
+ * them. Where the digest or the length differs from the descriptor, the
10
+ * receiver commits to the message as it actually held it: that part's
11
+ * `content_hash` and `content_length` replaced by the held bytes' own. Its
12
+ * `message_hash` then differs from the sender's and the pair fails, so a
13
+ * transport that keeps the descriptor and swaps the bytes is caught in the
14
+ * committed record, not in a finding only the receiver's reader sees.
15
+ *
16
+ * An inline part (content inside the message) is committed by `message_hash`
17
+ * itself. If it also carries a `content_hash` or `content_length`, both are
18
+ * checked against the inline content and a disagreement is named.
19
+ *
20
+ * A refusal is scoped to its part and never stops the record being written:
21
+ * `heldMessage` is never null, so neither a sender nor a bad supply can deny
22
+ * the receiver a record or launder a swap on another part.
23
+ *
24
+ * `substituted` decides `rejected`; it does not make a hand-off clean. A caller
25
+ * writes `accepted` only when `substituted` and `uncheckedHeldParts` are both
26
+ * empty. Supplying a tampered copy alongside the true one reads
27
+ * `conflicting_part_bytes` with nothing substituted, so held-but-unchecked parts
28
+ * must be counted in the record.
29
+ *
30
+ * This module does not canonicalize or hash the message; the caller hashes
31
+ * `heldMessage` as §4.1 states.
116
32
  */
117
33
  import { sha256Hex } from "./crypto.js";
118
34
 
119
- /**
120
- * The result of a part whose supplied bytes hash to its `content_hash` AND
121
- * count to its `content_length`.
122
- */
35
+ /** The supplied bytes match both `content_hash` and `content_length`. */
123
36
  export const PART_MATCHED = "part_matched";
124
37
 
125
38
  /**
126
- * The result of a part whose supplied bytes miss either of those, with no
127
- * respelling of them that explains the digest. The held-message substitution
128
- * applies to this part, and it replaces BOTH values with the held bytes' own.
39
+ * The supplied bytes miss either value, and no respelling explains the digest.
40
+ * The held message replaces both values with the held bytes' own.
129
41
  */
130
42
  export const PART_MISMATCHED = "part_mismatched";
131
43
 
132
- /**
133
- * A file part whose bytes were not supplied, or whose supply was REFUSED. It is
134
- * not checked and it is not clean: section 4.1 forbids a reader treating it as
135
- * checked.
136
- */
44
+ /** Bytes not supplied, or the supply was refused. Not checked, and not clean. */
137
45
  export const PART_UNCHECKED = "part_unchecked";
138
46
 
139
47
  /**
140
- * A `kind: "file"` part carrying its content inside the message, and ACCEPTED
141
- * (proof#157, ratified by Phil 2026-09-22). It is committed by the MESSAGE
142
- * ITSELF: `message_hash` is SHA-256 over the canonical bytes of the message
143
- * object, so altering that content changes the message, changes the hash, and
144
- * fails the pair. Nothing is hashed against it here and nothing is substituted,
145
- * because there is nothing travelling beside the message for the message to
146
- * disagree with. That is why the swapped-bytes attack cannot reach this shape.
48
+ * A file part with its content inside the message, committed by
49
+ * `message_hash` itself. Nothing is hashed or substituted.
147
50
  */
148
51
  export const PART_INLINE_BYTES = "part_inline_bytes";
149
52
 
150
53
  /**
151
- * An inline part whose `content_hash`, `content_length` or both DISAGREE with
152
- * its inline content: no single reading of that content satisfies every claim
153
- * the part writes beside it. The message's own commitment still governs and
154
- * nothing is substituted; this names the SECOND claim that disagrees with it,
155
- * which is a finding in its own right (proof#157).
156
- *
157
- * ONE name for both claims, exactly as `PART_MISMATCHED` is one name for both
158
- * on a descriptor part. It was `part_inline_hash_mismatch` for one round of
159
- * this unmerged PR, when only the hash was compared; a name that says "hash"
160
- * about a result that also covers a length asserts more than its body does, so
161
- * the name was re-cut with the rule (proof#156 second gate, F4).
162
- *
163
- * The check is deliberately permissive ACROSS readings: the raw UTF-8 of the
164
- * value, its base64 decoding and its hex decoding are all tried, so this fires
165
- * only where no reading agrees at all. It is not permissive BETWEEN claims: one
166
- * reading has to satisfy all of them, because a hash that agrees under the
167
- * base64 decoding beside a length that agrees under the raw text describes no
168
- * blob that exists.
54
+ * An inline part whose `content_hash` or `content_length` disagrees with its
55
+ * inline content. The raw UTF-8, base64 and hex readings are all tried, but one
56
+ * reading must satisfy every claim present. Nothing is substituted.
169
57
  */
170
58
  export const PART_INLINE_MISMATCHED = "part_inline_mismatched";
171
59
 
172
60
  /**
173
61
  * `kind: "file"` without a conformant `content_hash` and `content_length`. A
174
- * sender's defect rather than a receiver's, so it is named rather than refused:
175
- * the receiver did nothing wrong by holding it.
62
+ * sender's defect, so it is named rather than refused.
176
63
  */
177
64
  export const PART_MALFORMED_DESCRIPTOR = "part_malformed_descriptor";
178
65
 
179
- /**
180
- * Any other part. It is committed as ordinary JSON content and this rule checks
181
- * nothing about it.
182
- */
66
+ /** Any other part: ordinary JSON content, not checked by this rule. */
183
67
  export const PART_NOT_A_FILE = "part_not_a_file";
184
68
 
185
69
  /**
186
- * REFUSAL of ONE supplied entry. Bytes were supplied for no part of the
187
- * message, or for a part this rule cannot check. The caller and the message
188
- * disagree about what was sent, and hashing bytes against a part that is not
189
- * there establishes nothing. Where the entry names a part that EXISTS but is
190
- * not checkable, the refusal carries that part's index and the part keeps the
191
- * result it classified to; where it names no part at all, the refusal carries
192
- * the entry position and no part. Neither stops the record being written.
70
+ * Refusal of one supplied entry: it names no part, or a part this rule cannot
71
+ * check. Carries that part's index when the part exists, else only the entry
72
+ * position.
193
73
  */
194
74
  export const BYTES_FOR_UNKNOWN_PART = "bytes_for_unknown_part";
195
75
 
196
- /**
197
- * REFUSAL. Two DIFFERENT byte strings were supplied for one part. A receiver
198
- * holding two answers has not established which one it held.
199
- */
76
+ /** Refusal: two different byte strings were supplied for one part. */
200
77
  export const CONFLICTING_PART_BYTES = "conflicting_part_bytes";
201
78
 
202
79
  /**
203
- * REFUSAL of ONE supplied entry. The supplied bytes miss the descriptor, but a
204
- * base64 or hex RESPELLING of them in EITHER direction hashes to it: either the
205
- * bytes DECODE to the part (the receiver's stack never decoded what it was
206
- * handed) or they ENCODE to it (the receiver's stack decoded bytes the sender
207
- * committed in their encoded spelling, which is what a single-line DER
208
- * attachment, a PEM body and a JWT all are). That is a coding defect in the
209
- * receiver, not a tamper by the sender: a proto3 JSON transcoder re-encodes
210
- * base64 URL-safe and unpadded while behaving honestly, and under the silence
211
- * this amendment removes it produced a permanent tamper record against a sender
212
- * who did nothing. Recording evidence out of a receiver's own bug is the one
213
- * outcome worse than recording nothing, so BOTH directions are guarded
214
- * (proof#156 gate): guarding only the decoding direction left the other half of
215
- * one honest stack's behavior reading as a tamper.
80
+ * Refusal of one supplied entry: the bytes miss the descriptor, but a base64
81
+ * or hex respelling in either direction hashes to it. That is a receiver's
82
+ * coding defect (an undecoded supply, or a decoded PEM, DER or JWT the sender
83
+ * committed as text), not a sender's tamper, so it must not produce a tamper
84
+ * record.
216
85
  */
217
86
  export const PART_ENCODING_MISMATCH = "part_encoding_mismatch";
218
87
 
219
88
  /**
220
- * A MESSAGE-LEVEL result. The message carries a `parts` key whose value is
221
- * present and is not an array, which is how proto3 JSON encodes a map and so is
222
- * reachable from the same transport family the motivating example comes from.
223
- * Section 4.1 asks for one result per part; a message shaped like this has
224
- * part-shaped content and no parts, and before proof#156's gate it produced
225
- * zero readings and an accepted record. It is named rather than refused: the
226
- * receiver did nothing wrong by holding it, and nothing in it is checked. A
227
- * `parts` written as `null` is an ABSENT `parts` and is not this, on A14's
228
- * ruling that an absent value and one written as `null` name one thing.
89
+ * Message-level: `parts` is present, not null and not an array (proto3 JSON
90
+ * encodes a map this way). Named, not refused. A `parts` of `null` is absent.
229
91
  */
230
92
  export const PARTS_NOT_AN_ARRAY = "parts_not_an_array";
231
93
 
232
- /**
233
- * NOTE, not a refusal. One part supplied more than once with IDENTICAL bytes is
234
- * one supply stated twice, and the part is checked normally. Different bytes for
235
- * one part are `CONFLICTING_PART_BYTES`.
236
- */
94
+ /** Note, not a refusal: one part supplied more than once with identical bytes. */
237
95
  export const PART_SUPPLIED_TWICE = "part_supplied_twice";
238
96
 
239
- /**
240
- * The order refusals are reported in, fixed so two kernels report one list
241
- * rather than two orderings of it.
242
- */
97
+ /** The fixed order refusals are reported in. */
243
98
  export const REFUSAL_ORDER = [
244
99
  BYTES_FOR_UNKNOWN_PART,
245
100
  CONFLICTING_PART_BYTES,
246
101
  PART_ENCODING_MISMATCH,
247
102
  ] as const;
248
103
 
249
- /**
250
- * The order message-level results are reported in, for the same reason as
251
- * `REFUSAL_ORDER`.
252
- */
104
+ /** The fixed order message-level results are reported in. */
253
105
  export const MESSAGE_RESULT_ORDER = [PARTS_NOT_AN_ARRAY] as const;
254
106
 
255
107
  /**
256
- * The results that put a HELD part into `uncheckedHeldParts`, written out so a
257
- * third kernel reads the rule rather than inferring it from one kernel's
258
- * filter (proof#156 third gate, C1).
259
- *
260
- * The line is whether the MESSAGE ITSELF commits the part's content. It does
261
- * not for these two: `PART_UNCHECKED` is a supply the rule refused to grade,
262
- * and `PART_MALFORMED_DESCRIPTOR` is a part whose content lives BESIDE the
263
- * message under a claim no receiver can use, so in both the receiver holds
264
- * bytes that nothing in the committed record covers.
265
- *
266
- * The other three results a supply can be refused against are excluded, and
267
- * excluded for a reason rather than by omission. `PART_INLINE_BYTES`,
268
- * `PART_INLINE_MISMATCHED` and `PART_NOT_A_FILE` all carry their content
269
- * INSIDE the message, which `message_hash` already commits on both sides:
270
- * bytes supplied beside one of those grade nothing and hide nothing, because
271
- * a reader of either pack reads the content itself. Counting them would make
272
- * the caller's obligation fire on parts no attack can reach.
108
+ * Results that put a held part into `uncheckedHeldParts`: those whose content
109
+ * the message itself does not commit. Inline parts and non-file parts with no
110
+ * URL are excluded because `message_hash` already commits their content; a URL
111
+ * part (`isUrlPart`) counts, because the message commits the URL and not the
112
+ * file behind it.
273
113
  */
274
114
  const HELD_UNCHECKED_RESULTS: readonly string[] = [
275
115
  PART_UNCHECKED,
@@ -277,33 +117,10 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
277
117
  ];
278
118
 
279
119
  /**
280
- * The keys that make a part carry its content INLINE, in the order they are
281
- * read. Settled at `bytes` alone (envelope-spec §4.1, A31, proof#163): that
282
- * is A2A's spelling, the one the wedge actually emits, and it is the only
283
- * inline spelling either reference kernel has read from a real producer.
284
- * `content` and `data` were added on inference rather than on an observed
285
- * wire shape, and sanning-io/anchor's `filePart()` and `file_part()` SDK
286
- * helpers, the only producer this plane ships, confirm the inference was
287
- * wrong: both emit a descriptor part, `kind: "file"` plus `content_hash` and
288
- * `content_length`, and neither ever writes a `content` or `data` wire key
289
- * for inline bytes. None is A2A 1.0.0's either: its `Part` has no `kind` and
290
- * carries a file as `raw` or `url`, so a literal A2A part reads
291
- * `part_not_a_file` (envelope-spec §4.1, A19). The one key present with a
292
- * value that is not `null` is the inline content.
293
- *
294
- * A key present with `null` is an ABSENT inline value, on A14's ruling that an
295
- * absent value and one written as `null` name one thing. That matters: a
296
- * proto3 JSON encoder can write an unset field as `"bytes": null`, and reading
297
- * that as inline made a conformant DESCRIPTOR part uncheckable
298
- * (proof#156 gate). Such a part falls through to the descriptor rule, which is
299
- * what it is.
120
+ * The key that makes a part carry its content inline (envelope-spec §4.1).
121
+ * `bytes` is A2A's spelling. A key present with `null` is absent, so a proto3
122
+ * `"bytes": null` on a descriptor part still reads as a descriptor.
300
123
  */
301
- // Before A31 this list also carried `content` and `data`, and a conformant
302
- // DESCRIPTOR part that happened to carry either key meaning something else (a
303
- // filename, a caption) was silently reclassified as inline and never checked
304
- // against supplied bytes: the swapped-bytes check A18 exists to run
305
- // (proof#145) never ran on it. proof#163 is resolved by narrowing to `bytes`
306
- // alone, closing that gap.
307
124
  export const INLINE_BYTES_KEYS = ["bytes"] as const;
308
125
 
309
126
  const HEX64 = /^[0-9a-fA-F]{64}$/;
@@ -312,9 +129,8 @@ const LOWER_HEX = "0123456789abcdef";
312
129
  const UPPER_HEX = "0123456789ABCDEF";
313
130
 
314
131
  /**
315
- * The largest integer a double carries exactly, the same bound section 4.1 puts
316
- * on `event.sequence`: a `content_length` outside it is a value the two kernels
317
- * would read as two numbers.
132
+ * The largest exactly representable integer, the bound §4.1 puts on
133
+ * `event.sequence`. A larger `content_length` is malformed.
318
134
  */
319
135
  export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
320
136
 
@@ -322,12 +138,8 @@ const BASE64_STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz012
322
138
  const BASE64_URLSAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
323
139
 
324
140
  /**
325
- * One byte string a receiver holds, and the part it holds it for.
326
- *
327
- * `part` is an INDEX into the message's `parts` array. It is an index rather
328
- * than a content hash on purpose: keying the supply by the value the check is
329
- * about to grade would mean a receiver could only supply bytes it had already
330
- * agreed with, which is the whole defect stated backwards.
141
+ * A byte string the receiver holds, keyed by the index of its part. Keyed by
142
+ * index, not by hash, so a receiver can supply bytes that disagree.
331
143
  */
332
144
  export interface SuppliedPart {
333
145
  part: unknown;
@@ -341,13 +153,9 @@ export interface PartReading {
341
153
  }
342
154
 
343
155
  /**
344
- * A caller error. Nothing is written, nothing is signed, no record made.
345
- *
346
- * `part` is the part index where the refusal is about a part of the message,
347
- * and `entry` is the POSITION of the offending supplied entry in the sequence
348
- * the caller passed. The position is recorded rather than the value the caller
349
- * named, so a caller that names a part with a float or a string cannot make two
350
- * kernels serialize one reading two ways.
156
+ * A caller error. `part` is the part index where there is one; `entry` is the
157
+ * position of the supplied entry, recorded rather than the caller's value so
158
+ * every implementation serializes it the same way.
351
159
  */
352
160
  export interface Refusal {
353
161
  name: string;
@@ -356,29 +164,18 @@ export interface Refusal {
356
164
  }
357
165
 
358
166
  /**
359
- * Something worth reporting that refuses nothing and checks nothing out. Same
360
- * shape as a `Refusal` and carried in its own list, so no caller can read a
361
- * note as a refusal by looking only at a name.
167
+ * Reported, but refuses nothing. Kept in its own list so a note is never read
168
+ * as a refusal by name alone.
362
169
  */
363
170
  export type Note = Refusal;
364
171
 
365
172
  /**
366
- * The message a receiver commits to, and everything it found on the way.
173
+ * What a receiver commits to, and what it found.
367
174
  *
368
- * `heldMessage` is ALWAYS the message the receiver commits to and is never
369
- * null: a refusal is scoped to the supply it is about and never denies the
370
- * receiver a record (proof#156 gate). The caller commits `event.message_hash`
371
- * as SHA-256 over its JCS (RFC 8785) canonical bytes, which is the rule section
372
- * 4.1 already states and the reason this module does not hash anything itself.
373
- *
374
- * `messageResults` carries the message-level named results, in
375
- * `MESSAGE_RESULT_ORDER`. It is empty for every message whose shape the rule
376
- * reads.
377
- *
378
- * `substituted` carries the indexes of the parts whose descriptor the receiver
379
- * replaced with the held bytes' own hash and length. It is empty for an honest
380
- * hand-off, and where it is not empty the receiving side writes
381
- * `outcome: "rejected"`.
175
+ * `heldMessage` is never null; the caller commits `event.message_hash` as
176
+ * SHA-256 over its JCS bytes. `messageResults` is in `MESSAGE_RESULT_ORDER`.
177
+ * `substituted` lists the parts replaced by held bytes; non-empty means the
178
+ * receiver writes `outcome: "rejected"`.
382
179
  */
383
180
  export interface HeldMessage {
384
181
  parts: PartReading[];
@@ -398,20 +195,15 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
398
195
  }
399
196
 
400
197
  /**
401
- * 64 hex characters, in EITHER case. Phil ratified proof#135 as "pin the hex
402
- * charset only": a case-only difference names the same value, and refusing
403
- * upper case here would contradict a ratified decision and punish a producer
404
- * who did nothing wrong. Every comparison against a digest lowercases both
405
- * sides, which is what the other kernels already do.
198
+ * 64 hex characters in either case: the hex rule pins the charset, not the
199
+ * case. Every digest comparison lowercases both sides.
406
200
  */
407
201
  function isHex64(value: unknown): boolean {
408
202
  return typeof value === "string" && HEX64.test(value);
409
203
  }
410
204
 
411
205
  /**
412
- * A byte count: an integer at least zero and inside the safe range. `true` is
413
- * not a number. Zero is a legal length: the empty part is a real part, and a
414
- * rule that read zero as "no length" would leave it unchecked in silence.
206
+ * A non-negative safe integer. Zero is legal: the empty part is a real part.
415
207
  */
416
208
  function isSafeLength(value: unknown): boolean {
417
209
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -421,17 +213,10 @@ function isSafeIndex(value: unknown): boolean {
421
213
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
422
214
  }
423
215
 
424
- /**
425
- * Distinguishes "no inline content" from an inline content of `null`, which
426
- * A14's ruling says is the same thing but which a plain `null` could not be
427
- * told apart from a legitimate value.
428
- */
429
216
  const ABSENT = Symbol("absent");
430
217
 
431
218
  /**
432
- * The part's inline content, or `ABSENT` where it carries none: the FIRST of
433
- * `INLINE_BYTES_KEYS` present with a value that is not `null`, so a part
434
- * spelling it twice reads one way in both kernels.
219
+ * The first of `INLINE_BYTES_KEYS` present with a non-null value, or `ABSENT`.
435
220
  */
436
221
  function inlineValue(part: Record<string, unknown>): unknown | typeof ABSENT {
437
222
  for (const key of INLINE_BYTES_KEYS) {
@@ -443,13 +228,8 @@ function inlineValue(part: Record<string, unknown>): unknown | typeof ABSENT {
443
228
  }
444
229
 
445
230
  /**
446
- * Every byte string a reader can form from an inline value: the raw UTF-8 of
447
- * the string, its base64 decoding and its hex decoding. Deliberately
448
- * permissive: this exists only to decide whether a `content_hash` sitting
449
- * BESIDE inline content agrees with it, and the message's own commitment
450
- * governs either way, so the finding fires only where no reading agrees at all.
451
- * A value that is not a string carries no readings, so a `content_hash` beside
452
- * one has nothing to agree with.
231
+ * The raw UTF-8, base64 and hex readings of an inline string value. A
232
+ * non-string has no readings.
453
233
  */
454
234
  function inlineReadings(value: unknown): Uint8Array[] {
455
235
  if (typeof value !== "string") return [];
@@ -464,19 +244,13 @@ function inlineReadings(value: unknown): Uint8Array[] {
464
244
  }
465
245
 
466
246
  /**
467
- * The SHAPE a part reads with before any supplied bytes are considered, or
468
- * `null` where the part is one this rule checks against them.
469
- *
470
- * Shape only, and synchronous on purpose: the inline hash agreement needs
471
- * SHA-256, which is async here and not in Python, so refining
472
- * `PART_INLINE_BYTES` to `PART_INLINE_MISMATCHED` happens in the reading
473
- * loop instead. Keeping it out of here is what lets `isFilePart` mean the same
474
- * thing, and have the same signature, in both kernels.
247
+ * The result a part reads before supplied bytes are considered, or `null` for
248
+ * a checkable descriptor part. Synchronous so `isFilePart` stays synchronous;
249
+ * the inline hash check, which needs async SHA-256, runs in the reading loop.
475
250
  */
476
251
  function classify(part: unknown): string | null {
477
252
  if (!isPlainObject(part)) return PART_NOT_A_FILE;
478
253
  if (part.kind !== "file") return PART_NOT_A_FILE;
479
- // ACCEPTED, and committed by the message itself (proof#157).
480
254
  if (inlineValue(part) !== ABSENT) return PART_INLINE_BYTES;
481
255
  if (!isHex64(part.content_hash)) return PART_MALFORMED_DESCRIPTOR;
482
256
  if (!isSafeLength(part.content_length)) return PART_MALFORMED_DESCRIPTOR;
@@ -484,28 +258,10 @@ function classify(part: unknown): string | null {
484
258
  }
485
259
 
486
260
  /**
487
- * Whether the claims written BESIDE an inline part's content agree with it.
488
- *
489
- * Two claims can sit beside inline content, `content_hash` and
490
- * `content_length`, and BOTH are read (proof#156 second gate, F4). The length
491
- * was skipped for one round of this unmerged PR, on the argument that a reader
492
- * holding the message can see a contradiction between the content and the
493
- * claim from the message alone. That argument is true and it is also true of
494
- * `content_hash`, which is compared: accepting it deletes the neighboring
495
- * check. So the rule is the one section 4.1 already applies to a descriptor
496
- * part, where a hash that misses and a count that misses reach one result: two
497
- * claims about one blob, and reading one of them lets the other be false in
498
- * silence.
499
- *
500
- * A claim that is ABSENT, or is not the shape the table names, is not a claim:
501
- * it is not compared and it cannot disagree. A part writing neither agrees by
502
- * default, because the message's own commitment governs and this only ever
503
- * names a SECOND claim that contradicts it.
504
- *
505
- * ONE reading has to satisfy EVERY claim present, rather than each claim
506
- * finding its own. Grading them separately would let a `content_hash` that
507
- * agrees under the base64 decoding sit beside a `content_length` that agrees
508
- * under the raw text, a pair no blob in the world satisfies, and call it clean.
261
+ * Whether the `content_hash` and `content_length` beside an inline part agree
262
+ * with its content. An absent or malformed claim is not compared. One reading
263
+ * must satisfy every claim present: a hash agreeing under base64 beside a
264
+ * length agreeing under raw text describes no real blob.
509
265
  */
510
266
  async function inlineClaimsAgree(part: Record<string, unknown>): Promise<boolean> {
511
267
  const wanted = isHex64(part.content_hash)
@@ -524,23 +280,16 @@ async function inlineClaimsAgree(part: Record<string, unknown>): Promise<boolean
524
280
  }
525
281
 
526
282
  /**
527
- * Whether this part is one this rule CHECKS against bytes travelling BESIDE the
528
- * message: a conformant descriptor part. Exactly the three conditions section
529
- * 4.1 states, in the order that keeps inline content out. An INLINE part is
530
- * accepted and committed by the message itself (proof#157), so there is nothing
531
- * here for supplied bytes to be checked against and this answers `false`.
283
+ * Whether bytes beside the message are checked against this part: true only
284
+ * for a conformant descriptor part, never for an inline one.
532
285
  */
533
286
  export function isFilePart(part: unknown): boolean {
534
287
  return classify(part) === null;
535
288
  }
536
289
 
537
290
  /**
538
- * The parts of a message, or none at all.
539
- *
540
- * A message that carries no `parts` array carries no file parts, and that is
541
- * not a deviation: it is the common case, a JSON-only message. Every supplied
542
- * byte string then has no part to belong to and is refused, which is the
543
- * reading section 4.1 asks for and not an exception.
291
+ * The message's `parts`, or none. A message without parts is the common
292
+ * JSON-only case; every supplied entry is then refused.
544
293
  */
545
294
  function messageParts(message: unknown): unknown[] {
546
295
  if (!isPlainObject(message)) return [];
@@ -548,14 +297,7 @@ function messageParts(message: unknown): unknown[] {
548
297
  return Array.isArray(parts) ? [...parts] : [];
549
298
  }
550
299
 
551
- /**
552
- * The message-level named results, before any part is read.
553
- *
554
- * One condition reaches this today: a `parts` that is present, is not null and
555
- * is not an array. It gets a name rather than the silence it had (proof#156
556
- * gate), because a message with part-shaped content and no parts is exactly the
557
- * shape section 4.1's "one result per part" leaves nothing to say about.
558
- */
300
+ /** Message-level results, before any part is read. */
559
301
  function messageResults(message: unknown): string[] {
560
302
  if (!isPlainObject(message)) return [];
561
303
  if (!hasOwn(message, "parts")) return [];
@@ -579,28 +321,14 @@ function asAscii(data: Uint8Array): string {
579
321
  }
580
322
 
581
323
  /**
582
- * Strict ASCII hex. Written out rather than delegated, because Python's
583
- * `bytes.fromhex` skips ASCII whitespace and a charset test does not: a decoder
584
- * the two kernels disagree about would put a record on one side of
585
- * `part_encoding_mismatch` in one language and the other side of it in the
586
- * other.
587
- *
588
- * EVERY GUARD HERE AND IN `base64Decode` IS PINNED BY A CORPUS VECTOR CARRYING
589
- * A HAND-WRITTEN EXPECTATION (proof#156 second gate, F3), named in the comment
590
- * beside it. Until that round, six of the seven produced no signal when
591
- * deleted: the whole ladder and the whole leg stayed green, so a third kernel
592
- * that delegated to `atob` or `base64.b64decode` reproduced the entire corpus
593
- * byte for byte, was conformant under `architecture.md` K3, and still put a
594
- * record on the other side of `part_encoding_mismatch`. An agreement case
595
- * cannot close that: the leg grades two kernels against each other, so a guard
596
- * deleted from both is invisible to it. Only a hand-written `expected` block
597
- * states which side of the boundary an input falls on.
324
+ * Strict ASCII hex, written out because library decoders differ (Python's
325
+ * `bytes.fromhex` skips whitespace), and a difference would move a record
326
+ * across `part_encoding_mismatch`. Each guard here and in `base64Decode` is
327
+ * pinned by a hand-written vector, named beside it, so deleting it goes red.
598
328
  */
599
329
  function hexDecode(data: Uint8Array): Uint8Array | null {
600
- // An odd number of characters is not hex. Deleting this line lets `"ab0"`
601
- // read as `0xab 0x00`, so a receiver handed a truncated hex string writes
602
- // `part_encoding_mismatch` where it owes `part_mismatched` and a
603
- // substitution: `parts-decoder-hex-odd-length-01`.
330
+ // Odd length is not hex; otherwise "ab0" reads as 0xab 0x00
331
+ // (`parts-decoder-hex-odd-length-01`).
604
332
  if (data.length === 0 || data.length % 2 !== 0) return null;
605
333
  const text = asAscii(data);
606
334
  if (!HEX_CHARSET.test(text)) return null;
@@ -612,33 +340,13 @@ function hexDecode(data: Uint8Array): Uint8Array | null {
612
340
  }
613
341
 
614
342
  /**
615
- * Strict base64, standard OR URL-safe, padded or not, canonical only.
616
- *
617
- * Written out for the same reason as `hexDecode`: Python's `base64.b64decode`
618
- * and JavaScript's `atob` disagree about padding, about the URL-safe alphabet
619
- * and about non-zero trailing bits, and each of those disagreements decides
620
- * whether a receiver refuses its own decoding defect or records a tamper
621
- * against an honest sender. The two alphabets are not mixed: a string carrying
622
- * one character from each is neither spelling and decodes to nothing.
623
- *
624
- * TWO GUARDS THAT STOOD HERE WERE DELETED IN THE PROOF#156 SECOND GATE'S
625
- * ROUND, because they could not change a decode for any input and so read as
626
- * protection that was not there:
627
- *
628
- * * `pad > 0 && text.length % 4 !== 0` is the SAME PREDICATE as the
629
- * `(body.length + pad) % 4` test below it. The strip loop above makes
630
- * `text` exactly `body + "=".repeat(pad)`, so the two lengths are equal by
631
- * construction and the two conditions are one condition written twice.
632
- * Each masked the other, which is why deleting either alone changed
633
- * nothing;
634
- * * `body.includes("=")` is subsumed by the alphabet test three lines below
635
- * it: `=` is in neither alphabet, so a body carrying one is refused there.
343
+ * Strict base64, standard or URL-safe (never mixed), padded or not, canonical
344
+ * only. Written out because `atob` and Python's `base64.b64decode` disagree on
345
+ * padding, the URL-safe alphabet and non-zero trailing bits.
636
346
  *
637
- * Checked exhaustively over every byte string of length 0 to 2 across all 256
638
- * byte values, exhaustively over lengths 3 to 6 on the 16-character alphabet
639
- * that reaches these branches, and over 500,000 random byte strings: zero
640
- * decodes move.
641
- * The remaining guards each carry the vector that pins them.
347
+ * There is no separate `pad > 0 && text.length % 4 !== 0` or
348
+ * `body.includes("=")` guard: the first is the alignment test below, and `=`
349
+ * fails the alphabet test.
642
350
  */
643
351
  function base64Decode(data: Uint8Array): Uint8Array | null {
644
352
  if (data.length === 0) return null;
@@ -649,32 +357,19 @@ function base64Decode(data: Uint8Array): Uint8Array | null {
649
357
  body = body.slice(0, -1);
650
358
  pad += 1;
651
359
  }
652
- // At most two padding characters. Deleting this line lets `"===="` and any
653
- // aligned body followed by four `=` decode as though the padding were not
654
- // there: `parts-decoder-base64-four-pad-chars-01`.
360
+ // At most two padding characters (`parts-decoder-base64-four-pad-chars-01`).
655
361
  if (pad > 2) return null;
656
362
 
657
363
  const standard = [...body].every((c) => BASE64_STANDARD.includes(c));
658
364
  const urlsafe = [...body].every((c) => BASE64_URLSAFE.includes(c));
659
- // One alphabet or the other, never a merge of the two. `"-/AA"` is neither
660
- // spelling, and deleting this line does not merge the alphabets: it picks
661
- // the URL-safe one and asks it for `/`, where `indexOf` answers -1 and
662
- // garbles the arithmetic into `f7 f0 00`. TWO vectors pin that, one per
663
- // shape of third kernel: `parts-decoder-base64-mixed-alphabet-01` commits
664
- // the bytes a kernel that MERGES the alphabets reads, and
665
- // `parts-decoder-base64-mixed-alphabet-02` commits the bytes this kernel's
666
- // own -1 produces, which is the one that goes red HERE (proof#156 third
667
- // gate, C2: deleting this line left all 2224 TypeScript tests green).
365
+ // One alphabet or the other, never both (`parts-decoder-base64-mixed-alphabet-01`
366
+ // and `-02`).
668
367
  if (!standard && !urlsafe) return null;
669
368
  const alphabet = standard ? BASE64_STANDARD : BASE64_URLSAFE;
670
- // A lone trailing character carries six bits and no encoder emits one.
671
- // Deleting this lets `"A"` read as the EMPTY blob, so any of the four
672
- // characters whose low bits are zero could claim to be it:
673
- // `parts-decoder-base64-single-character-01`.
369
+ // A lone trailing character carries six bits; otherwise "A" reads as the
370
+ // empty blob (`parts-decoder-base64-single-character-01`).
674
371
  if (body.length % 4 === 1) return null;
675
- // Padding that does not align the string. Deleting this lets a 36-character
676
- // body followed by one `=` decode as though the `=` were not there:
677
- // `parts-decoder-base64-padding-misaligned-01`.
372
+ // Padding must align the string (`parts-decoder-base64-padding-misaligned-01`).
678
373
  if (pad > 0 && (body.length + pad) % 4 !== 0) return null;
679
374
 
680
375
  let bits = 0;
@@ -689,23 +384,15 @@ function base64Decode(data: Uint8Array): Uint8Array | null {
689
384
  bits = bits % 2 ** width;
690
385
  }
691
386
  }
692
- // Canonical only: the unused low bits of the last character are zero in every
693
- // encoder's output, and accepting them non-zero would let two spellings of one
694
- // blob decode to one value. Both `atob` and `base64.b64decode` accept them,
695
- // which is the sharpest reason this decoder is written out: deleting this
696
- // line lets `"QR=="` read as `"A"`, so a receiver handed one of the sixteen
697
- // non-canonical spellings of a one-byte part writes `part_encoding_mismatch`
698
- // where it owes `part_mismatched` and a substitution:
699
- // `parts-decoder-non-canonical-tail-01`.
387
+ // Canonical only: non-zero unused low bits would let two spellings decode to
388
+ // one blob, and `atob` accepts them (`parts-decoder-non-canonical-tail-01`).
700
389
  if (width > 0 && bits !== 0) return null;
701
390
  return new Uint8Array(out);
702
391
  }
703
392
 
704
393
  /**
705
- * The inverse of `base64Decode`, written out for the same reason: `btoa` and
706
- * Python's `base64` module differ in what they accept and in how they pad, and
707
- * this value is compared against a digest a sender committed, so the two
708
- * kernels have to emit the same characters.
394
+ * The inverse of `base64Decode`, written out so every implementation emits the
395
+ * same characters.
709
396
  */
710
397
  function base64Encode(data: Uint8Array, alphabet: string, pad: boolean): Uint8Array {
711
398
  let out = "";
@@ -734,27 +421,10 @@ function asciiBytes(text: string): Uint8Array {
734
421
  }
735
422
 
736
423
  /**
737
- * Every spelling of `data` an honest stack might have been handed.
738
- *
739
- * The ENCODING direction of the encoding guard. A receiver whose stack decoded
740
- * what it was given holds the decoded bytes while the sender committed the
741
- * encoded text, which is what a single-line DER attachment, a PEM body and a
742
- * JWT each are: the part's bytes ARE the base64 text. Guarding only the
743
- * decoding direction told that receiver it held a tamper and fired the
744
- * substitution, which is the one outcome this module calls worse than recording
745
- * nothing.
746
- *
747
- * The list is fixed and short, the same six spellings the decoder accepts, so
748
- * the two kernels try the same candidates in the same order and the cost is
749
- * linear in the length of the supply.
750
- *
751
- * A `data.length === 0` guard stood here and was deleted in the proof#156
752
- * second gate's round, for the same reason as the two in `base64Decode`: it
753
- * could not change an answer. This is reached only from `respellingExplains`,
754
- * and only after `sha256(data)` has already been compared to `contentHash` and
755
- * missed. Every spelling of the empty byte string is the empty byte string, so
756
- * the one candidate it would have produced is the digest that just failed. An
757
- * optimization written as a guard reads as protection that is not there.
424
+ * Every spelling of `data` an honest stack might have been handed: the
425
+ * encoding direction of the encoding guard. A receiver whose stack decoded a
426
+ * PEM, DER or JWT holds decoded bytes while the sender committed the text.
427
+ * No empty-input guard: an empty `data` already missed the digest.
758
428
  */
759
429
  function respellings(data: Uint8Array): Uint8Array[] {
760
430
  return [
@@ -768,10 +438,8 @@ function respellings(data: Uint8Array): Uint8Array[] {
768
438
  }
769
439
 
770
440
  /**
771
- * Whether a base64 or hex respelling of `data`, in EITHER direction, hashes to
772
- * `contentHash`. `contentHash` arrives lowercased: the charset is pinned and
773
- * the case is not (proof#135), so every comparison in this module is made on
774
- * one spelling.
441
+ * Whether a base64 or hex respelling of `data`, in either direction, hashes to
442
+ * `contentHash` (lowercase).
775
443
  */
776
444
  async function respellingExplains(data: Uint8Array, contentHash: string): Promise<boolean> {
777
445
  for (const decoded of [base64Decode(data), hexDecode(data)]) {
@@ -786,22 +454,11 @@ async function respellingExplains(data: Uint8Array, contentHash: string): Promis
786
454
  /**
787
455
  * What a receiver commits to, given the message and the bytes it holds.
788
456
  *
789
- * `supplied` is the bytes the receiver actually holds, each named by the index
790
- * of the part it belongs to. An empty list is the common case, a receiver that
791
- * holds no part's bytes, and it reads exactly as section 4.1 read before this
792
- * amendment: every file part is `PART_UNCHECKED` and the message a receiver
793
- * commits to is the message it was given.
794
- *
795
- * Where a part's bytes hash to something other than its `content_hash`, or
796
- * where their count is not its `content_length`, the returned `heldMessage`
797
- * carries that part's `content_hash` and `content_length` replaced by the held
798
- * bytes' own. The receiver commits `event.message_hash` over THAT, so its
799
- * record and the sender's disagree and the pair fails on `message_hash`.
800
- *
801
- * A refusal is scoped to the supplied entry it is about: the part it names is
802
- * not checked and not substituted, every other part reads as it would have, and
803
- * `heldMessage` is returned either way. Nothing a caller or a sender can do
804
- * denies the receiver a record.
457
+ * `supplied` names each byte string by its part index. With none, every file
458
+ * part is `PART_UNCHECKED` and the held message is the message given. Where a
459
+ * part's bytes miss its hash or length, `heldMessage` carries the held bytes'
460
+ * own values for that part, so the pair fails on `message_hash`. A refusal is
461
+ * scoped to its entry; `heldMessage` is always returned.
805
462
  */
806
463
  export async function commitHeldMessage(
807
464
  message: unknown,
@@ -809,7 +466,6 @@ export async function commitHeldMessage(
809
466
  ): Promise<HeldMessage> {
810
467
  const parts = messageParts(message);
811
468
  const classifications = parts.map(classify);
812
- // The inline hash agreement, refined out of the shape pass because it hashes.
813
469
  for (let i = 0; i < parts.length; i++) {
814
470
  if (classifications[i] !== PART_INLINE_BYTES) continue;
815
471
  if (!(await inlineClaimsAgree(parts[i] as Record<string, unknown>))) {
@@ -824,15 +480,13 @@ export async function commitHeldMessage(
824
480
  supplied.forEach((item, entry) => {
825
481
  const index = item.part;
826
482
  if (!isSafeIndex(index) || (index as number) >= parts.length) {
827
- // No part of this message: the only genuinely message-level refusal, and
828
- // it still names itself rather than going quiet.
483
+ // No part of this message.
829
484
  refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: null, entry });
830
485
  return;
831
486
  }
832
487
  if (classifications[index as number] !== null) {
833
- // A part that EXISTS and cannot be checked. Scoped to that part, so a
834
- // sender cannot buy silence by emitting one uncheckable part beside the
835
- // part it altered.
488
+ // A part that exists but cannot be checked. Scoped to that part, so one
489
+ // uncheckable part cannot hide an altered one.
836
490
  refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: index as number, entry });
837
491
  return;
838
492
  }
@@ -874,10 +528,7 @@ export async function commitHeldMessage(
874
528
  const contentLength = part.content_length as number;
875
529
  const digest = await sha256Hex(first.data);
876
530
  if (digest === contentHash) {
877
- // The LENGTH is compared too, and a disagreement substitutes exactly as a
878
- // hash disagreement does (proof#156 gate). Before that, a part whose
879
- // stated length the receiver could see was false read `part_matched`, so
880
- // the receiver certified the part and committed the false length with it.
531
+ // A matching hash with a false length still substitutes.
881
532
  if (first.data.length === contentLength) {
882
533
  readings.push({ index, result: PART_MATCHED });
883
534
  continue;
@@ -915,9 +566,7 @@ export async function commitHeldMessage(
915
566
 
916
567
  let held: unknown = message;
917
568
  if (replacements.size > 0) {
918
- // Rebuilt rather than mutated in place: the message a caller handed in is
919
- // the caller's, and a reading that edits its input is a reading that
920
- // changes what the caller commits to on the honest path too.
569
+ // Rebuilt, not mutated: the caller's message is the caller's.
921
570
  const heldParts = parts.map((part, index) => {
922
571
  const replacement = replacements.get(index);
923
572
  if (replacement === undefined) return part;
@@ -944,55 +593,14 @@ export async function commitHeldMessage(
944
593
  }
945
594
 
946
595
  /**
947
- * The parts the receiver HELD bytes for and did NOT check.
948
- *
949
- * The gap between "nothing was substituted" and "every part is clean", named
950
- * so a caller does not have to re-derive it from the refusal list (proof#156
951
- * second gate, F5). A part is in here where a refusal NAMES it and its reading
952
- * is one of `HELD_UNCHECKED_RESULTS`: the receiver had bytes for that part,
953
- * the rule declined to grade them, and NOTHING about that reaches the
954
- * committed record.
955
- *
956
- * THE RULE, in one sentence, because a filter is not a rule. A part is in here
957
- * where the receiver held bytes for it AND the message itself does not commit
958
- * its content. `PART_UNCHECKED` is a refused supply;
959
- * `PART_MALFORMED_DESCRIPTOR` is a part whose content travels BESIDE the
960
- * message under a claim no receiver can use, so the receiver's bytes are
961
- * exactly as ungraded and the record says as little. The first cut of this
962
- * function omitted the second, which the third gate measured: a `kind: "file"`
963
- * part with a non-conformant `content_hash` or `content_length`, bytes held,
964
- * read `[]` here, so a caller following the rule this module prescribes wrote
965
- * `accepted` over a part it held bytes for and never checked. That is the
966
- * class the mechanism exists to close, and Phil named it in proof#162's
967
- * ratification as the gap that decision inherits unless it is closed here.
968
- *
969
- * Three results are excluded ON PURPOSE. An inline part (`PART_INLINE_BYTES`,
970
- * `PART_INLINE_MISMATCHED`) and a non-file part (`PART_NOT_A_FILE`) carry
971
- * their content INSIDE the message, and `message_hash` commits it on both
972
- * sides: bytes supplied beside one of those grade nothing and hide nothing,
973
- * because a reader of either pack reads the content itself. Naming them would
974
- * fire the caller's obligation on parts the swapped-bytes attack cannot reach
975
- * at all.
596
+ * The parts the receiver held bytes for and did not check: a refusal names the
597
+ * part and its result is in `HELD_UNCHECKED_RESULTS`, or the part is a URL part
598
+ * that reads `PART_NOT_A_FILE` and the refusal is `BYTES_FOR_UNKNOWN_PART`. A
599
+ * caller writes `accepted` only when this and `substituted` are both empty, and
600
+ * records the positions and their count when it is not zero.
976
601
  *
977
- * Why it is worth a function rather than a note. Delivering a tampered copy of
978
- * a part alone is `part_mismatched`, is substituted, and fails the pair on
979
- * `message_hash`. Delivering the tampered copy AND the true one is
980
- * `conflicting_part_bytes`, is never substituted, and the pair VERIFIES, so
981
- * adding data suppresses detection and the only trace is a refusal the
982
- * receiving side publishes at its own discretion.
983
- *
984
- * NOT the same as "this part reads `PART_UNCHECKED`". A receiver that holds no
985
- * bytes at all reads every file part that way, and that is the COMMON CASE,
986
- * unchanged by A18 and not a reason to withhold `accepted`. Only a part whose
987
- * bytes the receiver actually held and could not grade is in here.
988
- *
989
- * What this does NOT do: it cannot stop a caller writing `accepted` anyway.
990
- * This module reads and does not write the record. proof#162 ratified what the
991
- * record layer does instead (Phil, 2026-09-22): `accepted` keeps its meaning
992
- * and the COUNT of these parts rides in an additive `context` key, REQUIRED
993
- * when it is not zero, enforced against SDKs through conformance vectors. So a
994
- * part missing from this list is a part no record built on that rule will ever
995
- * mention.
602
+ * Not the same as "reads `PART_UNCHECKED`": a receiver holding no bytes reads
603
+ * every file part that way, which is the common case.
996
604
  */
997
605
  export function uncheckedHeldParts(reading: HeldMessage): number[] {
998
606
  const unchecked = new Set(
@@ -1000,9 +608,23 @@ export function uncheckedHeldParts(reading: HeldMessage): number[] {
1000
608
  .filter((p) => HELD_UNCHECKED_RESULTS.includes(p.result))
1001
609
  .map((p) => p.index),
1002
610
  );
611
+ const held = reading.heldMessage;
612
+ const parts = isPlainObject(held) && Array.isArray(held.parts) ? held.parts : [];
613
+ const urlParts = new Set(
614
+ reading.parts
615
+ .filter((p) => p.result === PART_NOT_A_FILE && p.index < parts.length && isUrlPart(parts[p.index]))
616
+ .map((p) => p.index),
617
+ );
1003
618
  const named = new Set<number>();
1004
619
  for (const refusal of reading.refusals) {
1005
- if (refusal.part !== null && unchecked.has(refusal.part)) named.add(refusal.part);
620
+ if (refusal.part === null) continue;
621
+ if (unchecked.has(refusal.part)) named.add(refusal.part);
622
+ if (urlParts.has(refusal.part) && refusal.name === BYTES_FOR_UNKNOWN_PART) named.add(refusal.part);
1006
623
  }
1007
624
  return [...named].sort((a, b) => a - b);
1008
625
  }
626
+
627
+ /** A part with no `kind: "file"` whose `url` member is present and not `null`. */
628
+ function isUrlPart(part: unknown): boolean {
629
+ return isPlainObject(part) && part.kind !== "file" && hasOwn(part, "url") && part.url !== null;
630
+ }