@bcts/provenance-mark 1.0.0-alpha.17 → 1.0.0-alpha.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bcts/provenance-mark",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.18",
4
4
  "type": "module",
5
5
  "description": "Blockchain Commons Provenance Mark for TypeScript - A cryptographically-secured system for establishing and verifying the authenticity of works",
6
6
  "license": "BSD-2-Clause-Patent",
@@ -68,8 +68,8 @@
68
68
  "@bcts/eslint": "^0.1.0",
69
69
  "@bcts/tsconfig": "^0.1.0",
70
70
  "@eslint/js": "^9.39.2",
71
- "@typescript-eslint/eslint-plugin": "^8.53.1",
72
- "@typescript-eslint/parser": "^8.53.1",
71
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
72
+ "@typescript-eslint/parser": "^8.54.0",
73
73
  "eslint": "^9.39.2",
74
74
  "ts-node": "^10.9.2",
75
75
  "tsdown": "^0.20.1",
@@ -78,11 +78,11 @@
78
78
  "vitest": "^4.0.18"
79
79
  },
80
80
  "dependencies": {
81
- "@bcts/dcbor": "^1.0.0-alpha.17",
82
- "@bcts/envelope": "^1.0.0-alpha.17",
83
- "@bcts/rand": "^1.0.0-alpha.17",
84
- "@bcts/tags": "^1.0.0-alpha.17",
85
- "@bcts/uniform-resources": "^1.0.0-alpha.17",
81
+ "@bcts/dcbor": "^1.0.0-alpha.18",
82
+ "@bcts/envelope": "^1.0.0-alpha.18",
83
+ "@bcts/rand": "^1.0.0-alpha.18",
84
+ "@bcts/tags": "^1.0.0-alpha.18",
85
+ "@bcts/uniform-resources": "^1.0.0-alpha.18",
86
86
  "@noble/ciphers": "^2.1.1",
87
87
  "@noble/hashes": "^2.0.1"
88
88
  }
package/src/envelope.ts CHANGED
@@ -8,15 +8,16 @@
8
8
  * Ported from provenance-mark-rust/src/mark.rs and generator.rs (envelope feature)
9
9
  */
10
10
 
11
- import { Envelope } from "@bcts/envelope";
12
- import { type Cbor } from "@bcts/dcbor";
11
+ import {
12
+ type Envelope,
13
+ type FormatContext,
14
+ withFormatContextMut,
15
+ registerTagsIn as envelopeRegisterTagsIn,
16
+ } from "@bcts/envelope";
17
+ import { type Cbor, type SummarizerResult } from "@bcts/dcbor";
13
18
  import { PROVENANCE_MARK } from "@bcts/tags";
14
19
  import { ProvenanceMark } from "./mark.js";
15
20
  import { ProvenanceMarkGenerator } from "./generator.js";
16
- import { resolutionFromCbor, resolutionToNumber } from "./resolution.js";
17
- import { ProvenanceSeed } from "./seed.js";
18
- import { RngState } from "./rng-state.js";
19
- import { ProvenanceMarkError, ProvenanceMarkErrorType } from "./error.js";
20
21
 
21
22
  // ============================================================================
22
23
  // Tag Registration
@@ -25,40 +26,39 @@ import { ProvenanceMarkError, ProvenanceMarkErrorType } from "./error.js";
25
26
  /**
26
27
  * Registers provenance mark tags in the global format context.
27
28
  *
28
- * This function sets up a summarizer for the PROVENANCE_MARK tag that displays
29
- * provenance marks in a human-readable format.
29
+ * Matches Rust: register_tags()
30
30
  */
31
31
  export function registerTags(): void {
32
- // In TypeScript, we don't have a global format context like Rust.
33
- // Tag summarizers are typically handled at the envelope level.
34
- // This function is provided for API parity.
35
- registerTagsIn(globalTagsContext);
32
+ withFormatContextMut((context) => {
33
+ registerTagsIn(context);
34
+ });
36
35
  }
37
36
 
38
37
  /**
39
38
  * Registers provenance mark tags in a specific format context.
40
39
  *
40
+ * Matches Rust: register_tags_in()
41
+ *
41
42
  * @param context - The format context to register tags in
42
43
  */
43
- export function registerTagsIn(context: TagsContext): void {
44
- context.setSummarizer(Number(PROVENANCE_MARK.value), (cborValue: Cbor) => {
45
- const mark = ProvenanceMark.fromUntaggedCbor(cborValue);
46
- return mark.toString();
47
- });
44
+ export function registerTagsIn(context: FormatContext): void {
45
+ envelopeRegisterTagsIn(context);
46
+
47
+ context
48
+ .tags()
49
+ .setSummarizer(
50
+ BigInt(PROVENANCE_MARK.value),
51
+ (untaggedCbor: Cbor, _flat: boolean): SummarizerResult => {
52
+ try {
53
+ const mark = ProvenanceMark.fromUntaggedCbor(untaggedCbor);
54
+ return { ok: true, value: mark.toString() };
55
+ } catch {
56
+ return { ok: false, error: { type: "Custom", message: "invalid provenance mark" } };
57
+ }
58
+ },
59
+ );
48
60
  }
49
61
 
50
- // Simple tags context interface for registration
51
- export interface TagsContext {
52
- setSummarizer(tag: number, summarizer: (cbor: Cbor) => string): void;
53
- }
54
-
55
- // Global tags context (minimal implementation)
56
- const globalTagsContext: TagsContext = {
57
- setSummarizer(_tag: number, _summarizer: (cbor: Cbor) => string): void {
58
- // Tag summarizers are handled by the envelope package's format context
59
- },
60
- };
61
-
62
62
  // ============================================================================
63
63
  // ProvenanceMark Envelope Support
64
64
  // ============================================================================
@@ -66,43 +66,26 @@ const globalTagsContext: TagsContext = {
66
66
  /**
67
67
  * Convert a ProvenanceMark to an Envelope.
68
68
  *
69
- * The envelope contains the tagged CBOR representation of the mark.
69
+ * Delegates to ProvenanceMark.intoEnvelope() single source of truth.
70
70
  *
71
71
  * @param mark - The provenance mark to convert
72
72
  * @returns An envelope containing the mark
73
73
  */
74
74
  export function provenanceMarkToEnvelope(mark: ProvenanceMark): Envelope {
75
- return Envelope.new(mark.toCborData());
75
+ return mark.intoEnvelope();
76
76
  }
77
77
 
78
78
  /**
79
79
  * Extract a ProvenanceMark from an Envelope.
80
80
  *
81
+ * Delegates to ProvenanceMark.fromEnvelope() — single source of truth.
82
+ *
81
83
  * @param envelope - The envelope to extract from
82
84
  * @returns The extracted provenance mark
83
85
  * @throws ProvenanceMarkError if extraction fails
84
86
  */
85
87
  export function provenanceMarkFromEnvelope(envelope: Envelope): ProvenanceMark {
86
- // The envelope contains the CBOR-encoded bytes of the mark
87
- // Use asByteString to extract the raw bytes, then decode
88
- const bytes = envelope.asByteString();
89
- if (bytes !== undefined) {
90
- return ProvenanceMark.fromCborData(bytes);
91
- }
92
-
93
- // Try extracting from subject if it's a node
94
- const envCase = envelope.case();
95
- if (envCase.type === "node") {
96
- const subject = envCase.subject;
97
- const subjectBytes = subject.asByteString();
98
- if (subjectBytes !== undefined) {
99
- return ProvenanceMark.fromCborData(subjectBytes);
100
- }
101
- }
102
-
103
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
104
- message: "Could not extract ProvenanceMark from envelope",
105
- });
88
+ return ProvenanceMark.fromEnvelope(envelope);
106
89
  }
107
90
 
108
91
  // ============================================================================
@@ -112,121 +95,24 @@ export function provenanceMarkFromEnvelope(envelope: Envelope): ProvenanceMark {
112
95
  /**
113
96
  * Convert a ProvenanceMarkGenerator to an Envelope.
114
97
  *
115
- * The envelope contains structured assertions for all generator fields:
116
- * - type: "provenance-generator"
117
- * - res: The resolution
118
- * - seed: The seed
119
- * - next-seq: The next sequence number
120
- * - rng-state: The RNG state
98
+ * Delegates to ProvenanceMarkGenerator.intoEnvelope() single source of truth.
121
99
  *
122
100
  * @param generator - The generator to convert
123
101
  * @returns An envelope containing the generator
124
102
  */
125
103
  export function provenanceMarkGeneratorToEnvelope(generator: ProvenanceMarkGenerator): Envelope {
126
- // Create envelope with chain ID as subject
127
- let envelope = Envelope.new(generator.chainId());
128
-
129
- // Add type assertion (using addType() which uses IS_A KnownValue, like Rust's add_type())
130
- envelope = envelope.addType("provenance-generator");
131
-
132
- // Add resolution
133
- envelope = envelope.addAssertion("res", resolutionToNumber(generator.res()));
134
-
135
- // Add seed
136
- envelope = envelope.addAssertion("seed", generator.seed().toBytes());
137
-
138
- // Add next sequence number
139
- envelope = envelope.addAssertion("next-seq", generator.nextSeq());
140
-
141
- // Add RNG state
142
- envelope = envelope.addAssertion("rng-state", generator.rngState().toBytes());
143
-
144
- return envelope;
104
+ return generator.intoEnvelope();
145
105
  }
146
106
 
147
- // Type extension for envelope with extra methods
148
- type EnvelopeExt = Envelope & {
149
- asByteString(): Uint8Array | undefined;
150
- hasType(t: string): boolean;
151
- assertionsWithPredicate(p: string): Envelope[];
152
- subject(): Envelope;
153
- };
154
-
155
107
  /**
156
108
  * Extract a ProvenanceMarkGenerator from an Envelope.
157
109
  *
110
+ * Delegates to ProvenanceMarkGenerator.fromEnvelope() — single source of truth.
111
+ *
158
112
  * @param envelope - The envelope to extract from
159
113
  * @returns The extracted generator
160
114
  * @throws ProvenanceMarkError if extraction fails
161
115
  */
162
116
  export function provenanceMarkGeneratorFromEnvelope(envelope: Envelope): ProvenanceMarkGenerator {
163
- const env = envelope as EnvelopeExt;
164
-
165
- // Check type
166
- if (!env.hasType("provenance-generator")) {
167
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
168
- message: "Envelope is not a provenance-generator",
169
- });
170
- }
171
-
172
- // Extract chain ID from subject
173
- const subject = env.subject() as EnvelopeExt;
174
- const chainId = subject.asByteString();
175
- if (chainId === undefined) {
176
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
177
- message: "Could not extract chain ID",
178
- });
179
- }
180
-
181
- // Helper to extract assertion object value
182
- const extractAssertion = (predicate: string): { cbor: Cbor; bytes: Uint8Array | undefined } => {
183
- const assertions = env.assertionsWithPredicate(predicate);
184
- if (assertions.length === 0) {
185
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
186
- message: `Missing ${predicate} assertion`,
187
- });
188
- }
189
- const assertionCase = assertions[0].case();
190
- if (assertionCase.type !== "assertion") {
191
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
192
- message: `Invalid ${predicate} assertion`,
193
- });
194
- }
195
- const obj = assertionCase.assertion.object() as EnvelopeExt;
196
- const objCase = obj.case();
197
- if (objCase.type === "leaf") {
198
- return { cbor: objCase.cbor, bytes: obj.asByteString() };
199
- }
200
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
201
- message: `Invalid ${predicate} value`,
202
- });
203
- };
204
-
205
- // Extract resolution
206
- const resValue = extractAssertion("res");
207
- const res = resolutionFromCbor(resValue.cbor);
208
-
209
- // Extract seed
210
- const seedValue = extractAssertion("seed");
211
- if (seedValue.bytes === undefined) {
212
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
213
- message: "Invalid seed data",
214
- });
215
- }
216
- const seed = ProvenanceSeed.fromBytes(seedValue.bytes);
217
-
218
- // Extract next-seq
219
- const seqValue = extractAssertion("next-seq");
220
- const nextSeq = Number(seqValue.cbor);
221
-
222
- // Extract rng-state
223
- const rngValue = extractAssertion("rng-state");
224
- if (rngValue.bytes === undefined) {
225
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
226
- message: "Invalid rng-state data",
227
- });
228
- }
229
- const rngState = RngState.fromBytes(rngValue.bytes);
230
-
231
- return ProvenanceMarkGenerator.new(res, seed, chainId, nextSeq, rngState);
117
+ return ProvenanceMarkGenerator.fromEnvelope(envelope);
232
118
  }
package/src/index.ts CHANGED
@@ -97,5 +97,7 @@ export {
97
97
  provenanceMarkFromEnvelope,
98
98
  provenanceMarkGeneratorToEnvelope,
99
99
  provenanceMarkGeneratorFromEnvelope,
100
- type TagsContext,
101
100
  } from "./envelope.js";
101
+
102
+ // Re-export FormatContext for registerTagsIn callers
103
+ export { FormatContext } from "@bcts/envelope";
package/src/mark-info.ts CHANGED
@@ -74,7 +74,7 @@ export class ProvenanceMarkInfo {
74
74
  lines.push("---");
75
75
 
76
76
  lines.push("");
77
- lines.push(this._mark.date().toISOString());
77
+ lines.push(this._mark.date().toISOString().replace(".000Z", "Z"));
78
78
 
79
79
  lines.push("");
80
80
  lines.push(`#### ${this._ur.toString()}`);
package/src/mark.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  import { Envelope } from "@bcts/envelope";
15
15
 
16
16
  import { ProvenanceMarkError, ProvenanceMarkErrorType } from "./error.js";
17
+ import { validate as validateMarks } from "./validate.js";
18
+ import type { ValidationIssue, ValidationReport } from "./validate.js";
17
19
  import {
18
20
  type ProvenanceMarkResolution,
19
21
  linkLength,
@@ -286,6 +288,42 @@ export class ProvenanceMark {
286
288
  return prefix ? `\u{1F151} ${s}` : s;
287
289
  }
288
290
 
291
+ /**
292
+ * A compact 8-letter identifier derived from the upper-case ByteWords
293
+ * identifier by taking the first and last letter of each ByteWords word
294
+ * (4 words x 2 letters = 8 letters).
295
+ *
296
+ * Example: "ABLE ACID ALSO APEX" -> "AEADAOAX"
297
+ * If prefix is true, prepends the provenance mark prefix character.
298
+ */
299
+ bytewordsMinimalIdentifier(prefix: boolean): string {
300
+ const full = encodeBytewordsIdentifier(this._hash.slice(0, 4));
301
+
302
+ const words = full.split(/\s+/);
303
+ let out = "";
304
+ if (words.length === 4) {
305
+ for (const w of words) {
306
+ if (w.length === 0) continue;
307
+ out += w[0].toUpperCase();
308
+ out += w[w.length - 1].toUpperCase();
309
+ }
310
+ }
311
+
312
+ // Conservative fallback: if the input wasn't in the expected
313
+ // space-separated 4-word format, remove whitespace and chunk the
314
+ // remaining letters.
315
+ if (out.length !== 8) {
316
+ out = "";
317
+ const compact = full.replace(/[^a-zA-Z]/g, "").toUpperCase();
318
+ for (let i = 0; i + 3 < compact.length; i += 4) {
319
+ out += compact[i];
320
+ out += compact[i + 3];
321
+ }
322
+ }
323
+
324
+ return prefix ? `\u{1F151} ${out}` : out;
325
+ }
326
+
289
327
  /**
290
328
  * Get the first four bytes of the hash as Bytemoji.
291
329
  */
@@ -309,30 +347,54 @@ export class ProvenanceMark {
309
347
 
310
348
  /**
311
349
  * Check if this mark precedes another mark, throwing on validation errors.
350
+ * Errors carry a structured `validationIssue` in their details, matching Rust's
351
+ * `Error::Validation(ValidationIssue)` pattern.
312
352
  */
313
353
  precedesOpt(next: ProvenanceMark): void {
314
354
  // `next` can't be a genesis
315
355
  if (next._seq === 0) {
316
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
317
- message: "non-genesis mark at sequence 0",
318
- });
356
+ const issue: ValidationIssue = { type: "NonGenesisAtZero" };
357
+ throw new ProvenanceMarkError(
358
+ ProvenanceMarkErrorType.ValidationError,
359
+ "non-genesis mark at sequence 0",
360
+ { validationIssue: issue },
361
+ );
319
362
  }
320
363
  if (arraysEqual(next._key, next._chainId)) {
321
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
322
- message: "genesis mark must have key equal to chain_id",
323
- });
364
+ const issue: ValidationIssue = { type: "InvalidGenesisKey" };
365
+ throw new ProvenanceMarkError(
366
+ ProvenanceMarkErrorType.ValidationError,
367
+ "genesis mark must have key equal to chain_id",
368
+ { validationIssue: issue },
369
+ );
324
370
  }
325
371
  // `next` must have the next highest sequence number
326
372
  if (this._seq !== next._seq - 1) {
327
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
328
- message: `sequence gap: expected ${this._seq + 1}, got ${next._seq}`,
329
- });
373
+ const issue: ValidationIssue = {
374
+ type: "SequenceGap",
375
+ expected: this._seq + 1,
376
+ actual: next._seq,
377
+ };
378
+ throw new ProvenanceMarkError(
379
+ ProvenanceMarkErrorType.ValidationError,
380
+ `sequence gap: expected ${this._seq + 1}, got ${next._seq}`,
381
+ { validationIssue: issue },
382
+ );
330
383
  }
331
384
  // `next` must have an equal or later date
332
385
  if (this._date > next._date) {
333
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
334
- message: `date ordering: ${this._date.toISOString()} > ${next._date.toISOString()}`,
335
- });
386
+ const dateStr = this._date.toISOString().replace(".000Z", "Z");
387
+ const nextDateStr = next._date.toISOString().replace(".000Z", "Z");
388
+ const issue: ValidationIssue = {
389
+ type: "DateOrdering",
390
+ previous: dateStr,
391
+ next: nextDateStr,
392
+ };
393
+ throw new ProvenanceMarkError(
394
+ ProvenanceMarkErrorType.ValidationError,
395
+ `date ordering: ${dateStr} > ${nextDateStr}`,
396
+ { validationIssue: issue },
397
+ );
336
398
  }
337
399
  // `next` must reveal the key that was used to generate this mark's hash
338
400
  const expectedHash = ProvenanceMark.makeHash(
@@ -345,15 +407,16 @@ export class ProvenanceMark {
345
407
  this._infoBytes,
346
408
  );
347
409
  if (!arraysEqual(this._hash, expectedHash)) {
348
- throw new ProvenanceMarkError(ProvenanceMarkErrorType.ValidationError, undefined, {
349
- message: "hash mismatch",
350
- expected: Array.from(expectedHash)
351
- .map((b) => b.toString(16).padStart(2, "0"))
352
- .join(""),
353
- actual: Array.from(this._hash)
354
- .map((b) => b.toString(16).padStart(2, "0"))
355
- .join(""),
356
- });
410
+ const issue: ValidationIssue = {
411
+ type: "HashMismatch",
412
+ expected: bytesToHex(expectedHash),
413
+ actual: bytesToHex(this._hash),
414
+ };
415
+ throw new ProvenanceMarkError(
416
+ ProvenanceMarkErrorType.ValidationError,
417
+ `hash mismatch: expected ${bytesToHex(expectedHash)}, got ${bytesToHex(this._hash)}`,
418
+ { validationIssue: issue },
419
+ );
357
420
  }
358
421
  }
359
422
 
@@ -617,6 +680,20 @@ export class ProvenanceMark {
617
680
  return new ProvenanceMark(res, key, hash, chainId, seqBytes, dateBytes, infoBytes, seq, date);
618
681
  }
619
682
 
683
+ // ============================================================================
684
+ // Validation (delegate to ValidationReport)
685
+ // ============================================================================
686
+
687
+ /**
688
+ * Validate a collection of provenance marks.
689
+ *
690
+ * Matches Rust: `ProvenanceMark::validate()` which delegates to
691
+ * `ValidationReport::validate()`.
692
+ */
693
+ static validate(marks: ProvenanceMark[]): ValidationReport {
694
+ return validateMarks(marks);
695
+ }
696
+
620
697
  // ============================================================================
621
698
  // Envelope Support (EnvelopeEncodable)
622
699
  // ============================================================================
@@ -624,37 +701,27 @@ export class ProvenanceMark {
624
701
  /**
625
702
  * Convert this provenance mark to a Gordian Envelope.
626
703
  *
627
- * The envelope contains the tagged CBOR representation of the mark.
628
- *
629
- * Note: Use provenanceMarkToEnvelope() for a standalone function alternative.
704
+ * Creates a leaf envelope containing the tagged CBOR representation.
705
+ * Matches Rust: `Envelope::new(mark.to_cbor())` which creates a CBOR leaf.
630
706
  */
631
707
  intoEnvelope(): Envelope {
632
- return Envelope.new(this.toCborData());
708
+ return Envelope.newLeaf(this.taggedCbor());
633
709
  }
634
710
 
635
711
  /**
636
712
  * Extract a ProvenanceMark from a Gordian Envelope.
637
713
  *
714
+ * Matches Rust: `envelope.subject().try_leaf()?.try_into()`
715
+ *
638
716
  * @param envelope - The envelope to extract from
639
717
  * @returns The extracted provenance mark
640
718
  * @throws ProvenanceMarkError if extraction fails
641
719
  */
642
720
  static fromEnvelope(envelope: Envelope): ProvenanceMark {
643
- // The envelope contains the CBOR-encoded bytes of the mark
644
- // Use asByteString to extract the raw bytes, then decode
645
- const bytes = envelope.asByteString();
646
- if (bytes !== undefined) {
647
- return ProvenanceMark.fromCborData(bytes);
648
- }
649
-
650
- // Try extracting from subject if it's a node
651
- const envCase = envelope.case();
652
- if (envCase.type === "node") {
653
- const subject = envCase.subject;
654
- const subjectBytes = subject.asByteString();
655
- if (subjectBytes !== undefined) {
656
- return ProvenanceMark.fromCborData(subjectBytes);
657
- }
721
+ // Extract the CBOR leaf from the envelope subject, matching Rust's try_leaf()
722
+ const leaf = envelope.subject().asLeaf();
723
+ if (leaf !== undefined) {
724
+ return ProvenanceMark.fromTaggedCbor(leaf);
658
725
  }
659
726
 
660
727
  throw new ProvenanceMarkError(ProvenanceMarkErrorType.CborError, undefined, {
package/src/validate.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Ported from provenance-mark-rust/src/validate.rs
2
2
 
3
3
  import type { ProvenanceMark } from "./mark.js";
4
+ import { ProvenanceMarkError } from "./error.js";
4
5
 
5
6
  /**
6
7
  * Format for validation report output.
@@ -257,23 +258,47 @@ export function formatReport(report: ValidationReport, format: ValidationReportF
257
258
  */
258
259
  function reportToJSON(report: ValidationReport): unknown {
259
260
  return {
260
- marks: report.marks.map((m) => m.toUrlEncoding()),
261
+ marks: report.marks.map((m) => m.urString()),
261
262
  chains: report.chains.map((chain) => ({
262
263
  chain_id: hexEncode(chain.chainId),
263
264
  has_genesis: chain.hasGenesis,
264
- marks: chain.marks.map((m) => m.toUrlEncoding()),
265
+ marks: chain.marks.map((m) => m.urString()),
265
266
  sequences: chain.sequences.map((seq) => ({
266
267
  start_seq: seq.startSeq,
267
268
  end_seq: seq.endSeq,
268
269
  marks: seq.marks.map((fm) => ({
269
- mark: fm.mark.toUrlEncoding(),
270
- issues: fm.issues,
270
+ mark: fm.mark.urString(),
271
+ issues: fm.issues.map(issueToJSON),
271
272
  })),
272
273
  })),
273
274
  })),
274
275
  };
275
276
  }
276
277
 
278
+ /**
279
+ * Convert a ValidationIssue to JSON matching Rust's serde format.
280
+ *
281
+ * Rust uses `#[serde(tag = "type", content = "data")]` which wraps
282
+ * struct variant data in a `"data"` field. Unit variants have no
283
+ * `"data"` field.
284
+ */
285
+ function issueToJSON(issue: ValidationIssue): unknown {
286
+ switch (issue.type) {
287
+ case "HashMismatch":
288
+ return { type: "HashMismatch", data: { expected: issue.expected, actual: issue.actual } };
289
+ case "SequenceGap":
290
+ return { type: "SequenceGap", data: { expected: issue.expected, actual: issue.actual } };
291
+ case "DateOrdering":
292
+ return { type: "DateOrdering", data: { previous: issue.previous, next: issue.next } };
293
+ case "KeyMismatch":
294
+ return { type: "KeyMismatch" };
295
+ case "NonGenesisAtZero":
296
+ return { type: "NonGenesisAtZero" };
297
+ case "InvalidGenesisKey":
298
+ return { type: "InvalidGenesisKey" };
299
+ }
300
+ }
301
+
277
302
  /**
278
303
  * Build sequence bins for a chain.
279
304
  */
@@ -301,8 +326,14 @@ function buildSequenceBins(marks: ProvenanceMark[]): SequenceReport[] {
301
326
  sequences.push(createSequenceReport(currentSequence));
302
327
  }
303
328
 
304
- // Parse the error to determine the issue type
305
- const issue = parseValidationError(e, prev, mark);
329
+ // Extract structured issue directly from the error
330
+ // Matches Rust: Error::Validation(v) => v, _ => ValidationIssue::KeyMismatch
331
+ let issue: ValidationIssue;
332
+ if (e instanceof ProvenanceMarkError && e.details?.["validationIssue"] !== undefined) {
333
+ issue = e.details["validationIssue"] as ValidationIssue;
334
+ } else {
335
+ issue = { type: "KeyMismatch" }; // Fallback
336
+ }
306
337
 
307
338
  // Start new sequence with this mark, flagged with the issue
308
339
  currentSequence = [{ mark, issues: [issue] }];
@@ -318,53 +349,6 @@ function buildSequenceBins(marks: ProvenanceMark[]): SequenceReport[] {
318
349
  return sequences;
319
350
  }
320
351
 
321
- /**
322
- * Parse a validation error into a ValidationIssue.
323
- */
324
- function parseValidationError(
325
- e: unknown,
326
- prev: ProvenanceMark,
327
- next: ProvenanceMark,
328
- ): ValidationIssue {
329
- const message = e instanceof Error ? e.message : "";
330
-
331
- if (message !== "" && message.includes("non-genesis mark at sequence 0")) {
332
- return { type: "NonGenesisAtZero" };
333
- }
334
- if (message !== "" && message.includes("genesis mark must have key equal to chain_id")) {
335
- return { type: "InvalidGenesisKey" };
336
- }
337
- if (message !== "" && message.includes("sequence gap")) {
338
- const seqGapRegex = /expected (\d+), got (\d+)/;
339
- const match = seqGapRegex.exec(message);
340
- if (match !== null) {
341
- return {
342
- type: "SequenceGap",
343
- expected: parseInt(match[1], 10),
344
- actual: parseInt(match[2], 10),
345
- };
346
- }
347
- }
348
- if (message !== "" && message.includes("date ordering")) {
349
- return {
350
- type: "DateOrdering",
351
- previous: prev.date().toISOString(),
352
- next: next.date().toISOString(),
353
- };
354
- }
355
- if (message !== "" && message.includes("hash mismatch")) {
356
- const hashRegex = /expected: (\w+), actual: (\w+)/;
357
- const match = hashRegex.exec(message);
358
- if (match !== null) {
359
- return { type: "HashMismatch", expected: match[1], actual: match[2] };
360
- }
361
- return { type: "HashMismatch", expected: "", actual: "" };
362
- }
363
-
364
- // Fallback
365
- return { type: "KeyMismatch" };
366
- }
367
-
368
352
  /**
369
353
  * Create a sequence report from flagged marks.
370
354
  */
@@ -379,10 +363,11 @@ function createSequenceReport(marks: FlaggedMark[]): SequenceReport {
379
363
  */
380
364
  export function validate(marks: ProvenanceMark[]): ValidationReport {
381
365
  // Deduplicate exact duplicates
366
+ // Matches Rust semantics: PartialEq compares (res, message())
382
367
  const seen = new Set<string>();
383
368
  const deduplicatedMarks: ProvenanceMark[] = [];
384
369
  for (const mark of marks) {
385
- const key = mark.toUrlEncoding();
370
+ const key = `${mark.res()}:${hexEncode(mark.message())}`;
386
371
  if (!seen.has(key)) {
387
372
  seen.add(key);
388
373
  deduplicatedMarks.push(mark);