@sanning/proof 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +43 -8
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +199 -43
  7. package/dist/case.js +522 -146
  8. package/dist/cli.d.ts +132 -1
  9. package/dist/cli.js +1140 -96
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/handoff.d.ts +16 -1
  21. package/dist/handoff.js +91 -16
  22. package/dist/index.d.ts +10 -7
  23. package/dist/index.js +15 -7
  24. package/dist/message-parts.d.ts +11 -9
  25. package/dist/message-parts.js +11 -9
  26. package/dist/types.d.ts +1 -0
  27. package/dist/verifier.d.ts +9 -2
  28. package/dist/verifier.js +28 -12
  29. package/dist/zip-reader.d.ts +50 -0
  30. package/dist/zip-reader.js +220 -0
  31. package/package.json +2 -2
  32. package/src/agent-proof.ts +4 -4
  33. package/src/case-file.ts +419 -0
  34. package/src/case.ts +651 -156
  35. package/src/cli.ts +1325 -100
  36. package/src/crypto.ts +5 -1
  37. package/src/declaration.ts +248 -52
  38. package/src/evidence.ts +142 -21
  39. package/src/exit-codes.ts +54 -0
  40. package/src/grading.ts +109 -52
  41. package/src/handoff.ts +108 -17
  42. package/src/index.ts +60 -3
  43. package/src/message-parts.ts +11 -9
  44. package/src/types.ts +5 -0
  45. package/src/verifier.ts +35 -13
  46. package/src/zip-reader.ts +229 -0
  47. package/vectors/README.md +56 -3
  48. package/vectors/case-file-index.json +668 -0
  49. package/vectors/case-split.json +1102 -0
  50. package/vectors/pack-grading.json +68 -4
  51. package/vectors/stamp-on-chain.json +3 -3
package/src/handoff.ts 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
@@ -119,14 +119,23 @@
119
119
  // `not_a_handoff_record` are findings reported beside a verdict rather than
120
120
  // verdicts.
121
121
  //
122
- // THE COST OF GRADING EVERY CANDIDATE, stated rather than left to be found. A
123
- // reader whose pile holds two or more records of the complementary type signed
124
- // by the counterparty key can no longer reach `verified`: every one of them is
125
- // graded and the worst of them is the reading. Two agents that exchange more
126
- // than one message land there. That is the price of never letting a value the
127
- // audited party writes decide what reaches a check, and the belt is the pack
128
- // layer: the declaration (evidence-bundle.md 5.4) says which records a pack
129
- // 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.
130
139
 
131
140
  export const CANDIDATE_NAMESPACE = "handoff.";
132
141
  export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
@@ -315,6 +324,16 @@ export interface CandidateReading {
315
324
  ref: string;
316
325
  verdict: HandoffVerdict;
317
326
  failedChecks: string[];
327
+ /**
328
+ * The ref of the record that ACCOUNTS FOR this candidate, or `null` (4.1
329
+ * v1.17, A20). Set only on a candidate that graded `failed` and that the
330
+ * reader holds a verified pair for elsewhere; such a candidate is still
331
+ * reported here and takes no part in the subject's verdict, failed checks or
332
+ * ambiguity. Every reading this kernel returns carries it. It is optional in
333
+ * the type only so that code which builds a `CandidateReading` itself keeps
334
+ * compiling: the field is an addition.
335
+ */
336
+ accountedFor?: string | null;
318
337
  }
319
338
 
320
339
  export interface HandoffReading {
@@ -325,7 +344,12 @@ export interface HandoffReading {
325
344
  findings: string[];
326
345
  /** Ordered, never filtered. */
327
346
  candidates: string[];
328
- /** One entry per candidate, in the same order, and nothing left out of it. */
347
+ /**
348
+ * One entry per candidate, in the same order, and nothing left out of it. A
349
+ * candidate another record accounts for is here with `accountedFor` set, and
350
+ * where every candidate is accounted for the reading is `counterpart_absent`
351
+ * with the candidates still listed.
352
+ */
329
353
  candidateReadings: CandidateReading[];
330
354
  }
331
355
 
@@ -620,23 +644,42 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
620
644
  // and took none of them out of this list.
621
645
  const graded: CandidateReading[] = candidates.map((candidate) => {
622
646
  const { verdict, checks } = gradePair(subject, candidate);
623
- return { ref: candidate.ref, verdict, failedChecks: checks };
647
+ const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
648
+ return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
624
649
  });
625
- const worst = Math.max(...graded.map((g) => VERDICT_RANK[g.verdict]));
650
+
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
+ const live = graded.filter((g) => g.accountedFor === null);
654
+ if (live.length === 0) {
655
+ // Every candidate is another exchange's record, each proven so by a
656
+ // verified pair with a record the subject's own key signed: an absence,
657
+ // reported with the candidates it looked at.
658
+ return {
659
+ ref: subject.ref,
660
+ verdict: null,
661
+ failedChecks: [],
662
+ findings: ["counterpart_absent"],
663
+ candidates: candidates.map((c) => c.ref),
664
+ candidateReadings: graded,
665
+ };
666
+ }
667
+ const worst = Math.max(...live.map((g) => VERDICT_RANK[g.verdict]));
626
668
  const verdict = (Object.keys(VERDICT_RANK).find(
627
669
  (name) => VERDICT_RANK[name] === worst,
628
670
  ) as HandoffVerdict)!;
629
671
 
630
- // Over every candidate, not only the ones at the winning verdict: `malformed`
631
- // outranks `failed`, so a single junk record would otherwise strip the named
632
- // `message_hash` failure out of the report (proof#133 review, ruling 6).
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).
633
676
  const failed = new Set<string>();
634
- for (const g of graded) for (const check of g.failedChecks) failed.add(check);
677
+ for (const g of live) for (const check of g.failedChecks) failed.add(check);
635
678
 
636
679
  const findings: string[] = [];
637
680
  // Never a free choice: a reader permitted to take one match can be handed an
638
681
  // honest decoy beside a tampered record.
639
- if (candidates.length > 1) findings.push("counterpart_ambiguous");
682
+ if (live.length > 1) findings.push("counterpart_ambiguous");
640
683
  if (verdict === "malformed") findings.push("malformed_record");
641
684
 
642
685
  return {
@@ -649,6 +692,54 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
649
692
  };
650
693
  }
651
694
 
695
+ /**
696
+ * The ref of the record that accounts for a FAILED candidate, or `null` (4.1
697
+ * v1.17, A20). The candidate is another exchange's record, and not a deviation
698
+ * on this one, only where the reader holds a record `o`, other than the
699
+ * subject, for which all four hold:
700
+ *
701
+ * 1. `o` is signed by the SUBJECT's own key, and is the subject's type;
702
+ * 2. `o` names the candidate's signer, and the candidate names the subject's
703
+ * key back;
704
+ * 3. `o`'s `sequence` differs from the subject's;
705
+ * 4. the candidate grades `verified` against `o`.
706
+ *
707
+ * Condition 1 is what the audited party cannot supply: a verified pair needs
708
+ * the OTHER side's signature over the digest and the number. Condition 3 is
709
+ * the position guard (proof#129): a second record at the subject's own number
710
+ * is a second claim about the subject's message. The first `o` in the order
711
+ * the records are held is reported, so two kernels name one record. The
712
+ * Python half, `_accounted_for`, carries the full reasoning.
713
+ */
714
+ function accountedFor(
715
+ subject: HeldRecord,
716
+ candidate: HeldRecord,
717
+ held: readonly HeldRecord[],
718
+ ): string | null {
719
+ const own = normalizedSigningKey(subject.publicKey);
720
+ const theirs = normalizedSigningKey(candidate.publicKey);
721
+ // Condition 2: the candidate names the subject's key back.
722
+ if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
723
+ return null;
724
+ }
725
+ const subjectSequence = section(subject.record, "event").sequence;
726
+ for (const other of held) {
727
+ if (other === subject) continue;
728
+ // Condition 1: signed by the subject's own key, of the subject's own type.
729
+ if (normalizedSigningKey(other.publicKey) !== own) continue;
730
+ if (eventType(other) !== eventType(subject)) continue;
731
+ // Condition 2: o names the candidate's signer.
732
+ if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
733
+ continue;
734
+ }
735
+ // Condition 3: another position than the subject's.
736
+ if (sameNumber(section(other.record, "event").sequence, subjectSequence)) continue;
737
+ // Condition 4: a verified pair.
738
+ if (gradePair(other, candidate).verdict === "verified") return other.ref;
739
+ }
740
+ return null;
741
+ }
742
+
652
743
  /**
653
744
  * EVERY record the reader holds gets a reading, in the order given.
654
745
  *
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // gateway/transport concerns are consumer-layer logic composed above it.
13
13
 
14
14
  // ACCEPTED_SPEC_MAJORS is exported for parity with the sibling kernels'
15
- // registries (Python ACCEPTED_SPEC_VERSIONS, Go SupportedSpecMajor): a consumer
15
+ // registries (Python ACCEPTED_SPEC_MAJORS, Go SupportedSpecMajor): a consumer
16
16
  // can see which profiles this build admits without inferring it from probe
17
17
  // calls. It is frozen at its definition (`Object.freeze`, typed
18
18
  // `readonly string[]`) because the verifier reads that same array — an exported
@@ -60,6 +60,7 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
60
60
  // bytes, and only for events whose verification bound them to the committed
61
61
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
62
62
  export {
63
+ ACCEPTED_EVIDENCE_MAJORS,
63
64
  ANCHOR_TRACE_BODY_TYPE,
64
65
  EXPORT_BODY_TYPE,
65
66
  STAMP_BODY_VERSION,
@@ -128,6 +129,8 @@ export type {
128
129
  // derivation to it, and diff its copy against ours. Additive — no existing
129
130
  // export changes.
130
131
  export {
132
+ PACK_GRADE_CAUSE_KINDS,
133
+ PACK_VERDICTS,
131
134
  accountDisclosure,
132
135
  completenessNotes,
133
136
  gradeCauses,
@@ -162,7 +165,14 @@ export type {
162
165
  // optional fourth argument, so a surface gets one verdict; omitting it is the
163
166
  // pre-§5.4 behavior, unchanged. Additive: no existing export changes.
164
167
  export {
168
+ DECLARATION_CONTENT_KINDS,
169
+ DECLARATION_DEVIATION_KINDS,
170
+ DECLARATION_NOTE_KINDS,
171
+ DECLARATION_ROLES,
172
+ DECLARATION_VERDICTS,
165
173
  DECLARATION_VERSION,
174
+ DECLARATION_VERSION_V2,
175
+ DECLARATION_VERSIONS,
166
176
  checkDeclaration,
167
177
  declarationClaims,
168
178
  packRecords,
@@ -180,6 +190,7 @@ export type {
180
190
  DeclarationDeviationKind,
181
191
  DeclarationNote,
182
192
  DeclarationNoteKind,
193
+ DeclarationRole,
183
194
  DeclarationVerdict,
184
195
  PackRecord,
185
196
  } from "./declaration.js";
@@ -193,7 +204,8 @@ export type {
193
204
  // because the omission happens before any reader runs. Reading several parties'
194
205
  // packs at once is what closes it: a `handoff.sent` with no counterpart is
195
206
  // always named, and where a pack signed by the key it names is in the case, the
196
- // reading says that pack holds no counterpart. Whether a party that presented
207
+ // reading says that pack holds no counterpart (unless every candidate was
208
+ // accounted for under A20, which reads as no counterpart and names no pack). Whether a party that presented
197
209
  // no such pack recorded anything is not established (proof#143). `readCase`
198
210
  // 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
199
211
  // organizations verifies (DL-60).
@@ -205,20 +217,53 @@ export {
205
217
  CASE_EXCEPTION_KINDS,
206
218
  CASE_QUARANTINED_PROFILES,
207
219
  CASE_RECORD_PROFILES,
220
+ CASE_NOTE_KINDS,
221
+ CASE_RELIANCE,
208
222
  CASE_VERDICTS,
223
+ CONTENT_WITHHELD_BY_DECLARATION,
224
+ VERDICT_DISPLAY_TITLES,
225
+ WITNESS_NOT_LOOKED_UP,
226
+ WITNESS_STATES,
209
227
  readCase,
228
+ renderCaseNote,
210
229
  renderCaseSummary,
211
230
  renderException,
231
+ renderWitnessLine,
212
232
  } from "./case.js";
213
233
  export type {
214
234
  CaseException,
215
235
  CaseExceptionKind,
236
+ CaseNote,
237
+ CaseNoteKind,
216
238
  CasePack,
239
+ CasePackReading,
217
240
  CaseReading,
218
241
  CaseRecordReading,
219
242
  CaseVerdict,
243
+ WitnessLine,
244
+ WitnessState,
220
245
  } from "./case.js";
221
246
 
247
+ // The case file's index (src/case-file.ts, evidence-bundle.md §5.6, A25):
248
+ // compared with the drop it describes, with NO verdict contribution. The index
249
+ // is never an input to `readCase`. Pure: bytes and a file list in.
250
+ export {
251
+ CASE_FILE_FINDING_KINDS,
252
+ CASE_FILE_INDEX_NAME,
253
+ CASE_FILE_VERSION,
254
+ HOW_TO_VERIFY_NAME,
255
+ REPORT_README_FIRST_LINE,
256
+ howToVerifyText,
257
+ isPackRef,
258
+ readCaseFileIndex,
259
+ renderCaseFileFinding,
260
+ } from "./case-file.js";
261
+ export type {
262
+ CaseFileFinding,
263
+ CaseFileFindingKind,
264
+ CaseFileIndexReading,
265
+ } from "./case-file.js";
266
+
222
267
  // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
223
268
  // record carries to the record before it, over the records ONE bundle presents.
224
269
  //
@@ -257,13 +302,25 @@ export type {
257
302
  // signed, offline-verifiable sanning.evidence.export/v2 artifact from a source
258
303
  // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
259
304
  // inverse of verifyEvidenceBundle's verifyExportBody dispatch.
305
+ // One exit-code table for every verb (0.13.0): the verdict word to the exit
306
+ // code, and the help text every verb prints. Additive.
307
+ export {
308
+ EXIT_CODES,
309
+ EXIT_CODES_HELP,
310
+ EXIT_FAILED,
311
+ EXIT_MALFORMED,
312
+ EXIT_MIXED,
313
+ EXIT_UNDETERMINED,
314
+ EXIT_VERIFIED,
315
+ exitCodeFor,
316
+ } from "./exit-codes.js";
260
317
  export { attestationSignatureToHex, composeExport } from "./compose.js";
261
318
  export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
262
319
 
263
320
  // Agent inclusion-proof bundle verification: the
264
321
  // sanning.agent.proof/v1 bundle. The CLI sniffs spec_version and routes here so
265
322
  // one verifier covers both agent and anchor bundles.
266
- export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
323
+ export { AGENT_PROOF_SPEC_VERSION, isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
267
324
  export type {
268
325
  AgentProofBundle,
269
326
  AgentProofResult,
@@ -278,17 +278,19 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
278
278
 
279
279
  /**
280
280
  * The keys that make a part carry its content INLINE, in the order they are
281
- * read. Named rather than inferred: `bytes` is A2A's spelling of an inline file
282
- * part, and `data` and `content` are the two other spellings in the wild. The
283
- * FIRST of them present with a value that is not `null` is the inline content,
284
- * so a part spelling it twice reads one way in both kernels.
281
+ * read. Named rather than inferred: `bytes`, `data` and `content` are the
282
+ * spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
283
+ * carries a file as `raw` or `url`, so a literal A2A part reads
284
+ * `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
285
+ * a value that is not `null` is the inline content, so a part spelling it
286
+ * twice reads one way in both kernels.
285
287
  *
286
288
  * A key present with `null` is an ABSENT inline value, on A14's ruling that an
287
- * absent value and one written as `null` name one thing. That matters: an A2A
288
- * `FilePart` with `uri` set carries `"bytes": null`, and so does proto3 JSON,
289
- * and reading those as inline made a conformant DESCRIPTOR part uncheckable
290
- * (proof#156 gate). They fall through to the descriptor rule, which is what
291
- * they are.
289
+ * absent value and one written as `null` name one thing. That matters: a
290
+ * proto3 JSON encoder can write an unset field as `"bytes": null`, and reading
291
+ * that as inline made a conformant DESCRIPTOR part uncheckable
292
+ * (proof#156 gate). Such a part falls through to the descriptor rule, which is
293
+ * what it is.
292
294
  */
293
295
  // A conformant DESCRIPTOR part that also carries a `content` or `data` key
294
296
  // meaning something else (a filename, a metadata blob) is reclassified by this
package/src/types.ts CHANGED
@@ -48,6 +48,11 @@ export interface VerifyOptions {
48
48
  // Content bind: the caller's own hash of an artifact (e.g. the in-browser
49
49
  // hash of a user's file) to match against the hashes the envelope commits to.
50
50
  expectedContentHash?: string;
51
+ // NARROWS the accept-set for this call and never widens it: the set used is
52
+ // `ACCEPTED_SPEC_MAJORS` intersected with this list, so a caller cannot
53
+ // admit a profile this kernel does not. The Python twin is
54
+ // `accepted_spec_majors` (P-10, 0.13.0).
55
+ acceptedSpecMajors?: readonly string[];
51
56
  }
52
57
 
53
58
  export interface VerificationResult {
package/src/verifier.ts CHANGED
@@ -23,7 +23,7 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
23
23
  // profile-specific dialect (no underscore strip, that is mlflow-only).
24
24
  //
25
25
  // Exported for parity with the sibling kernels' registries — Python
26
- // `ACCEPTED_SPEC_VERSIONS`, Go `SupportedSpecMajor` — so the accept-set is
26
+ // `ACCEPTED_SPEC_MAJORS`, Go `SupportedSpecMajor` — so the accept-set is
27
27
  // assertable rather than inferable: test/accept-set.test.ts holds it to the
28
28
  // ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
29
29
  // cross-kernel gate probes one signed envelope per identifier. This entry list
@@ -231,6 +231,16 @@ const JCS_BOM = "";
231
231
  * Throws for anything that is not conformant; every call site treats a throw as
232
232
  * "this reader cannot read these bytes" and names the check that failed.
233
233
  */
234
+ /**
235
+ * Bytes as strict UTF-8, a leading byte-order mark KEPT as a character.
236
+ * `utf8_strict` in the Python twin's `canonicalize.py` is the same rule, and
237
+ * `jcsParse` below performs the identical decode. Throws on bytes that are not
238
+ * UTF-8.
239
+ */
240
+ export function utf8Strict(bytes: Uint8Array): string {
241
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
242
+ }
243
+
234
244
  export function jcsParse(bytes: Uint8Array): unknown {
235
245
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
236
246
  if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
@@ -311,9 +321,17 @@ function hasLoneSurrogate(s: string): boolean {
311
321
  // `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
312
322
  // malformed, not a tolerated future version — matching the Python kernel's
313
323
  // 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
314
- export function specVersionSupported(specVersion: string): boolean {
324
+ //
325
+ // `accepted` NARROWS the registry and never widens it, as the Python twin's
326
+ // `spec_version_supported(spec_version, accepted)` does (P-10, 0.13.0).
327
+ export function specVersionSupported(
328
+ specVersion: string,
329
+ accepted?: readonly string[],
330
+ ): boolean {
315
331
  if (typeof specVersion !== "string" || specVersion === "") return false;
316
- return ACCEPTED_SPEC_MAJORS.some((m) => {
332
+ const registry =
333
+ accepted === undefined ? ACCEPTED_SPEC_MAJORS : ACCEPTED_SPEC_MAJORS.filter((m) => accepted.includes(m));
334
+ return registry.some((m) => {
317
335
  if (specVersion === m) return true;
318
336
  if (!specVersion.startsWith(`${m}.`)) return false;
319
337
  const minor = specVersion.slice(m.length + 1);
@@ -456,22 +474,26 @@ export function contentHashes(env: Envelope): { role: ContentRole; hash: string
456
474
  // signature, so stripping it to fake "external" — or injecting one to fake
457
475
  // "inline" — breaks the signature).
458
476
  //
459
- // The second argument accepts the legacy `expectedContentHash` string form
460
- // or a VerifyOptions object ({ payloadBytes, expectedContentHash }).
477
+ // The second argument is a VerifyOptions object ({ payloadBytes,
478
+ // expectedContentHash, acceptedSpecMajors }). Until 0.13.0 it could also be a
479
+ // bare `expectedContentHash` string; that overload is gone (P-10), so the two
480
+ // kernels take one options shape: write `{ expectedContentHash: hash }`.
461
481
  export async function verifyEnvelope(
462
482
  env: Envelope,
463
- optionsOrContentHash?: string | VerifyOptions,
483
+ options?: VerifyOptions,
464
484
  ): Promise<VerificationResult> {
465
- const opts: VerifyOptions =
466
- typeof optionsOrContentHash === "string"
467
- ? { expectedContentHash: optionsOrContentHash }
468
- : (optionsOrContentHash ?? {});
485
+ if (typeof options === "string") {
486
+ throw new TypeError(
487
+ "verifyEnvelope: pass { expectedContentHash } rather than a bare string (0.13.0)",
488
+ );
489
+ }
490
+ const opts: VerifyOptions = options ?? {};
469
491
  // REFUSE an unknown option key rather than ignore it. `expected_content_hash`
470
492
  // — the Python kernel's spelling — previously fell through as `undefined`, so
471
493
  // the content bind silently never ran and the caller got a clean pass with no
472
494
  // errors at all. A check that quietly does not happen is worse than one that
473
495
  // fails: the caller believes they verified an artifact they never checked.
474
- const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
496
+ const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash", "acceptedSpecMajors"]);
475
497
  const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
476
498
  if (unknownOptions.length > 0) {
477
499
  throw new TypeError(
@@ -551,7 +573,7 @@ async function verifyEnvelopeChecked(
551
573
  };
552
574
  }
553
575
 
554
- const specVersionOk = specVersionSupported(env.spec_version);
576
+ const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
555
577
  if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
556
578
 
557
579
  // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
@@ -625,7 +647,7 @@ async function verifyEnvelopeChecked(
625
647
  let signatureOk = false;
626
648
  try {
627
649
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
628
- signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
650
+ signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
629
651
  if (!signatureOk) errors.push("Ed25519 signature verification failed");
630
652
  } catch (e) {
631
653
  errors.push(`signature verification error: ${stringifyErr(e)}`);