@sanning/proof 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/dist/handoff.js
CHANGED
|
@@ -1,181 +1,67 @@
|
|
|
1
|
-
// The hand-off pairing rule of specs/envelope-spec.md §4.1
|
|
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
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// -
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
export const CANDIDATE_NAMESPACE = "handoff.";
|
|
140
|
-
export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"];
|
|
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
|
+
export const CANDIDATE_NAMESPACE = "agent.handoff.";
|
|
24
|
+
export const DEFINED_EVENT_TYPES = ["agent.handoff.sent", "agent.handoff.received"];
|
|
141
25
|
/**
|
|
142
|
-
* The finding a record that is not a hand-off record at all
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* (anchor#188) and this reading names an outcome beside one, exactly as
|
|
146
|
-
* `counterpart_absent`, `counterpart_ambiguous` and `malformed_record` do. It
|
|
147
|
-
* exists because 4.1 had a SILENT branch, and a rule with a silent branch is a
|
|
148
|
-
* rule the party being audited steers into (proof#136). A record TRYING to be
|
|
149
|
-
* a hand-off and getting it wrong reads `malformed`; a record that is not a
|
|
150
|
-
* hand-off at all reads this. Both are readings. Neither is silence.
|
|
26
|
+
* The finding for a record that is not a hand-off record at all. A finding
|
|
27
|
+
* beside a verdict, not a verdict: a record that tries to be a hand-off and
|
|
28
|
+
* gets it wrong reads `malformed` or `nonconforming` instead. Neither is silence.
|
|
151
29
|
*/
|
|
152
30
|
export const NOT_A_HANDOFF_FINDING = "not_a_handoff_record";
|
|
153
31
|
const OUTCOMES = ["accepted", "rejected", "failed"];
|
|
154
32
|
const ID_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
155
33
|
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
156
34
|
const LOWER_HEX64 = /^[0-9a-f]{64}$/;
|
|
157
|
-
//
|
|
158
|
-
// kernels report one list rather than two orderings of it.
|
|
35
|
+
// Fixed so two kernels report one list rather than two orderings of it.
|
|
159
36
|
const CHECK_ORDER = ["message_hash", "sequence", "self_handoff", "counterparty_named"];
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
37
|
+
// The verdict a defect gives a record (evidence-bundle.md section 3): a record that cannot be
|
|
38
|
+
// read as a hand-off is `incomplete` (its claim is not proven, unreadable), and one that
|
|
39
|
+
// breaks its vocabulary is `failed` with nothing proven altered.
|
|
40
|
+
const DEFECT_VERDICT = { malformed: "incomplete", nonconforming: "failed" };
|
|
41
|
+
/**
|
|
42
|
+
* Which candidate a subject reads as, worst last. A proven check failure outranks a
|
|
43
|
+
* record that breaks its vocabulary (`failed` with no check named), and a record that
|
|
44
|
+
* cannot be read outranks both, so one junk candidate is named as itself
|
|
45
|
+
* (`counterpart_malformed`) and never as a failed pair.
|
|
46
|
+
*/
|
|
47
|
+
function rank(verdict, failedChecks) {
|
|
48
|
+
if (verdict === "incomplete")
|
|
49
|
+
return 3;
|
|
50
|
+
if (verdict === "failed")
|
|
51
|
+
return failedChecks.length > 0 ? 2 : 1;
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
// Listed rather than delegated: `String.trim()` and Python's `str.strip()`
|
|
55
|
+
// disagree on `U+FEFF`, and the kernels must fold identically.
|
|
165
56
|
const ASCII_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\v", "\f"]);
|
|
166
57
|
/**
|
|
167
|
-
* An envelope `public_key` as a
|
|
168
|
-
*
|
|
169
|
-
* hex and so names no key this reading can compare.
|
|
58
|
+
* An envelope `public_key` as a key: the 32 key bytes as lowercase hex, or
|
|
59
|
+
* `null` where the value is not 64 hex characters.
|
|
170
60
|
*
|
|
171
|
-
* Case is
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* Python's `bytes.fromhex` skips ASCII whitespace and this kernel's charset
|
|
176
|
-
* check does not, a live divergence at the ENVELOPE layer and a separate
|
|
177
|
-
* founder call (proof#135). A reading rule that silently repaired it would
|
|
178
|
-
* hide it.
|
|
61
|
+
* Case is folded because the envelope layer accepts either case and the
|
|
62
|
+
* audited party is the producer. Whitespace is not stripped: Python's
|
|
63
|
+
* `bytes.fromhex` skips it and this kernel does not, and repairing that here
|
|
64
|
+
* would hide the divergence.
|
|
179
65
|
*/
|
|
180
66
|
export function normalizedSigningKey(value) {
|
|
181
67
|
if (typeof value !== "string" || !HEX64.test(value))
|
|
@@ -185,20 +71,9 @@ export function normalizedSigningKey(value) {
|
|
|
185
71
|
.join("");
|
|
186
72
|
}
|
|
187
73
|
/**
|
|
188
|
-
* Whether two envelope keys are the
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* Where either side names no key, the question cannot be answered from key
|
|
192
|
-
* bytes and the answer is the conservative one: the two are reported as one
|
|
193
|
-
* key, so the pair reads `failed[self_handoff]` rather than clean. Exact
|
|
194
|
-
* string equality was the answer here and it was the wrong way round: one key
|
|
195
|
-
* that signed both ends, spelled `abcd...` on one envelope and `abcd ...` on
|
|
196
|
-
* the other, is one party, and string equality called it two and read the
|
|
197
|
-
* self-hand-off `verified` (proof#133 fourth hunt, finding 1). A record whose
|
|
198
|
-
* own envelope key this reading cannot read is `malformed` under
|
|
199
|
-
* `recordIsMalformed` and a formed pair reaches `malformed` before it reaches
|
|
200
|
-
* this line, so the two rules are belt and braces rather than one rule stated
|
|
201
|
-
* twice.
|
|
74
|
+
* Whether two envelope keys are the same key. Where either side names no key,
|
|
75
|
+
* the answer is the conservative one (same key), so the pair reads
|
|
76
|
+
* `failed[self_handoff]` rather than clean.
|
|
202
77
|
*/
|
|
203
78
|
export function sameSigningKey(left, right) {
|
|
204
79
|
const leftKey = normalizedSigningKey(left);
|
|
@@ -208,11 +83,9 @@ export function sameSigningKey(left, right) {
|
|
|
208
83
|
return leftKey === rightKey;
|
|
209
84
|
}
|
|
210
85
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
214
|
-
* delegated to `toLowerCase()` or `trim()`, which do not agree with Python on
|
|
215
|
-
* non-ASCII input.
|
|
86
|
+
* An `event_type` folded for the namespace test only: ASCII whitespace
|
|
87
|
+
* trimmed, ASCII lower-cased, `-` and `_` read as `.`. Spelled out because
|
|
88
|
+
* `toLowerCase()` and `trim()` disagree with Python on non-ASCII input.
|
|
216
89
|
*/
|
|
217
90
|
function foldEventType(value) {
|
|
218
91
|
const characters = [...value];
|
|
@@ -232,45 +105,14 @@ function foldEventType(value) {
|
|
|
232
105
|
.join("");
|
|
233
106
|
}
|
|
234
107
|
/**
|
|
235
|
-
* Whether a
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* `handoff.` prefix did not deliver that: `HANDOFF.RECEIVED`,
|
|
239
|
-
* `sanning.handoff.received`, `handoff-received` and a non-string `event_type`
|
|
240
|
-
* all vanished from the reading entirely, not even as malformed, so a receipt
|
|
241
|
-
* typed one character outside the prefix was never read and, beside a clean decoy,
|
|
242
|
-
* published `verified` over proven tampering (proof#133 adversarial gate,
|
|
243
|
-
* ruling 2).
|
|
244
|
-
*
|
|
245
|
-
* The rule is checkable rather than a judgement, and it is exactly two
|
|
246
|
-
* clauses. The FIRST is the one the adversary cannot evade: the record's
|
|
247
|
-
* `event` carries `counterparty_public_key`, the field §4.1 defines and no
|
|
248
|
-
* other section does, WHATEVER its `event_type` says. The second keeps a
|
|
249
|
-
* record that omits the field in scope: an `event_type` that is a string whose
|
|
250
|
-
* folded form has `handoff` as one of its `.`-separated segments, which leaves
|
|
251
|
-
* `asset_missing` and the other families alone.
|
|
252
|
-
*
|
|
253
|
-
* The order is the fix, not decoration. Every earlier version of this test was
|
|
254
|
-
* an ALLOW-LIST OVER A NAME THE AUDITED PARTY WRITES, so each repair narrowed
|
|
255
|
-
* the class and the next spelling walked around it: an exact `handoff.` prefix
|
|
256
|
-
* fell to `HANDOFF.RECEIVED`, and the ASCII fold that replaced it fell to the
|
|
257
|
-
* `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`, `handoffs`, a
|
|
258
|
-
* zero-width space, a BOM and a non-breaking space, each of which left a
|
|
259
|
-
* tampered receipt with no reading at all beside a clean decoy (proof#133
|
|
260
|
-
* fourth hunt, finding 2). The structural field closes all of them in one rule
|
|
261
|
-
* rather than by chasing Unicode, because the attack needs the field: a
|
|
262
|
-
* receipt that drops `counterparty_public_key` says nothing about who it came
|
|
263
|
-
* from.
|
|
108
|
+
* Whether a record is in the hand-off namespace: its `event` carries
|
|
109
|
+
* `counterparty_public_key` (the field §4.1 defines), whatever its `event_type`
|
|
110
|
+
* says, or its folded `event_type` has `handoff` as a `.`-separated segment.
|
|
264
111
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* by the party being audited, and one code point on each put a record in scope
|
|
270
|
-
* by neither, which produced silence. `readHandoffs` reads every record it
|
|
271
|
-
* holds, and this test now decides only WHICH reading a record gets: in scope
|
|
272
|
-
* and wrong reads `malformed`, out of scope reads `NOT_A_HANDOFF_FINDING`. A
|
|
273
|
-
* respelling buys a different line rather than no line.
|
|
112
|
+
* The structural clause comes first because a name is written by the audited
|
|
113
|
+
* party and Unicode respellings walk around any allow-list. This decides only
|
|
114
|
+
* which reading a record gets: in scope and wrong reads `malformed`, out of
|
|
115
|
+
* scope reads `NOT_A_HANDOFF_FINDING`.
|
|
274
116
|
*/
|
|
275
117
|
export function isHandoffNamespace(held) {
|
|
276
118
|
if ("counterparty_public_key" in section(held.record, "event"))
|
|
@@ -290,45 +132,47 @@ function eventType(held) {
|
|
|
290
132
|
function isSafeSequence(value) {
|
|
291
133
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
292
134
|
}
|
|
293
|
-
// Code
|
|
294
|
-
// `len()` of the same character is 1 in Python, so a length bound counted in
|
|
295
|
-
// units makes the two kernels disagree about identical bytes (proof#133
|
|
296
|
-
// review, ruling 3).
|
|
135
|
+
// Code points, not UTF-16 units, so a length bound matches Python's `len()`.
|
|
297
136
|
function codePoints(value) {
|
|
298
137
|
return [...value].length;
|
|
299
138
|
}
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// `"sequence": true` stay in one kernel's reading and drop out of the other's.
|
|
139
|
+
// Strict: Python's half also rejects `True == 1`, so `"sequence": true` cannot
|
|
140
|
+
// read differently in the two kernels.
|
|
303
141
|
function sameNumber(left, right) {
|
|
304
142
|
return left === right;
|
|
305
143
|
}
|
|
306
144
|
/**
|
|
307
|
-
*
|
|
308
|
-
* not the type §4.1 states, is malformed and fails. Stated as a rule because
|
|
309
|
-
* §4.1 had none: a pair where both sides omitted `message_hash` compared absent
|
|
310
|
-
* to absent, found them equal, and graded intact (proof#132).
|
|
145
|
+
* What is wrong with a record section 4.1 governs, read on its own, or `null`.
|
|
311
146
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* whitespace, which reopened the whole of the first attack through the repair
|
|
320
|
-
* rather than around it (proof#133 fourth hunt, finding 1). A record a reader
|
|
321
|
-
* HOLDS, whose own key it cannot read, is a defect in the record and not an
|
|
322
|
-
* absence of one. This holds whatever the envelope layer decides about its hex
|
|
323
|
-
* charset (proof#135).
|
|
147
|
+
* `malformed` where it cannot be read as a hand-off record: its envelope key
|
|
148
|
+
* does not parse (an unreadable key would drop it out of candidacy and the
|
|
149
|
+
* other side would read an innocent `counterpart_absent`), or its type is a
|
|
150
|
+
* respelling of the two names. `nonconforming` where it is one of the two types
|
|
151
|
+
* and a required field is missing or mistyped (two records that both omit
|
|
152
|
+
* `message_hash` would otherwise compare equal), or the agent reader read it
|
|
153
|
+
* `failed` (`agentNonconforming`).
|
|
324
154
|
*/
|
|
325
|
-
export function
|
|
155
|
+
export function recordDefect(held) {
|
|
326
156
|
if (normalizedSigningKey(held.publicKey) === null)
|
|
327
|
-
return
|
|
157
|
+
return "malformed";
|
|
328
158
|
const type = eventType(held);
|
|
329
159
|
if (typeof type !== "string" || !DEFINED_EVENT_TYPES.includes(type)) {
|
|
330
|
-
return
|
|
160
|
+
return "malformed"; // including any other handoff.* name
|
|
331
161
|
}
|
|
162
|
+
if (held.agentNonconforming === true || breaksItsType(held, type))
|
|
163
|
+
return "nonconforming";
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/** `recordDefect` is `malformed`. */
|
|
167
|
+
export function recordIsMalformed(held) {
|
|
168
|
+
return recordDefect(held) === "malformed";
|
|
169
|
+
}
|
|
170
|
+
/** `recordDefect` is `nonconforming`. */
|
|
171
|
+
export function recordIsNonconforming(held) {
|
|
172
|
+
return recordDefect(held) === "nonconforming";
|
|
173
|
+
}
|
|
174
|
+
/** A record of one of the two types missing a field section 4.1 requires, or carrying one it does not allow. */
|
|
175
|
+
function breaksItsType(held, type) {
|
|
332
176
|
const raw = held.record;
|
|
333
177
|
for (const key of ["event", "context"]) {
|
|
334
178
|
const value = raw[key];
|
|
@@ -351,19 +195,16 @@ export function recordIsMalformed(held) {
|
|
|
351
195
|
}
|
|
352
196
|
if (!isSafeSequence(event.sequence))
|
|
353
197
|
return true;
|
|
354
|
-
if (type === "handoff.received") {
|
|
198
|
+
if (type === "agent.handoff.received") {
|
|
355
199
|
if (typeof event.outcome !== "string" || !OUTCOMES.includes(event.outcome))
|
|
356
200
|
return true;
|
|
357
201
|
}
|
|
358
202
|
else if ("outcome" in event) {
|
|
359
|
-
return true; // outcome is
|
|
203
|
+
return true; // outcome is an agent.handoff.received field only
|
|
360
204
|
}
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// `message_bytes_hash` is NOT checked here. Its own row in 4.1 says a reader
|
|
365
|
-
// MUST NOT match, pair or grade on it, and calling a record malformed for it
|
|
366
|
-
// is grading on it (proof#133 review, ruling 5).
|
|
205
|
+
// Optional fields: absence is never a defect, but a present one must have
|
|
206
|
+
// its §4.1 type. `message_bytes_hash` is not checked: §4.1 says a reader must
|
|
207
|
+
// not grade on it.
|
|
367
208
|
if ("case_id" in context) {
|
|
368
209
|
const value = context.case_id;
|
|
369
210
|
if (typeof value !== "string" || codePoints(value) > 128 || !ID_PATTERN.test(value))
|
|
@@ -382,26 +223,15 @@ export function recordIsMalformed(held) {
|
|
|
382
223
|
return false;
|
|
383
224
|
}
|
|
384
225
|
function complement(type) {
|
|
385
|
-
return type === "handoff.sent" ? "handoff.received" : "handoff.sent";
|
|
226
|
+
return type === "agent.handoff.sent" ? "agent.handoff.received" : "agent.handoff.sent";
|
|
386
227
|
}
|
|
387
228
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* reason is the honest two-way case rather than a preference: an honest reply is
|
|
392
|
-
* a `handoff.sent` signed by the other agent, naming this one, under the same
|
|
393
|
-
* case identifier and at the same sequence number, which is byte-for-byte the
|
|
394
|
-
* shape of a receiving side that mislabels its receipt. Admitting same-type
|
|
395
|
-
* candidates would grade an honest reply as a failed hand-off. §4.1 names that
|
|
396
|
-
* residual path, and the declaration (evidence-bundle.md §5.4) is its belt.
|
|
229
|
+
* The candidates for a subject, chosen by who signed. The complementary type is
|
|
230
|
+
* required because an honest reply has exactly the shape of a mislabelled
|
|
231
|
+
* receipt; the declaration (evidence-bundle.md §5.4) covers that residual path.
|
|
397
232
|
*/
|
|
398
233
|
function candidatesFor(subject, held) {
|
|
399
|
-
//
|
|
400
|
-
// audited party the set itself: an envelope key spelled in upper case names
|
|
401
|
-
// the same key, the record side is pinned to lowercase hex and the envelope
|
|
402
|
-
// side is pinned nowhere, so one character class took the tampered receipt
|
|
403
|
-
// out of the set and left the clean decoy in it (proof#133 adversarial
|
|
404
|
-
// gate, ruling 1).
|
|
234
|
+
// Keys, never spellings of a key: the envelope side's case is unconstrained.
|
|
405
235
|
const wanted = normalizedSigningKey(section(subject.record, "event").counterparty_public_key);
|
|
406
236
|
if (wanted === null)
|
|
407
237
|
return []; // the subject names no key
|
|
@@ -411,19 +241,9 @@ function candidatesFor(subject, held) {
|
|
|
411
241
|
eventType(candidate) === wantedType);
|
|
412
242
|
}
|
|
413
243
|
/**
|
|
414
|
-
* Order a candidate set
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* likely to want at the front of the report, and both are written by the party
|
|
418
|
-
* being audited, so neither decides what gets graded. A version of this function
|
|
419
|
-
* that filtered, "applying a step only where it leaves at least one candidate",
|
|
420
|
-
* was itself a laundering path: the receiving side publishes the tampered
|
|
421
|
-
* receipt with its `case_id` or its `sequence` changed and a clean decoy that
|
|
422
|
-
* carries the subject's values, the surviving decoy keeps the step alive, the
|
|
423
|
-
* tamper drops out of the set, and the reading is `verified` with no ambiguity
|
|
424
|
-
* named (proof#133 review, ruling 1).
|
|
425
|
-
*
|
|
426
|
-
* The sort is total and deterministic, so the two kernels report one order.
|
|
244
|
+
* Order a candidate set by `case_id`, then `sequence`. Removes nothing: both
|
|
245
|
+
* fields are written by the audited party. The sort is total so the two kernels
|
|
246
|
+
* report one order.
|
|
427
247
|
*/
|
|
428
248
|
function order(subject, candidates) {
|
|
429
249
|
const wantedCase = namedCase(section(subject.record, "context"));
|
|
@@ -445,27 +265,16 @@ function order(subject, candidates) {
|
|
|
445
265
|
});
|
|
446
266
|
}
|
|
447
267
|
/**
|
|
448
|
-
* Which case a record names, for
|
|
449
|
-
*
|
|
450
|
-
* An ABSENT `context.case_id` and one written as `null` name the same thing,
|
|
451
|
-
* which is no case. Python's missing value is `None` and this kernel's is
|
|
452
|
-
* `undefined`, so a candidate carrying `"case_id": null` was "same case" in
|
|
453
|
-
* one kernel and not in the other when the subject lawfully omits the OPTIONAL
|
|
454
|
-
* key: same verdict, DIFFERENT candidate order, and the cross-kernel gate's
|
|
455
|
-
* own contract is that the reading it compares carries the candidates in
|
|
456
|
-
* order. The party being audited writes that `null` (proof#133 fifth hunt,
|
|
457
|
-
* closed by proof#136).
|
|
268
|
+
* Which case a record names, for ordering only. Absent and `null` both mean no
|
|
269
|
+
* case, so Python's `None` and this kernel's `undefined` order identically.
|
|
458
270
|
*/
|
|
459
271
|
function namedCase(context) {
|
|
460
272
|
const value = context.case_id;
|
|
461
273
|
return value === undefined ? null : value;
|
|
462
274
|
}
|
|
463
275
|
/**
|
|
464
|
-
* Whether two records name the same case, for
|
|
465
|
-
*
|
|
466
|
-
* a string (4.1), so anything else names no case a reader can compare. Stated
|
|
467
|
-
* in one rule in both kernels so `None` and `undefined` cannot mean two
|
|
468
|
-
* things.
|
|
276
|
+
* Whether two records name the same case, for ordering only. Two records naming
|
|
277
|
+
* no case match; a non-string names no case.
|
|
469
278
|
*/
|
|
470
279
|
function isSameCase(left, right) {
|
|
471
280
|
if (left === null && right === null)
|
|
@@ -473,13 +282,12 @@ function isSameCase(left, right) {
|
|
|
473
282
|
return typeof left === "string" && typeof right === "string" && left === right;
|
|
474
283
|
}
|
|
475
284
|
function gradePair(subject, counterpart) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
285
|
+
const defects = [recordDefect(subject), recordDefect(counterpart)];
|
|
286
|
+
if (defects.includes("malformed"))
|
|
287
|
+
return { verdict: DEFECT_VERDICT.malformed, checks: [] };
|
|
288
|
+
if (defects.includes("nonconforming"))
|
|
289
|
+
return { verdict: DEFECT_VERDICT.nonconforming, checks: [] };
|
|
479
290
|
const failed = new Set();
|
|
480
|
-
// The same KEY, whatever the two envelopes spelled it as: one key signing
|
|
481
|
-
// both ends spelled upper case on one side and lower on the other read as a
|
|
482
|
-
// transfer between two parties (proof#133 adversarial gate, ruling 1).
|
|
483
291
|
if (sameSigningKey(subject.publicKey, counterpart.publicKey))
|
|
484
292
|
failed.add("self_handoff");
|
|
485
293
|
const subjectEvent = section(subject.record, "event");
|
|
@@ -490,34 +298,22 @@ function gradePair(subject, counterpart) {
|
|
|
490
298
|
failed.add("message_hash");
|
|
491
299
|
if (!sameNumber(subjectEvent.sequence, counterpartEvent.sequence))
|
|
492
300
|
failed.add("sequence");
|
|
493
|
-
// Each record
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
// Both halves of the naming are graded, each against the envelope key the
|
|
498
|
-
// other record was SIGNED with, compared as a key. The Python half is
|
|
499
|
-
// `_grade_pair`.
|
|
500
|
-
const [sent, received] = eventType(subject) === "handoff.sent" ? [subject, counterpart] : [counterpart, subject];
|
|
301
|
+
// Each record must name the other's signer, compared as a key against the
|
|
302
|
+
// envelope key the other was signed with. Candidacy alone reads only what the
|
|
303
|
+
// subject names.
|
|
304
|
+
const [sent, received] = eventType(subject) === "agent.handoff.sent" ? [subject, counterpart] : [counterpart, subject];
|
|
501
305
|
const sentNames = normalizedSigningKey(section(sent.record, "event").counterparty_public_key);
|
|
502
306
|
const receivedNames = normalizedSigningKey(section(received.record, "event").counterparty_public_key);
|
|
503
|
-
// The send names who received it.
|
|
504
307
|
if (sentNames !== normalizedSigningKey(received.publicKey))
|
|
505
308
|
failed.add("counterparty_named");
|
|
506
|
-
// The receipt names who sent it.
|
|
507
309
|
if (receivedNames !== normalizedSigningKey(sent.publicKey))
|
|
508
310
|
failed.add("counterparty_named");
|
|
509
311
|
const checks = CHECK_ORDER.filter((name) => failed.has(name));
|
|
510
312
|
return { verdict: checks.length > 0 ? "failed" : "verified", checks: [...checks] };
|
|
511
313
|
}
|
|
512
314
|
/**
|
|
513
|
-
* Read
|
|
514
|
-
*
|
|
515
|
-
* Every record gets a reading, and the two findings are how the distinction a
|
|
516
|
-
* reader needs survives (proof#136): a record that is not a hand-off record at
|
|
517
|
-
* all reads `NOT_A_HANDOFF_FINDING` with no verdict, and a record 4.1 DOES
|
|
518
|
-
* govern and that is wrong reads `malformed`. The scope test lives HERE and
|
|
519
|
-
* not in `readHandoffs`, so the two entry points cannot answer differently
|
|
520
|
-
* about one record.
|
|
315
|
+
* Read one record against everything the reader holds. The scope test lives
|
|
316
|
+
* here, not in `readHandoffs`, so the two entry points cannot disagree.
|
|
521
317
|
*/
|
|
522
318
|
export function readHandoff(subject, held) {
|
|
523
319
|
if (!isHandoffNamespace(subject)) {
|
|
@@ -530,12 +326,13 @@ export function readHandoff(subject, held) {
|
|
|
530
326
|
candidateReadings: [],
|
|
531
327
|
};
|
|
532
328
|
}
|
|
533
|
-
|
|
329
|
+
const defect = recordDefect(subject);
|
|
330
|
+
if (defect !== null) {
|
|
534
331
|
return {
|
|
535
332
|
ref: subject.ref,
|
|
536
|
-
verdict:
|
|
333
|
+
verdict: DEFECT_VERDICT[defect],
|
|
537
334
|
failedChecks: [],
|
|
538
|
-
findings: [
|
|
335
|
+
findings: [`${defect}_record`],
|
|
539
336
|
candidates: [],
|
|
540
337
|
candidateReadings: [],
|
|
541
338
|
};
|
|
@@ -551,20 +348,16 @@ export function readHandoff(subject, held) {
|
|
|
551
348
|
candidateReadings: [],
|
|
552
349
|
};
|
|
553
350
|
}
|
|
554
|
-
//
|
|
555
|
-
// and took none of them out of this list.
|
|
351
|
+
// Every candidate is graded; ordering only chose where each is reported.
|
|
556
352
|
const graded = candidates.map((candidate) => {
|
|
557
353
|
const { verdict, checks } = gradePair(subject, candidate);
|
|
558
|
-
|
|
354
|
+
// Only a pair that failed a check can be another exchange's record.
|
|
355
|
+
const by = verdict === "failed" && checks.length > 0 ? accountedFor(subject, candidate, held) : null;
|
|
559
356
|
return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
|
|
560
357
|
});
|
|
561
|
-
// The verdict, the failed checks and the ambiguity are taken over the
|
|
562
|
-
// candidates nothing accounts for. The others stay in the report.
|
|
563
358
|
const live = graded.filter((g) => g.accountedFor === null);
|
|
564
359
|
if (live.length === 0) {
|
|
565
|
-
// Every candidate
|
|
566
|
-
// verified pair with a record the subject's own key signed: an absence,
|
|
567
|
-
// reported with the candidates it looked at.
|
|
360
|
+
// Every candidate belongs to another exchange: an absence.
|
|
568
361
|
return {
|
|
569
362
|
ref: subject.ref,
|
|
570
363
|
verdict: null,
|
|
@@ -574,23 +367,21 @@ export function readHandoff(subject, held) {
|
|
|
574
367
|
candidateReadings: graded,
|
|
575
368
|
};
|
|
576
369
|
}
|
|
577
|
-
const worst = Math.max(...live.map((g) =>
|
|
578
|
-
const verdict =
|
|
579
|
-
// Over every live candidate
|
|
580
|
-
//
|
|
581
|
-
// strip the named `message_hash` failure out of the report (proof#133
|
|
582
|
-
// review, ruling 6).
|
|
370
|
+
const worst = Math.max(...live.map((g) => rank(g.verdict, g.failedChecks)));
|
|
371
|
+
const verdict = ["verified", "failed", "failed", "incomplete"][worst];
|
|
372
|
+
// Over every live candidate: `malformed` outranks `failed`, and one junk
|
|
373
|
+
// record must not strip a named failure from the report.
|
|
583
374
|
const failed = new Set();
|
|
584
375
|
for (const g of live)
|
|
585
376
|
for (const check of g.failedChecks)
|
|
586
377
|
failed.add(check);
|
|
587
378
|
const findings = [];
|
|
588
|
-
// Never a free choice: a reader permitted to take one match can be handed an
|
|
589
|
-
// honest decoy beside a tampered record.
|
|
590
379
|
if (live.length > 1)
|
|
591
380
|
findings.push("counterpart_ambiguous");
|
|
592
|
-
if (
|
|
381
|
+
if (worst === 3)
|
|
593
382
|
findings.push("malformed_record");
|
|
383
|
+
else if (worst === 1)
|
|
384
|
+
findings.push("nonconforming_record");
|
|
594
385
|
return {
|
|
595
386
|
ref: subject.ref,
|
|
596
387
|
verdict,
|
|
@@ -601,28 +392,23 @@ export function readHandoff(subject, held) {
|
|
|
601
392
|
};
|
|
602
393
|
}
|
|
603
394
|
/**
|
|
604
|
-
* The ref of the record that accounts for a
|
|
605
|
-
*
|
|
606
|
-
* on this one, only where the reader holds a record `o`, other than the
|
|
607
|
-
* subject, for which all four hold:
|
|
395
|
+
* The ref of the record that accounts for a failed candidate, or `null`. That
|
|
396
|
+
* is a record `o`, other than the subject, where:
|
|
608
397
|
*
|
|
609
|
-
* 1. `o` is signed by the
|
|
398
|
+
* 1. `o` is signed by the subject's key and is the subject's type;
|
|
610
399
|
* 2. `o` names the candidate's signer, and the candidate names the subject's
|
|
611
400
|
* key back;
|
|
612
401
|
* 3. `o`'s `sequence` differs from the subject's;
|
|
613
402
|
* 4. the candidate grades `verified` against `o`.
|
|
614
403
|
*
|
|
615
|
-
* Condition 1 is what the audited party cannot supply: a verified pair needs
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
* is a second claim about the subject's message. The first `o` in the order
|
|
619
|
-
* the records are held is reported, so two kernels name one record. The
|
|
620
|
-
* Python half, `_accounted_for`, carries the full reasoning.
|
|
404
|
+
* Condition 1 is what the audited party cannot supply: a verified pair needs the
|
|
405
|
+
* other side's signature. Condition 3 stops a second claim about the subject's
|
|
406
|
+
* own message. The first `o` in held order wins, so both kernels name one record.
|
|
621
407
|
*/
|
|
622
408
|
function accountedFor(subject, candidate, held) {
|
|
623
409
|
const own = normalizedSigningKey(subject.publicKey);
|
|
624
410
|
const theirs = normalizedSigningKey(candidate.publicKey);
|
|
625
|
-
// Condition 2
|
|
411
|
+
// Condition 2, the candidate's half.
|
|
626
412
|
if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
|
|
627
413
|
return null;
|
|
628
414
|
}
|
|
@@ -630,63 +416,37 @@ function accountedFor(subject, candidate, held) {
|
|
|
630
416
|
for (const other of held) {
|
|
631
417
|
if (other === subject)
|
|
632
418
|
continue;
|
|
633
|
-
// Condition 1
|
|
419
|
+
// Condition 1.
|
|
634
420
|
if (normalizedSigningKey(other.publicKey) !== own)
|
|
635
421
|
continue;
|
|
636
422
|
if (eventType(other) !== eventType(subject))
|
|
637
423
|
continue;
|
|
638
|
-
// Condition 2
|
|
424
|
+
// Condition 2, o's half.
|
|
639
425
|
if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
|
|
640
426
|
continue;
|
|
641
427
|
}
|
|
642
|
-
// Condition 3
|
|
428
|
+
// Condition 3.
|
|
643
429
|
if (sameNumber(section(other.record, "event").sequence, subjectSequence))
|
|
644
430
|
continue;
|
|
645
|
-
// Condition 4
|
|
431
|
+
// Condition 4.
|
|
646
432
|
if (gradePair(other, candidate).verdict === "verified")
|
|
647
433
|
return other.ref;
|
|
648
434
|
}
|
|
649
435
|
return null;
|
|
650
436
|
}
|
|
651
437
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
* record was read at all is gone (proof#136, ratified by Phil 2026-09-22).
|
|
656
|
-
* Scope was two clauses, an exact test for the key name
|
|
657
|
-
* `counterparty_public_key` and an ASCII fold of `event_type`, and both
|
|
658
|
-
* operands are written by the party being audited: one code point on each put
|
|
659
|
-
* a record in scope by NEITHER route, so it had no reading at all and the pile
|
|
660
|
-
* read clean. Five repairs narrowed which values reached that silent branch
|
|
661
|
-
* and five hunts found a value outside it, so the branch is removed rather
|
|
662
|
-
* than narrowed again.
|
|
663
|
-
*
|
|
664
|
-
* The distinction a reader needs survives as two findings rather than as a
|
|
665
|
-
* reading and a silence: a record TRYING to be a hand-off and getting it wrong
|
|
666
|
-
* reads `malformed` with `malformed_record`, and a record that is not a
|
|
667
|
-
* hand-off record at all reads `NOT_A_HANDOFF_FINDING` with no verdict.
|
|
668
|
-
* `isHandoffNamespace` decides which of the two, in `readHandoff`, and no
|
|
669
|
-
* longer whether a record is read.
|
|
670
|
-
*
|
|
671
|
-
* THE COST, stated rather than left to be found: a reader given a mixed pile
|
|
672
|
-
* produces a line per record, including for records that have nothing to do
|
|
673
|
-
* with hand-offs. 4.1 says so too.
|
|
438
|
+
* Read every held record, in the order given. Nothing is filtered: a record
|
|
439
|
+
* outside the hand-off namespace reads `NOT_A_HANDOFF_FINDING`, so a mixed pile
|
|
440
|
+
* produces a line per record.
|
|
674
441
|
*/
|
|
675
442
|
export function readHandoffs(held) {
|
|
676
443
|
return held.map((record) => readHandoff(record, held));
|
|
677
444
|
}
|
|
678
445
|
/**
|
|
679
|
-
* Whether
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* `handoff.sent` or a `handoff.received` carrying every REQUIRED field of its
|
|
683
|
-
* own type, under an envelope key this reading can read.
|
|
684
|
-
*
|
|
685
|
-
* `./declaration.ts` reads this for evidence-bundle.md 5.4: a claimed
|
|
686
|
-
* `sent_event_id` or `received_event_id` naming a record that is not one of
|
|
687
|
-
* these is a DEVIATION, so a record cannot be unreadable at the record layer
|
|
688
|
-
* and usable at the pack layer at the same time (proof#136).
|
|
446
|
+
* Whether a record is a conformant hand-off: in the namespace, with no
|
|
447
|
+
* defect. `./declaration.ts` uses it for evidence-bundle.md section 5.4, so a record
|
|
448
|
+
* cannot be unreadable here and usable at the pack layer.
|
|
689
449
|
*/
|
|
690
450
|
export function isConformantHandoffRecord(held) {
|
|
691
|
-
return isHandoffNamespace(held) &&
|
|
451
|
+
return isHandoffNamespace(held) && recordDefect(held) === null;
|
|
692
452
|
}
|