@mikeargento/bitgraph 1.4.0 → 1.6.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/src/fuse.ts CHANGED
@@ -46,8 +46,11 @@ import {
46
46
  computeSlotRecordHash,
47
47
  fuseAttribution,
48
48
  getPlacement,
49
+ parseSetManifest,
49
50
  readSetMetadata,
50
51
  SET_METADATA_KEY,
52
+ TRAILER_LENGTH,
53
+ TRAILER_MAGIC,
51
54
  verifyFuse,
52
55
  verifyFuseMember,
53
56
  base64ToBytes,
@@ -65,6 +68,25 @@ import type {
65
68
  SlotAllocation,
66
69
  } from "@mikeargento/bitgraph-verify";
67
70
 
71
+ /**
72
+ * SHA-256 over bytes: the platform's native hasher when one is present
73
+ * (WebCrypto, in browsers and in Node), else the JavaScript library. The
74
+ * native path runs about ten times faster over large files and both give
75
+ * the same digest; a test pins that. A platform that refuses the input (a
76
+ * shared or detached buffer) falls back to the library.
77
+ */
78
+ export async function digest(bytes: Uint8Array): Promise<Uint8Array> {
79
+ const subtle = (globalThis as { crypto?: { subtle?: { digest?: (alg: string, data: Uint8Array) => Promise<ArrayBuffer> } } }).crypto?.subtle;
80
+ if (subtle !== undefined && typeof subtle.digest === "function") {
81
+ try {
82
+ return new Uint8Array(await subtle.digest("SHA-256", bytes));
83
+ } catch {
84
+ // fall through to the library
85
+ }
86
+ }
87
+ return sha256(bytes);
88
+ }
89
+
68
90
  export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
69
91
 
70
92
  /** What the builder receives. The raw nonce is deliberately absent. */
@@ -133,6 +155,7 @@ export type FuseErrorCode =
133
155
  | "bad-input"
134
156
  | "allocate-failed"
135
157
  | "builder-failed"
158
+ | "load-failed"
136
159
  | "commitment-missing"
137
160
  | "commit-refused"
138
161
  | "slot-unavailable"
@@ -381,7 +404,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
381
404
  if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
382
405
 
383
406
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
384
- const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
407
+ const originDigest = options.original !== undefined ? await digest(options.original) : options.originDigest;
385
408
  const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
386
409
 
387
410
  // 1. nonce
@@ -399,7 +422,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
399
422
  requireCommitment(placement, fused, commitment);
400
423
 
401
424
  // 3. hash
402
- const artifactDigest = sha256(fused);
425
+ const artifactDigest = await digest(fused);
403
426
  const artifactDigestB64 = bytesToBase64(artifactDigest);
404
427
 
405
428
  // 4. fill
@@ -458,7 +481,18 @@ export const MAX_SET_MEMBERS = 2000;
458
481
  /** The placements a set member takes: Forms A and B, one original per member. */
459
482
  export type SetMemberPlacement = "trailer/1" | "container/1";
460
483
 
461
- export interface FuseSetMember {
484
+ /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
485
+ export interface FusedDigestInput {
486
+ commitment: Uint8Array;
487
+ commitmentHex: string;
488
+ slot: SlotAllocation;
489
+ }
490
+
491
+ /**
492
+ * A member given as bytes. The core hashes the original, builds the fused
493
+ * bytes under the slot's commitment, checks them, and hashes them.
494
+ */
495
+ export interface FuseSetBytesMember {
462
496
  /** The original bytes. Never modified. */
463
497
  original: Uint8Array;
464
498
  /** Default: placementForBytes(original). */
@@ -469,9 +503,85 @@ export interface FuseSetMember {
469
503
  builder?: FuseBuilder;
470
504
  }
471
505
 
506
+ /**
507
+ * A member whose bytes are read only when it is that member's turn, after
508
+ * the slot is held, and released once hashed: one member's bytes in memory
509
+ * at a time, however large the set. Nothing can be read before allocation
510
+ * without reading twice, so the caller names the placement and the origin
511
+ * digest up front; the digest is checked against the loaded bytes, and the
512
+ * byte guards run as for a bytes member.
513
+ */
514
+ export interface FuseSetLoadedMember {
515
+ load: () => Promise<Uint8Array> | Uint8Array;
516
+ originDigest: Uint8Array;
517
+ placement: SetMemberPlacement;
518
+ /** Advisory; feeds fusedNamesFor. */
519
+ name?: string;
520
+ /** Default: builderFor(placement, bytes). The locate and origin guards run regardless. */
521
+ builder?: FuseBuilder;
522
+ }
523
+
524
+ /**
525
+ * A member the caller hashes itself: it answers the fused digest for the
526
+ * held slot's commitment. For trailer/1 that is a hash state saved after
527
+ * the original and finished with trailerBytesFor(commitment), so the bytes
528
+ * are read once, when they are scanned, and never again. The core never
529
+ * sees this member's bytes: no byte guard runs, keepFused returns nothing
530
+ * for it, and verifyMembers refuses it before any request. Its row is bound
531
+ * to the committed manifest by digest like every other.
532
+ */
533
+ export interface FuseSetHashedMember {
534
+ originDigest: Uint8Array;
535
+ placement: SetMemberPlacement;
536
+ fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
537
+ /** Advisory; feeds fusedNamesFor. */
538
+ name?: string;
539
+ }
540
+
541
+ export type FuseSetMember = FuseSetBytesMember | FuseSetLoadedMember | FuseSetHashedMember;
542
+
543
+ /**
544
+ * The 48 bytes trailer/1 appends after the original: the magic, eight
545
+ * reserved zero bytes, the commitment. A hasher whose state was saved after
546
+ * the original finishes with these and holds the member's fused digest
547
+ * without reading the original again. A test pins them against the
548
+ * placement's own build.
549
+ */
550
+ export function trailerBytesFor(commitment: Uint8Array): Uint8Array {
551
+ if (!(commitment instanceof Uint8Array) || commitment.length !== 32) throw new FuseError("bad-input", "a slot commitment is 32 bytes");
552
+ const out = new Uint8Array(TRAILER_LENGTH);
553
+ out.set(new TextEncoder().encode(TRAILER_MAGIC), 0);
554
+ out.set(commitment, TRAILER_LENGTH - 32);
555
+ return out;
556
+ }
557
+
558
+ export interface FuseSetProgress {
559
+ /**
560
+ * "hash": each member checked before any request (a bytes member's origin digest is taken here).
561
+ * "fuse": each member's fused digest taken, after the slot is held.
562
+ * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
563
+ * "verify": only with verifyMembers, one per member.
564
+ */
565
+ phase: "hash" | "fuse" | "commit" | "verify";
566
+ done: number;
567
+ total: number;
568
+ }
569
+
472
570
  export interface FuseSetOptions {
473
- /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
571
+ /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
474
572
  keepFused?: boolean;
573
+ /**
574
+ * Run the full verifier (verifyFuseMember) over every member's fused bytes
575
+ * after the commit and return each verdict under `verification`. Default
576
+ * false: every member is bound to the returned proof by digest, its row in
577
+ * the committed manifest, which is itself verified FUSED_DIRECT; that is
578
+ * linear and reads no bytes. The full pass re-hashes every member with the
579
+ * verifier's own hasher and grows with the square of the member count. A
580
+ * set with a hashed member refuses it before any request.
581
+ */
582
+ verifyMembers?: boolean;
583
+ /** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
584
+ onProgress?: (progress: FuseSetProgress) => void;
475
585
  /** Actor-bound commits: an agency envelope passed through untouched. */
476
586
  agency?: unknown;
477
587
  transport?: FuseTransport;
@@ -490,10 +600,10 @@ export interface FuseSetMemberResult {
490
600
  fusedName: string | null;
491
601
  /** Advisory; no Frame is written for a set member this phase. */
492
602
  frameName: string | null;
493
- /** Present only when keepFused is true. */
603
+ /** Present only when keepFused is true and the member's bytes passed through the core (never for a hashed member). */
494
604
  fusedBytes?: Uint8Array;
495
- /** The local verification of the returned proof against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
496
- verification: FuseMemberResult;
605
+ /** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
606
+ verification?: FuseMemberResult;
497
607
  }
498
608
 
499
609
  export interface FuseSetResult {
@@ -519,26 +629,55 @@ export interface FuseSetResult {
519
629
  * Allocate once, fuse every member with the one commitment, hash the set
520
630
  * manifest, fill the slot with it. Returns the proof with the manifest bytes
521
631
  * beside it, or throws a FuseError; it never commits a partial set and never
522
- * allocates a second slot.
632
+ * allocates a second slot. Members may be given as bytes, as a loader read
633
+ * one at a time after the slot is held, or as a digest the caller finishes
634
+ * itself; one set may mix them.
523
635
  */
524
636
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
525
637
  // 0. validate, before any request. A refusal here burns nothing.
526
638
  if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
527
639
  if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
528
- interface Checked { placement: Placement; id: SetMemberPlacement; original: Uint8Array; originDigest: Uint8Array; name: string | null; builder: FuseBuilder }
640
+ const keep = options.keepFused === true;
641
+ const verifyMembers = options.verifyMembers === true;
642
+ type Kind = "bytes" | "loaded" | "hashed";
643
+ interface Checked {
644
+ kind: Kind;
645
+ placement: Placement;
646
+ id: SetMemberPlacement;
647
+ originDigest: Uint8Array;
648
+ name: string | null;
649
+ original: Uint8Array | null;
650
+ load: (() => Promise<Uint8Array> | Uint8Array) | null;
651
+ builder: FuseBuilder | null;
652
+ fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
653
+ }
529
654
  const checked: Checked[] = [];
530
655
  const seen = new Map<string, number>();
656
+ const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
657
+ if (options.onProgress === undefined) return;
658
+ try {
659
+ options.onProgress({ phase, done, total });
660
+ } catch {
661
+ // a progress hook never changes the outcome
662
+ }
663
+ };
664
+ const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
531
665
  for (let i = 0; i < members.length; i++) {
532
- const m = members[i];
533
- // A null, undefined or missing element is refused like any other member without original bytes.
534
- if (m === null || typeof m !== "object" || !(m.original instanceof Uint8Array)) throw new FuseError("bad-input", `member ${i}: original must be a Uint8Array`, null, i);
535
- const id = m.placement ?? placementForBytes(m.original);
666
+ const m = members[i] as Partial<FuseSetBytesMember & FuseSetLoadedMember & FuseSetHashedMember> | null | undefined;
667
+ // A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
668
+ if (m === null || typeof m !== "object") throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
669
+ const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : null;
670
+ if (kind === null) throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
671
+ if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
672
+ const id = m.placement ?? placementForBytes(m.original as Uint8Array);
536
673
  const placement = getPlacement(id);
537
674
  if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
538
- if (placement.form === "C") throw new FuseError("bad-input", `member ${i}: ${id} takes no original; a set holds trailer/1 and container/1 members only`, null, i);
539
- if (m.name !== undefined && typeof m.name !== "string") throw new FuseError("bad-input", `member ${i}: name must be a string`, null, i);
540
- if (m.builder !== undefined && typeof m.builder !== "function") throw new FuseError("bad-input", `member ${i}: builder must be a function`, null, i);
541
- const originDigest = sha256(m.original);
675
+ if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1 and container/1 members only`);
676
+ if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
677
+ if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
678
+ if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `a ${kind} member names its originDigest, 32 bytes`);
679
+ if (kind === "hashed" && verifyMembers) throw bad(i, "a hashed member cannot be verified in full; pass its bytes or drop verifyMembers");
680
+ const originDigest = kind === "bytes" ? await digest(m.original as Uint8Array) : (m.originDigest as Uint8Array);
542
681
  // The same original under the same placement fuses to the same bytes, which one manifest lists once.
543
682
  const key = `${id}:${bytesToHex(originDigest)}`;
544
683
  const j = seen.get(key);
@@ -546,42 +685,91 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
546
685
  throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would fuse to the same bytes; a set lists each fused artifact once`, null, i);
547
686
  }
548
687
  seen.set(key, i);
549
- checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
688
+ checked.push({
689
+ kind,
690
+ placement,
691
+ id,
692
+ originDigest,
693
+ name: m.name ?? null,
694
+ original: kind === "bytes" ? (m.original as Uint8Array) : null,
695
+ load: kind === "loaded" ? (m.load as Checked["load"]) : null,
696
+ builder: kind !== "hashed" && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
697
+ fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
698
+ });
699
+ report("hash", i + 1, members.length);
550
700
  }
551
701
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
552
702
 
553
703
  // 1. nonce: one slot for the whole set
554
704
  const slot = await allocateSlot(t);
555
705
 
556
- // 2. fuse: the commitment once, every member's bytes carrying it. The slot
557
- // is held and its TTL is running; a throw here burns it but commits nothing.
706
+ // 2. fuse: the commitment once, every member's digest under it. The slot
707
+ // is held and its TTL is running; a throw here burns it but commits
708
+ // nothing. A member's fused bytes are virtual: each is built, hashed
709
+ // and released in turn, so memory holds one member's bytes at a time.
710
+ // They are held only for a caller who keeps them or asks the full
711
+ // verifier to read them.
558
712
  const commitment = computeSlotCommitment(slot);
559
713
  const commitmentHex = bytesToHex(commitment);
560
- const fusedBytes: Uint8Array[] = [];
714
+ const fusedBytes: (Uint8Array | null)[] = [];
561
715
  const rows: SetMember[] = [];
716
+ const expiring = "nothing was committed and the slot will expire";
562
717
  for (let i = 0; i < checked.length; i++) {
563
718
  const c = checked[i]!;
564
- let fused: Uint8Array;
565
- try {
566
- fused = await c.builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
567
- } catch (err) {
568
- throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
569
- }
570
- if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
571
- const located = requireCommitment(c.placement, fused, commitment, i);
572
- // The row's origin must be the origin the bytes embed, else the member
573
- // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
574
- // facts are checked when both are present: the digest the bytes declare
575
- // (container/1's payload) and the bytes they carry, so a builder cannot
576
- // pack other bytes under the member's digest and leave a member no
577
- // original rebuilds.
578
- const declared = located.originDigest;
579
- const carried = located.originalBytes !== undefined ? sha256(located.originalBytes) : undefined;
580
- if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.originDigest))) {
581
- throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; nothing was committed and the slot will expire`, null, i);
719
+ let artifact: Uint8Array;
720
+ let held: Uint8Array | null = null;
721
+ if (c.kind === "hashed") {
722
+ let d: unknown;
723
+ try {
724
+ d = await c.fusedDigest!({ commitment, commitmentHex, slot });
725
+ } catch (err) {
726
+ throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
727
+ }
728
+ if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
729
+ artifact = d;
730
+ } else {
731
+ let original: Uint8Array;
732
+ if (c.kind === "loaded") {
733
+ let loaded: unknown;
734
+ try {
735
+ loaded = await c.load!();
736
+ } catch (err) {
737
+ throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
738
+ }
739
+ if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
740
+ original = loaded;
741
+ // The digest the caller named is the row's origin; it must be these bytes' own.
742
+ if (!bytesEqual(await digest(original), c.originDigest)) throw new FuseError("bad-input", `member ${i}: originDigest is not the SHA-256 of the loaded bytes; ${expiring}`, null, i);
743
+ } else {
744
+ original = c.original!;
745
+ }
746
+ const builder = c.builder ?? builderFor(c.id, original);
747
+ let fused: Uint8Array;
748
+ try {
749
+ fused = await builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
750
+ } catch (err) {
751
+ throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
752
+ }
753
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
754
+ const located = requireCommitment(c.placement, fused, commitment, i);
755
+ // The row's origin must be the origin the bytes embed, else the member
756
+ // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
757
+ // facts are checked when both are present: the digest the bytes declare
758
+ // (container/1's payload) and the bytes they carry, compared byte for
759
+ // byte with the member's original rather than hashed again, so a builder
760
+ // cannot pack other bytes under the member's digest and leave a member no
761
+ // original rebuilds.
762
+ const declared = located.originDigest;
763
+ const carried = located.originalBytes;
764
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
765
+ throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; ${expiring}`, null, i);
766
+ }
767
+ artifact = await digest(fused);
768
+ if (keep || verifyMembers) held = fused;
582
769
  }
583
- fusedBytes.push(fused);
584
- rows.push({ artifact: sha256(fused), origin: c.originDigest, placement: c.id });
770
+ rows.push({ artifact, origin: c.originDigest, placement: c.id });
771
+ fusedBytes.push(held);
772
+ report("fuse", i + 1, checked.length);
585
773
  }
586
774
 
587
775
  // 3. hash: the canonical manifest is the artifact
@@ -589,9 +777,9 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
589
777
  try {
590
778
  manifestBytes = buildSetManifest(commitment, rows);
591
779
  } catch (err) {
592
- throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; nothing was committed and the slot will expire`);
780
+ throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
593
781
  }
594
- const artifactDigestB64 = bytesToBase64(sha256(manifestBytes));
782
+ const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
595
783
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
596
784
 
597
785
  // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
@@ -604,45 +792,66 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
604
792
  metadata: { [SET_METADATA_KEY]: manifest },
605
793
  };
606
794
  if (options.agency !== undefined) body.agency = options.agency;
795
+ report("commit", 0, 1);
607
796
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
797
+ report("commit", 1, 1);
608
798
 
609
799
  // The manifest is verified by a reader before the proof is called a set proof.
610
800
  const verification = await verifyFuse({ proof, bytes: manifestBytes });
611
801
  if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
612
802
  throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
613
803
  }
614
- // The echo is unsigned and advisory. Absent is normal: no production
615
- // boundary returns it today (the site proxy does not forward metadata and
616
- // the enclave's commitDigest action drops it); differing means a boundary
617
- // rewrote the response.
804
+ // The echo is unsigned and advisory. Absent is normal for a boundary that
805
+ // drops metadata on a held-slot commit (enclaves before v6, and a proxy
806
+ // that does not forward it); differing means a boundary rewrote the
807
+ // response.
618
808
  const echoed = readSetMetadata(proof);
619
809
  if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
620
810
  throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
621
811
  }
622
812
  const manifestEchoed = echoed !== null;
623
- // Every member against the explicit manifest bytes, so no verdict depends on the echo.
624
- const keep = options.keepFused === true;
813
+ // Every member is bound to the returned proof by its row: the manifest the
814
+ // proof commits (verified FUSED_DIRECT above) is parsed strictly, and each
815
+ // member's computed fused digest, origin and placement must sit in it. No
816
+ // member's bytes are read again. With verifyMembers the full verifier runs
817
+ // over each member's fused bytes as well, against the explicit manifest
818
+ // bytes so no verdict depends on the echo, and its verdict is returned.
819
+ const parsed = parseSetManifest(manifestBytes);
820
+ if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
821
+ const rowIndex = new Map<string, number>();
822
+ parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
625
823
  const results: FuseSetMemberResult[] = [];
626
824
  for (let i = 0; i < checked.length; i++) {
627
825
  const c = checked[i]!;
628
- const fused = fusedBytes[i]!;
629
- const memberArtifactB64 = bytesToBase64(rows[i]!.artifact);
630
- const v = await verifyFuseMember({ proof, bytes: fused, manifest: manifestBytes });
631
- const member = v.set?.member ?? null;
632
- if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64) {
633
- throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
826
+ const row = rows[i]!;
827
+ const k = rowIndex.get(bytesToHex(row.artifact));
828
+ const listed = k !== undefined ? parsed.members[k] : undefined;
829
+ if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
830
+ throw new FuseError("verification-failed", `member ${i}: the committed manifest does not list this member's fused digest with its origin and placement`, null, i);
831
+ }
832
+ const memberArtifactB64 = bytesToBase64(row.artifact);
833
+ let verification: FuseMemberResult | undefined;
834
+ if (verifyMembers) {
835
+ const v = await verifyFuseMember({ proof, bytes: fusedBytes[i]!, manifest: manifestBytes });
836
+ const member = v.set?.member ?? null;
837
+ if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64 || member.index !== k) {
838
+ throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
839
+ }
840
+ verification = v;
841
+ report("verify", i + 1, checked.length);
634
842
  }
635
843
  const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
844
+ const held = fusedBytes[i];
636
845
  results.push({
637
846
  index: i,
638
- manifestIndex: member.index,
847
+ manifestIndex: k,
639
848
  placement: c.id,
640
849
  originDigestB64: bytesToBase64(c.originDigest),
641
850
  artifactDigestB64: memberArtifactB64,
642
851
  fusedName: names?.fusedName ?? null,
643
852
  frameName: names?.frameName ?? null,
644
- ...(keep ? { fusedBytes: fused } : {}),
645
- verification: v,
853
+ ...(keep && held !== null ? { fusedBytes: held } : {}),
854
+ ...(verification !== undefined ? { verification } : {}),
646
855
  });
647
856
  }
648
857
  return {
package/src/index.ts CHANGED
@@ -40,9 +40,9 @@ export { Constructor } from "./constructor.js";
40
40
  // The producer profile over the primitive (working name Fuse): allocate a
41
41
  // slot, write a commitment to it into the artifact, hash, commit under the
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
- export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
43
+ export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
44
44
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
45
- export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
45
+ export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
46
  // The verify-package types those results are made of, so the core entry names everything it returns.
47
47
  export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
48
48