@sanning/proof 0.11.0 → 0.12.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/dist/handoff.d.ts CHANGED
@@ -126,6 +126,16 @@ export interface CandidateReading {
126
126
  ref: string;
127
127
  verdict: HandoffVerdict;
128
128
  failedChecks: string[];
129
+ /**
130
+ * The ref of the record that ACCOUNTS FOR this candidate, or `null` (4.1
131
+ * v1.17, A20). Set only on a candidate that graded `failed` and that the
132
+ * reader holds a verified pair for elsewhere; such a candidate is still
133
+ * reported here and takes no part in the subject's verdict, failed checks or
134
+ * ambiguity. Every reading this kernel returns carries it. It is optional in
135
+ * the type only so that code which builds a `CandidateReading` itself keeps
136
+ * compiling: the field is an addition.
137
+ */
138
+ accountedFor?: string | null;
129
139
  }
130
140
  export interface HandoffReading {
131
141
  ref: string;
@@ -135,7 +145,12 @@ export interface HandoffReading {
135
145
  findings: string[];
136
146
  /** Ordered, never filtered. */
137
147
  candidates: string[];
138
- /** One entry per candidate, in the same order, and nothing left out of it. */
148
+ /**
149
+ * One entry per candidate, in the same order, and nothing left out of it. A
150
+ * candidate another record accounts for is here with `accountedFor` set, and
151
+ * where every candidate is accounted for the reading is `counterpart_absent`
152
+ * with the candidates still listed.
153
+ */
139
154
  candidateReadings: CandidateReading[];
140
155
  }
141
156
  /**
package/dist/handoff.js CHANGED
@@ -1,4 +1,4 @@
1
- // The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.9).
1
+ // The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.17).
2
2
  //
3
3
  // Two agents exchange a message and each side records what it held. This module
4
4
  // is the TypeScript half of the reference reading of those two records: which
@@ -40,6 +40,9 @@
40
40
  // compared absent to absent and found them equal.
41
41
  // - A pair whose two envelope signing keys are equal does not verify.
42
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.
43
46
  //
44
47
  // THE THREE ATTACKS THE ADVERSARIAL GATE LANDED (proof#133, rulings 1 to 3),
45
48
  // and they are one root cause: candidacy and the self-hand-off check compared
@@ -116,14 +119,23 @@
116
119
  // `not_a_handoff_record` are findings reported beside a verdict rather than
117
120
  // verdicts.
118
121
  //
119
- // THE COST OF GRADING EVERY CANDIDATE, stated rather than left to be found. A
120
- // reader whose pile holds two or more records of the complementary type signed
121
- // by the counterparty key can no longer reach `verified`: every one of them is
122
- // graded and the worst of them is the reading. Two agents that exchange more
123
- // than one message land there. That is the price of never letting a value the
124
- // audited party writes decide what reaches a check, and the belt is the pack
125
- // layer: the declaration (evidence-bundle.md 5.4) says which records a pack
126
- // claims are a pair.
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.
127
139
  export const CANDIDATE_NAMESPACE = "handoff.";
128
140
  export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"];
129
141
  /**
@@ -144,7 +156,7 @@ const HEX64 = /^[0-9a-fA-F]{64}$/;
144
156
  const LOWER_HEX64 = /^[0-9a-f]{64}$/;
145
157
  // The order a reader names the checks a formed pair failed, fixed so two
146
158
  // kernels report one list rather than two orderings of it.
147
- const CHECK_ORDER = ["message_hash", "sequence", "self_handoff"];
159
+ const CHECK_ORDER = ["message_hash", "sequence", "self_handoff", "counterparty_named"];
148
160
  const VERDICT_RANK = { verified: 0, failed: 1, malformed: 2 };
149
161
  // ASCII whitespace, listed rather than delegated: `String.trim()` and Python's
150
162
  // `str.strip()` do not strip the same set (`U+FEFF` is whitespace to one and
@@ -478,6 +490,22 @@ function gradePair(subject, counterpart) {
478
490
  failed.add("message_hash");
479
491
  if (!sameNumber(subjectEvent.sequence, counterpartEvent.sequence))
480
492
  failed.add("sequence");
493
+ // Each record names the OTHER's signer (v1.16, A21). Candidacy reads the key
494
+ // the subject names and nothing the candidate says, so up to and including
495
+ // 0.11.0 a sent record addressed to C paired `verified` with B's receipt of
496
+ // it, and a receipt naming C as its sender paired `verified` with A's send.
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];
501
+ const sentNames = normalizedSigningKey(section(sent.record, "event").counterparty_public_key);
502
+ const receivedNames = normalizedSigningKey(section(received.record, "event").counterparty_public_key);
503
+ // The send names who received it.
504
+ if (sentNames !== normalizedSigningKey(received.publicKey))
505
+ failed.add("counterparty_named");
506
+ // The receipt names who sent it.
507
+ if (receivedNames !== normalizedSigningKey(sent.publicKey))
508
+ failed.add("counterparty_named");
481
509
  const checks = CHECK_ORDER.filter((name) => failed.has(name));
482
510
  return { verdict: checks.length > 0 ? "failed" : "verified", checks: [...checks] };
483
511
  }
@@ -527,21 +555,39 @@ export function readHandoff(subject, held) {
527
555
  // and took none of them out of this list.
528
556
  const graded = candidates.map((candidate) => {
529
557
  const { verdict, checks } = gradePair(subject, candidate);
530
- return { ref: candidate.ref, verdict, failedChecks: checks };
558
+ const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
559
+ return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
531
560
  });
532
- const worst = Math.max(...graded.map((g) => VERDICT_RANK[g.verdict]));
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
+ const live = graded.filter((g) => g.accountedFor === null);
564
+ if (live.length === 0) {
565
+ // Every candidate is another exchange's record, each proven so by a
566
+ // verified pair with a record the subject's own key signed: an absence,
567
+ // reported with the candidates it looked at.
568
+ return {
569
+ ref: subject.ref,
570
+ verdict: null,
571
+ failedChecks: [],
572
+ findings: ["counterpart_absent"],
573
+ candidates: candidates.map((c) => c.ref),
574
+ candidateReadings: graded,
575
+ };
576
+ }
577
+ const worst = Math.max(...live.map((g) => VERDICT_RANK[g.verdict]));
533
578
  const verdict = Object.keys(VERDICT_RANK).find((name) => VERDICT_RANK[name] === worst);
534
- // Over every candidate, not only the ones at the winning verdict: `malformed`
535
- // outranks `failed`, so a single junk record would otherwise strip the named
536
- // `message_hash` failure out of the report (proof#133 review, ruling 6).
579
+ // Over every live candidate, not only the ones at the winning verdict:
580
+ // `malformed` outranks `failed`, so a single junk record would otherwise
581
+ // strip the named `message_hash` failure out of the report (proof#133
582
+ // review, ruling 6).
537
583
  const failed = new Set();
538
- for (const g of graded)
584
+ for (const g of live)
539
585
  for (const check of g.failedChecks)
540
586
  failed.add(check);
541
587
  const findings = [];
542
588
  // Never a free choice: a reader permitted to take one match can be handed an
543
589
  // honest decoy beside a tampered record.
544
- if (candidates.length > 1)
590
+ if (live.length > 1)
545
591
  findings.push("counterpart_ambiguous");
546
592
  if (verdict === "malformed")
547
593
  findings.push("malformed_record");
@@ -554,6 +600,54 @@ export function readHandoff(subject, held) {
554
600
  candidateReadings: graded,
555
601
  };
556
602
  }
603
+ /**
604
+ * The ref of the record that accounts for a FAILED candidate, or `null` (4.1
605
+ * v1.17, A20). The candidate is another exchange's record, and not a deviation
606
+ * on this one, only where the reader holds a record `o`, other than the
607
+ * subject, for which all four hold:
608
+ *
609
+ * 1. `o` is signed by the SUBJECT's own key, and is the subject's type;
610
+ * 2. `o` names the candidate's signer, and the candidate names the subject's
611
+ * key back;
612
+ * 3. `o`'s `sequence` differs from the subject's;
613
+ * 4. the candidate grades `verified` against `o`.
614
+ *
615
+ * Condition 1 is what the audited party cannot supply: a verified pair needs
616
+ * the OTHER side's signature over the digest and the number. Condition 3 is
617
+ * the position guard (proof#129): a second record at the subject's own number
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.
621
+ */
622
+ function accountedFor(subject, candidate, held) {
623
+ const own = normalizedSigningKey(subject.publicKey);
624
+ const theirs = normalizedSigningKey(candidate.publicKey);
625
+ // Condition 2: the candidate names the subject's key back.
626
+ if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
627
+ return null;
628
+ }
629
+ const subjectSequence = section(subject.record, "event").sequence;
630
+ for (const other of held) {
631
+ if (other === subject)
632
+ continue;
633
+ // Condition 1: signed by the subject's own key, of the subject's own type.
634
+ if (normalizedSigningKey(other.publicKey) !== own)
635
+ continue;
636
+ if (eventType(other) !== eventType(subject))
637
+ continue;
638
+ // Condition 2: o names the candidate's signer.
639
+ if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
640
+ continue;
641
+ }
642
+ // Condition 3: another position than the subject's.
643
+ if (sameNumber(section(other.record, "event").sequence, subjectSequence))
644
+ continue;
645
+ // Condition 4: a verified pair.
646
+ if (gradePair(other, candidate).verdict === "verified")
647
+ return other.ref;
648
+ }
649
+ return null;
650
+ }
557
651
  /**
558
652
  * EVERY record the reader holds gets a reading, in the order given.
559
653
  *
package/dist/index.js CHANGED
@@ -77,7 +77,8 @@ export { DECLARATION_VERSION, checkDeclaration, declarationClaims, packRecords,
77
77
  // because the omission happens before any reader runs. Reading several parties'
78
78
  // packs at once is what closes it: a `handoff.sent` with no counterpart is
79
79
  // always named, and where a pack signed by the key it names is in the case, the
80
- // reading says that pack holds no counterpart. Whether a party that presented
80
+ // reading says that pack holds no counterpart (unless every candidate was
81
+ // accounted for under A20, which reads as no counterpart and names no pack). Whether a party that presented
81
82
  // no such pack recorded anything is not established (proof#143). `readCase`
82
83
  // verifies every pack itself, offline, so it is async. Matching is on keys and hashes alone and NEVER on tenancy, so a pair from two
83
84
  // organizations verifies (DL-60).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/case.ts CHANGED
@@ -185,7 +185,8 @@
185
185
  * (F2).
186
186
  * 10. **`counterpart_absent`** (`continue`). Skips the ambiguity and pair
187
187
  * reporting, and there is nothing to skip: 4.1 emits that finding only
188
- * for an EMPTY candidate list. Which of the three counterpart kinds it
188
+ * where no candidate is left to read, an EMPTY candidate list or one whose
189
+ * every candidate another record accounts for (4.1 v1.17, A20). Which of the three counterpart kinds it
189
190
  * gets reads the keys of the packs that verified, counted before exit 4,
190
191
  * and whether any pack took exit 2 or 3.
191
192
  *
@@ -225,16 +226,16 @@
225
226
  * with that verdict and its reasons, so the case never reads above its
226
227
  * weakest pack. On-chain re-fetch and `gradePack` are a caller's to run.
227
228
  *
228
- * THE COST OF 4.1's "GRADE EVERY CANDIDATE", INHERITED HERE AND STATED RATHER
229
- * THAN LEFT TO BE FOUND (proof#134, ratified by Phil 2026-09-22). A reader
230
- * whose pile holds two or more records of the complementary type signed by one
231
- * counterparty key cannot reach `verified`: the other exchanges' records are
232
- * candidates too, they commit to other messages, and they fail. One agent
233
- * sending two messages to the same counterparty inside one case lands there,
234
- * and it surfaces as `COUNTERPART_AMBIGUOUS` beside the failure. An
235
- * alternating exchange (A sends, B replies) does NOT, because a reply is
236
- * signed by the other key and is the other type. Narrowing that here with
237
- * `handoffs[]` is precisely the move that would reopen proof#137's door.
229
+ * THE COST OF 4.1's "GRADE EVERY CANDIDATE", AND WHAT IS LEFT OF IT (4.1
230
+ * v1.17, amendment A20, which reopens proof#134). Up to v1.16 a pile holding
231
+ * two or more records of the complementary type signed by one counterparty key
232
+ * could not reach `verified`. 4.1 now sets such a candidate aside where a
233
+ * record the subject's own key signed pairs with it verified at another
234
+ * sequence, so two messages in one direction, and the demo's A to B, B to A,
235
+ * A to B, read clean. What is left surfaces as `COUNTERPART_AMBIGUOUS` beside
236
+ * the failure: fan-in, fan-out, and the same pair in two cases at the same
237
+ * sequence. Narrowing any of it here with `handoffs[]` is precisely the move
238
+ * that would reopen proof#137's door.
238
239
  */
239
240
 
240
241
  import {
@@ -335,7 +336,9 @@ export const CASE_QUARANTINED_PROFILES = ["sanning.mlflow/v1"] as const;
335
336
  /**
336
337
  * A conformant hand-off record with no counterpart among the records this
337
338
  * reader read, where every pack in the case verified under a key this reader
338
- * can read and NO pack in the case is signed by the key the record names.
339
+ * can read and NO pack in the case is signed by the key the record names,
340
+ * or where every candidate it had was accounted for by another record under
341
+ * A20 (the exception then carries `count`, and names no pack).
339
342
  * **An exception, never a failure** (anchor#198, criterion 3).
340
343
  *
341
344
  * That is ALL it says. It does not say the counterparty's account is absent,
@@ -425,9 +428,11 @@ export const COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS =
425
428
  export const COUNTERPART_ABSENT_SIGNER_UNKNOWN = "counterpart_absent_signer_unknown";
426
429
 
427
430
  /**
428
- * More than one candidate counterpart (4.1, and proof#134's stated cost). The
429
- * reading is the worst of them and the ambiguity is named: a reader permitted
430
- * to take one match can be handed an honest decoy beside a tampered record.
431
+ * More than one candidate counterpart that nothing accounts for (4.1 v1.17,
432
+ * A20). The reading is the worst of them and the ambiguity is named: a reader
433
+ * permitted to take one match can be handed an honest decoy beside a tampered
434
+ * record. `count` is how many of the candidates are live, which is the number
435
+ * the ambiguity is about.
431
436
  */
432
437
  export const COUNTERPART_AMBIGUOUS = "counterpart_ambiguous";
433
438
 
@@ -793,7 +798,11 @@ export interface CaseException {
793
798
  counterpartyKey?: string | null;
794
799
  /** Which checks a formed pair failed, in 4.1's fixed order. */
795
800
  failedChecks?: string[];
796
- /** How many of a thing the exception is about, where it is about a count. */
801
+ /**
802
+ * How many of a thing the exception is about, where it is about a count. On
803
+ * `COUNTERPART_ABSENT` it is set only where 4.1 set every candidate aside
804
+ * (v1.17, A20), and it is how many were set aside.
805
+ */
797
806
  count?: number;
798
807
  /** The pack's own bundle verdict, for `PACK_BUNDLE_NOT_VERIFIED`. */
799
808
  status?: CaseVerdict;
@@ -1343,13 +1352,34 @@ export async function readCase(
1343
1352
  // that party's pack. Where some pack could not be tied to a key, "no
1344
1353
  // pack is signed by the named key" is not established and the reading
1345
1354
  // says it could not tell.
1355
+ //
1356
+ // EXCEPT where 4.1 set every candidate aside (v1.17, A20). There the
1357
+ // absence is a finding about the candidates the reader DID read, each
1358
+ // paired `verified` with another record the subject's own signer made,
1359
+ // and not a finding about any pack. 4.1 and evidence-bundle A16 name the
1360
+ // neutral kind for it, and the damning one would put the audited party's
1361
+ // own unfaithful record on the counterparty's pack as a statement
1362
+ // against it. `count` is how many candidates were set aside, which is
1363
+ // also what tells the rendered sentence which absence this is.
1364
+ const graded = reading.candidateReadings;
1365
+ const setAside =
1366
+ graded.length > 0 && graded.every((c) => c.accountedFor !== null)
1367
+ ? graded.length
1368
+ : 0;
1346
1369
  const kind =
1347
- named !== null && holderKeys.has(named)
1348
- ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1349
- : unattributed > 0
1350
- ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1351
- : COUNTERPART_ABSENT;
1352
- note(kind, { packRef, eventId, counterpartyKey: named });
1370
+ setAside > 0
1371
+ ? COUNTERPART_ABSENT
1372
+ : named !== null && holderKeys.has(named)
1373
+ ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1374
+ : unattributed > 0
1375
+ ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1376
+ : COUNTERPART_ABSENT;
1377
+ note(
1378
+ kind,
1379
+ setAside > 0
1380
+ ? { packRef, eventId, counterpartyKey: named, count: setAside }
1381
+ : { packRef, eventId, counterpartyKey: named },
1382
+ );
1353
1383
  continue;
1354
1384
  }
1355
1385
 
@@ -1357,7 +1387,7 @@ export async function readCase(
1357
1387
  note(COUNTERPART_AMBIGUOUS, {
1358
1388
  packRef,
1359
1389
  eventId,
1360
- count: reading.candidates.length,
1390
+ count: reading.candidateReadings.filter((c) => c.accountedFor === null).length,
1361
1391
  });
1362
1392
  }
1363
1393
  // ON `failedChecks` AND NOT ON THE VERDICT WORD. 4.1 keeps the UNION of
@@ -1406,6 +1436,17 @@ export function renderException(exception: CaseException): string {
1406
1436
  const where = `${exception.packRef}/${exception.eventId}`;
1407
1437
  switch (exception.kind) {
1408
1438
  case COUNTERPART_ABSENT:
1439
+ if (exception.count) {
1440
+ const which =
1441
+ exception.count === 1
1442
+ ? "its one candidate counterpart pairs"
1443
+ : `each of its ${exception.count} candidate counterparts pairs`;
1444
+ return (
1445
+ `${where} has no counterpart among the records this verifier read: ` +
1446
+ `${which} verified with another record signed by the key that ` +
1447
+ "signed this one, at a different sequence, and is set aside"
1448
+ );
1449
+ }
1409
1450
  return (
1410
1451
  `${where} has no counterpart among the records this verifier read, ` +
1411
1452
  "and no pack in this case is signed by the key it names"
@@ -1523,7 +1564,7 @@ export function renderException(exception: CaseException): string {
1523
1564
  case RECORD_PROFILE_QUARANTINED:
1524
1565
  return (
1525
1566
  `${where} is signed under a profile this case reader does not read ` +
1526
- "while its admission is undecided (DL-63), so what it says is UNKNOWN"
1567
+ "while its admission is undecided, so what it says is UNKNOWN"
1527
1568
  );
1528
1569
  case CASE_HOLDS_NO_PACKS:
1529
1570
  return "this case holds no packs";