@mikeargento/bitgraph 1.3.0 → 1.5.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
@@ -15,6 +15,16 @@
15
15
  * 4. fill: commit that digest under the same slot, with the placement id
16
16
  * and the origin digest in the signed attribution.
17
17
  *
18
+ * fuseSet(members, options): the same four beats for N files under ONE slot.
19
+ * The commitment is computed once and written into every member by that
20
+ * member's own placement; the committed artifact is the canonical set
21
+ * manifest (placement set/1, built by the verify package); the signed title
22
+ * is "set/1" with no origin, because a set has no single origin; the parsed
23
+ * manifest rides along as unsigned metadata. Nothing is committed unless
24
+ * every member's bytes carry the commitment and embed its own origin, and no
25
+ * proof is returned unless the manifest verifies FUSED_DIRECT and every
26
+ * member SET_MEMBER_DIRECT against the explicit manifest bytes.
27
+ *
18
28
  * What this module never does: write the nonce anywhere but process memory,
19
29
  * put it in a message, or fall back to an ordinary recording when the fused
20
30
  * commit fails. A failure is reported as a failure and the slot expires on
@@ -28,18 +38,54 @@
28
38
  import { sha256 } from "@noble/hashes/sha256";
29
39
  import {
30
40
  buildFrame,
41
+ buildSetManifest,
42
+ bytesEqual,
31
43
  bytesToBase64,
32
44
  bytesToHex,
33
45
  computeSlotCommitment,
34
46
  computeSlotRecordHash,
35
47
  fuseAttribution,
36
48
  getPlacement,
49
+ parseSetManifest,
50
+ readSetMetadata,
51
+ SET_METADATA_KEY,
37
52
  verifyFuse,
53
+ verifyFuseMember,
38
54
  base64ToBytes,
39
55
  } from "@mikeargento/bitgraph-verify";
40
- import type { BitGraphProof, FuseFrame, PlacementId, SlotAllocation, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
56
+ import type {
57
+ BitGraphProof,
58
+ FuseFrame,
59
+ FuseMemberResult,
60
+ FuseVerifyResult,
61
+ Located,
62
+ Placement,
63
+ PlacementId,
64
+ SetManifest,
65
+ SetMember,
66
+ SlotAllocation,
67
+ } from "@mikeargento/bitgraph-verify";
41
68
 
42
- export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof } from "@mikeargento/bitgraph-verify";
69
+ /**
70
+ * SHA-256 over bytes: the platform's native hasher when one is present
71
+ * (WebCrypto, in browsers and in Node), else the JavaScript library. The
72
+ * native path runs about ten times faster over large files and both give
73
+ * the same digest; a test pins that. A platform that refuses the input (a
74
+ * shared or detached buffer) falls back to the library.
75
+ */
76
+ export async function digest(bytes: Uint8Array): Promise<Uint8Array> {
77
+ const subtle = (globalThis as { crypto?: { subtle?: { digest?: (alg: string, data: Uint8Array) => Promise<ArrayBuffer> } } }).crypto?.subtle;
78
+ if (subtle !== undefined && typeof subtle.digest === "function") {
79
+ try {
80
+ return new Uint8Array(await subtle.digest("SHA-256", bytes));
81
+ } catch {
82
+ // fall through to the library
83
+ }
84
+ }
85
+ return sha256(bytes);
86
+ }
87
+
88
+ export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
43
89
 
44
90
  /** What the builder receives. The raw nonce is deliberately absent. */
45
91
  export interface BuilderInput {
@@ -119,11 +165,14 @@ export type FuseErrorCode =
119
165
  export class FuseError extends Error {
120
166
  readonly code: FuseErrorCode;
121
167
  readonly status: number | null;
122
- constructor(code: FuseErrorCode, message: string, status: number | null = null) {
168
+ /** The caller's 0-based index into a set's members when the failure is one member's; null otherwise. fuse() never sets it. */
169
+ readonly member: number | null;
170
+ constructor(code: FuseErrorCode, message: string, status: number | null = null, member: number | null = null) {
123
171
  super(message);
124
172
  this.name = "FuseError";
125
173
  this.code = code;
126
174
  this.status = status;
175
+ this.member = member;
127
176
  }
128
177
  }
129
178
 
@@ -181,6 +230,9 @@ const DEFAULTS = {
181
230
  recoveryDelayMs: 1_500,
182
231
  } as const;
183
232
 
233
+ /** A transport with every default filled in: what the beats below take. */
234
+ type BoundTransport = Required<Pick<FuseTransport, keyof typeof DEFAULTS>> & FuseTransport;
235
+
184
236
  const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
185
237
  const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
186
238
 
@@ -267,23 +319,8 @@ async function recover(
267
319
  return null;
268
320
  }
269
321
 
270
- /**
271
- * Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
272
- * throws a FuseError; it never returns an ordinary recording in place of a
273
- * fused one.
274
- */
275
- export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
276
- const placement = getPlacement(options.placement);
277
- if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
278
- if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
279
- if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
280
- if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
281
-
282
- const t = { ...DEFAULTS, ...(options.transport ?? {}) };
283
- const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
284
- const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
285
-
286
- // 1. nonce
322
+ /** 1. nonce. The signed slot record from the boundary; it must sit on the anchored chain. */
323
+ async function allocateSlot(t: BoundTransport): Promise<SlotAllocation> {
287
324
  const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
288
325
  if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
289
326
  if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
@@ -291,37 +328,29 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
291
328
  const slot = (alloc.json as { slot?: unknown } | null)?.slot;
292
329
  if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
293
330
  if (slot.chainId !== "bitgraph:main") throw new FuseError("allocate-failed", "the slot is not on the anchored chain; a fused floor needs bitgraph:main");
331
+ return slot;
332
+ }
294
333
 
295
- // 2. fuse
296
- const commitment = computeSlotCommitment(slot);
297
- let fused: Uint8Array;
298
- try {
299
- fused = await builder({ commitment, commitmentHex: bytesToHex(commitment), ...(originDigest !== undefined ? { originDigest } : {}), slot });
300
- } catch (err) {
301
- throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
302
- }
303
- if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
304
- // Fail closed: never commit bytes that do not carry the commitment.
334
+ /**
335
+ * Fail closed: never commit bytes that do not carry the commitment. Returns
336
+ * what the placement located, for any further check. `member` names the
337
+ * set member the bytes belong to; null for a single fused artifact.
338
+ */
339
+ function requireCommitment(placement: Placement, fused: Uint8Array, commitment: Uint8Array, member: number | null = null): Located {
305
340
  const located = placement.locate(fused);
306
341
  if (located === null || bytesToHex(located.commitment) !== bytesToHex(commitment)) {
307
- throw new FuseError("commitment-missing", `the fused bytes do not carry the ${placement.id} commitment; nothing was committed and the slot will expire`);
342
+ const label = member !== null ? `member ${member}: ` : "";
343
+ throw new FuseError("commitment-missing", `${label}the fused bytes do not carry the ${placement.id} commitment; nothing was committed and the slot will expire`, null, member);
308
344
  }
345
+ return located;
346
+ }
309
347
 
310
- // 3. hash
311
- const artifactDigest = sha256(fused);
312
- const artifactDigestB64 = bytesToBase64(artifactDigest);
313
-
314
- // 4. fill
315
- const attribution = fuseAttribution(placement.id, originDigest);
316
- const body: Record<string, unknown> = {
317
- digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
318
- slotId: slot.nonceB64,
319
- slot,
320
- chainId: "bitgraph:main",
321
- attribution,
322
- };
323
- if (options.agency !== undefined) body.agency = options.agency;
324
-
348
+ /**
349
+ * 4. fill. Commit under the held slot; on a lost or refused response read
350
+ * back by digest and match the slot record; never allocate again; refuse a
351
+ * proof under any other slot.
352
+ */
353
+ async function commitUnderSlot(t: BoundTransport, body: Record<string, unknown>, artifactDigestB64: string, slot: SlotAllocation): Promise<{ proof: BitGraphProof; recovered: boolean }> {
325
354
  let proof: BitGraphProof | null = null;
326
355
  let recovered = false;
327
356
  let commit: { status: number; json: unknown } | null = null;
@@ -356,6 +385,55 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
356
385
  if (proof.slotAllocation?.nonceB64 !== slot.nonceB64 || proof.commit?.nonceB64 !== slot.nonceB64) {
357
386
  throw new FuseError("slot-mismatch", "the boundary returned a proof under a different slot; nothing is labelled fused");
358
387
  }
388
+ return { proof, recovered };
389
+ }
390
+
391
+ /**
392
+ * Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
393
+ * throws a FuseError; it never returns an ordinary recording in place of a
394
+ * fused one.
395
+ */
396
+ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
397
+ const placement = getPlacement(options.placement);
398
+ if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
399
+ if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
400
+ if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
401
+ if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
402
+
403
+ const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
404
+ const originDigest = options.original !== undefined ? await digest(options.original) : options.originDigest;
405
+ const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
406
+
407
+ // 1. nonce
408
+ const slot = await allocateSlot(t);
409
+
410
+ // 2. fuse
411
+ const commitment = computeSlotCommitment(slot);
412
+ let fused: Uint8Array;
413
+ try {
414
+ fused = await builder({ commitment, commitmentHex: bytesToHex(commitment), ...(originDigest !== undefined ? { originDigest } : {}), slot });
415
+ } catch (err) {
416
+ throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
417
+ }
418
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
419
+ requireCommitment(placement, fused, commitment);
420
+
421
+ // 3. hash
422
+ const artifactDigest = await digest(fused);
423
+ const artifactDigestB64 = bytesToBase64(artifactDigest);
424
+
425
+ // 4. fill
426
+ const attribution = fuseAttribution(placement.id, originDigest);
427
+ const body: Record<string, unknown> = {
428
+ digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
429
+ slotId: slot.nonceB64,
430
+ slot,
431
+ chainId: "bitgraph:main",
432
+ attribution,
433
+ };
434
+ if (options.agency !== undefined) body.agency = options.agency;
435
+ const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
436
+
359
437
  // A minted proof is verified by a reader before it is called a proof.
360
438
  const verification = await verifyFuse({ proof, bytes: fused });
361
439
  if (verification.category !== "FUSED_DIRECT") {
@@ -382,6 +460,283 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
382
460
  };
383
461
  }
384
462
 
463
+ // ---------------------------------------------------------------------------
464
+ // Sets: N files fused under ONE slot
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * The most members one set takes. Measured: one canonical row is 246 bytes
469
+ * (container/1, the longest id this phase), so 2000 rows is 492,174 bytes.
470
+ * The parent refuses raw bodies over 1 MB (server.ts:249), which would land
471
+ * AFTER allocation and burn the slot. Half the cap is left for the slot
472
+ * record, an agency envelope, and future longer placement ids. 4000 rows
473
+ * (984 KB) leaves 63 KB and is refused; 10000 rows (2.4 MB) cannot pass at
474
+ * all. A test pins the budget.
475
+ */
476
+ export const MAX_SET_MEMBERS = 2000;
477
+
478
+ /** The placements a set member takes: Forms A and B, one original per member. */
479
+ export type SetMemberPlacement = "trailer/1" | "container/1";
480
+
481
+ export interface FuseSetMember {
482
+ /** The original bytes. Never modified. */
483
+ original: Uint8Array;
484
+ /** Default: placementForBytes(original). */
485
+ placement?: SetMemberPlacement;
486
+ /** Advisory; feeds fusedNamesFor. */
487
+ name?: string;
488
+ /** Default: builderFor(placement, original). The locate and origin guards run regardless. */
489
+ builder?: FuseBuilder;
490
+ }
491
+
492
+ export interface FuseSetProgress {
493
+ /**
494
+ * "hash": origin digests, one per member, before any request.
495
+ * "fuse": each member built and its fused digest taken, after the slot is held.
496
+ * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
497
+ * "verify": only with verifyMembers, one per member.
498
+ */
499
+ phase: "hash" | "fuse" | "commit" | "verify";
500
+ done: number;
501
+ total: number;
502
+ }
503
+
504
+ export interface FuseSetOptions {
505
+ /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
506
+ keepFused?: boolean;
507
+ /**
508
+ * Run the full verifier (verifyFuseMember) over every member's fused bytes
509
+ * after the commit and return each verdict under `verification`. Default
510
+ * false: every member is bound to the returned proof by digest, its row in
511
+ * the committed manifest, which is itself verified FUSED_DIRECT; that is
512
+ * linear and reads no bytes. The full pass re-hashes every member with the
513
+ * verifier's own hasher and grows with the square of the member count.
514
+ */
515
+ verifyMembers?: boolean;
516
+ /** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
517
+ onProgress?: (progress: FuseSetProgress) => void;
518
+ /** Actor-bound commits: an agency envelope passed through untouched. */
519
+ agency?: unknown;
520
+ transport?: FuseTransport;
521
+ }
522
+
523
+ export interface FuseSetMemberResult {
524
+ /** The caller's index into members. */
525
+ index: number;
526
+ /** The row's position in the sorted manifest; equals verification.set.member.index. */
527
+ manifestIndex: number;
528
+ placement: SetMemberPlacement;
529
+ originDigestB64: string;
530
+ /** SHA-256 of the member's fused bytes; the row's artifact. */
531
+ artifactDigestB64: string;
532
+ /** fusedNamesFor(name, placement); null when the member has no name. */
533
+ fusedName: string | null;
534
+ /** Advisory; no Frame is written for a set member this phase. */
535
+ frameName: string | null;
536
+ /** Present only when keepFused is true. */
537
+ fusedBytes?: Uint8Array;
538
+ /** 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". */
539
+ verification?: FuseMemberResult;
540
+ }
541
+
542
+ export interface FuseSetResult {
543
+ proof: BitGraphProof;
544
+ /** The committed artifact. Keep it beside the proof. */
545
+ manifestBytes: Uint8Array;
546
+ /** JSON.parse of manifestBytes; the exact object sent under metadata. */
547
+ manifest: SetManifest;
548
+ /** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
549
+ artifactDigestB64: string;
550
+ slotCommitmentB64: string;
551
+ /** In the caller's order. */
552
+ members: FuseSetMemberResult[];
553
+ /** True when the commit response was lost and the proof was read back by the manifest digest. */
554
+ recovered: boolean;
555
+ /** True only when readSetMetadata(proof) is byte-equal to manifestBytes. */
556
+ manifestEchoed: boolean;
557
+ /** verifyFuse over manifestBytes. Always FUSED_DIRECT under "set/1" on success. */
558
+ verification: FuseVerifyResult;
559
+ }
560
+
561
+ /**
562
+ * Allocate once, fuse every member with the one commitment, hash the set
563
+ * manifest, fill the slot with it. Returns the proof with the manifest bytes
564
+ * beside it, or throws a FuseError; it never commits a partial set and never
565
+ * allocates a second slot.
566
+ */
567
+ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
568
+ // 0. validate, before any request. A refusal here burns nothing.
569
+ if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
570
+ if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
571
+ interface Checked { placement: Placement; id: SetMemberPlacement; original: Uint8Array; originDigest: Uint8Array; name: string | null; builder: FuseBuilder }
572
+ const checked: Checked[] = [];
573
+ const seen = new Map<string, number>();
574
+ const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
575
+ if (options.onProgress === undefined) return;
576
+ try {
577
+ options.onProgress({ phase, done, total });
578
+ } catch {
579
+ // a progress hook never changes the outcome
580
+ }
581
+ };
582
+ for (let i = 0; i < members.length; i++) {
583
+ const m = members[i];
584
+ // A null, undefined or missing element is refused like any other member without original bytes.
585
+ if (m === null || typeof m !== "object" || !(m.original instanceof Uint8Array)) throw new FuseError("bad-input", `member ${i}: original must be a Uint8Array`, null, i);
586
+ const id = m.placement ?? placementForBytes(m.original);
587
+ const placement = getPlacement(id);
588
+ if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
589
+ 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);
590
+ if (m.name !== undefined && typeof m.name !== "string") throw new FuseError("bad-input", `member ${i}: name must be a string`, null, i);
591
+ if (m.builder !== undefined && typeof m.builder !== "function") throw new FuseError("bad-input", `member ${i}: builder must be a function`, null, i);
592
+ const originDigest = await digest(m.original);
593
+ // The same original under the same placement fuses to the same bytes, which one manifest lists once.
594
+ const key = `${id}:${bytesToHex(originDigest)}`;
595
+ const j = seen.get(key);
596
+ if (j !== undefined) {
597
+ 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);
598
+ }
599
+ seen.set(key, i);
600
+ checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
601
+ report("hash", i + 1, members.length);
602
+ }
603
+ const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
604
+
605
+ // 1. nonce: one slot for the whole set
606
+ const slot = await allocateSlot(t);
607
+
608
+ // 2. fuse: the commitment once, every member's bytes carrying it. The slot
609
+ // is held and its TTL is running; a throw here burns it but commits nothing.
610
+ const commitment = computeSlotCommitment(slot);
611
+ const commitmentHex = bytesToHex(commitment);
612
+ const keep = options.keepFused === true;
613
+ const verifyMembers = options.verifyMembers === true;
614
+ // A member's fused bytes are virtual: each is built, hashed and released in
615
+ // turn, so memory holds the originals and one fused copy. They are held only
616
+ // for a caller who keeps them or asks the full verifier to read them.
617
+ const fusedBytes: (Uint8Array | null)[] = [];
618
+ const rows: SetMember[] = [];
619
+ for (let i = 0; i < checked.length; i++) {
620
+ const c = checked[i]!;
621
+ let fused: Uint8Array;
622
+ try {
623
+ fused = await c.builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
624
+ } catch (err) {
625
+ throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
626
+ }
627
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
628
+ const located = requireCommitment(c.placement, fused, commitment, i);
629
+ // The row's origin must be the origin the bytes embed, else the member
630
+ // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
631
+ // facts are checked when both are present: the digest the bytes declare
632
+ // (container/1's payload) and the bytes they carry, compared byte for
633
+ // byte with the member's original rather than hashed again, so a builder
634
+ // cannot pack other bytes under the member's digest and leave a member no
635
+ // original rebuilds.
636
+ const declared = located.originDigest;
637
+ const carried = located.originalBytes;
638
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.original))) {
639
+ 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);
640
+ }
641
+ rows.push({ artifact: await digest(fused), origin: c.originDigest, placement: c.id });
642
+ fusedBytes.push(keep || verifyMembers ? fused : null);
643
+ report("fuse", i + 1, checked.length);
644
+ }
645
+
646
+ // 3. hash: the canonical manifest is the artifact
647
+ let manifestBytes: Uint8Array;
648
+ try {
649
+ manifestBytes = buildSetManifest(commitment, rows);
650
+ } catch (err) {
651
+ 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`);
652
+ }
653
+ const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
654
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
655
+
656
+ // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
657
+ const body: Record<string, unknown> = {
658
+ digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
659
+ slotId: slot.nonceB64,
660
+ slot,
661
+ chainId: "bitgraph:main",
662
+ attribution: fuseAttribution("set/1"),
663
+ metadata: { [SET_METADATA_KEY]: manifest },
664
+ };
665
+ if (options.agency !== undefined) body.agency = options.agency;
666
+ report("commit", 0, 1);
667
+ const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
668
+ report("commit", 1, 1);
669
+
670
+ // The manifest is verified by a reader before the proof is called a set proof.
671
+ const verification = await verifyFuse({ proof, bytes: manifestBytes });
672
+ if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
673
+ throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
674
+ }
675
+ // The echo is unsigned and advisory. Absent is normal for a boundary that
676
+ // drops metadata on a held-slot commit (enclaves before v6, and a proxy
677
+ // that does not forward it); differing means a boundary rewrote the
678
+ // response.
679
+ const echoed = readSetMetadata(proof);
680
+ if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
681
+ throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
682
+ }
683
+ const manifestEchoed = echoed !== null;
684
+ // Every member is bound to the returned proof by its row: the manifest the
685
+ // proof commits (verified FUSED_DIRECT above) is parsed strictly, and each
686
+ // member's computed fused digest, origin and placement must sit in it. No
687
+ // member's bytes are read again. With verifyMembers the full verifier runs
688
+ // over each member's fused bytes as well, against the explicit manifest
689
+ // bytes so no verdict depends on the echo, and its verdict is returned.
690
+ const parsed = parseSetManifest(manifestBytes);
691
+ if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
692
+ const rowIndex = new Map<string, number>();
693
+ parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
694
+ const results: FuseSetMemberResult[] = [];
695
+ for (let i = 0; i < checked.length; i++) {
696
+ const c = checked[i]!;
697
+ const row = rows[i]!;
698
+ const k = rowIndex.get(bytesToHex(row.artifact));
699
+ const listed = k !== undefined ? parsed.members[k] : undefined;
700
+ if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
701
+ 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);
702
+ }
703
+ const memberArtifactB64 = bytesToBase64(row.artifact);
704
+ let verification: FuseMemberResult | undefined;
705
+ if (verifyMembers) {
706
+ const v = await verifyFuseMember({ proof, bytes: fusedBytes[i]!, manifest: manifestBytes });
707
+ const member = v.set?.member ?? null;
708
+ if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64 || member.index !== k) {
709
+ throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
710
+ }
711
+ verification = v;
712
+ report("verify", i + 1, checked.length);
713
+ }
714
+ const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
715
+ results.push({
716
+ index: i,
717
+ manifestIndex: k,
718
+ placement: c.id,
719
+ originDigestB64: bytesToBase64(c.originDigest),
720
+ artifactDigestB64: memberArtifactB64,
721
+ fusedName: names?.fusedName ?? null,
722
+ frameName: names?.frameName ?? null,
723
+ ...(keep ? { fusedBytes: fusedBytes[i]! } : {}),
724
+ ...(verification !== undefined ? { verification } : {}),
725
+ });
726
+ }
727
+ return {
728
+ proof,
729
+ manifestBytes,
730
+ manifest,
731
+ artifactDigestB64,
732
+ slotCommitmentB64: bytesToBase64(commitment),
733
+ members: results,
734
+ recovered,
735
+ manifestEchoed,
736
+ verification,
737
+ };
738
+ }
739
+
385
740
  /** Decode a standard-base64 digest, for callers holding one as text. */
386
741
  export function digestFromBase64(b64: string): Uint8Array {
387
742
  const d = base64ToBytes(b64);
package/src/index.ts CHANGED
@@ -40,8 +40,11 @@ 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, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
43
+ export { fuse, fuseSet, MAX_SET_MEMBERS, 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, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
+ // The verify-package types those results are made of, so the core entry names everything it returns.
47
+ export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
45
48
 
46
49
  // Policy parsing, hashing, and validation
47
50
  export {