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