@mikeargento/bitgraph 1.3.0 → 1.4.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-cli.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * bitgraph-fuse: the internal harness (spec 9.2), as a command rather than a
6
6
  * page. Exercises Forms A, B and C end to end through fuse(), writes the
7
7
  * Frame (and the fused bytes when they must be kept), and prints the bounded
8
- * copy of spec 9.3. `check` runs verifyFuse over a Frame (or bare proof)
9
- * and a file, so both verification paths can be exercised from a shell.
8
+ * copy of spec 9.3. `set` runs fuseSet() over N files under one slot and
9
+ * writes the proof beside the manifest bytes. `check` runs verifyFuse over a
10
+ * Frame (or bare proof) and a file, or verifyFuseMember when the proof is a
11
+ * set proof and the file is not its manifest, so every verification path can
12
+ * be exercised from a shell.
10
13
  *
11
14
  * Not a product surface. The site has no /fuse page: a page would make the
12
15
  * website build depend on packages that are not published, which is a deploy
@@ -18,9 +21,9 @@
18
21
  import { readFile, writeFile, mkdir } from "node:fs/promises";
19
22
  import { basename, extname, join, resolve } from "node:path";
20
23
  import { sha256 } from "@noble/hashes/sha256";
21
- import { parseFrame, verifyFuse, bytesToBase64 } from "@mikeargento/bitgraph-verify";
22
- import type { BitGraphProof, PlacementId } from "@mikeargento/bitgraph-verify";
23
- import { fuse, builderFor, FuseError } from "./fuse.js";
24
+ import { parseFrame, verifyFuse, verifyFuseMember, bytesToBase64 } from "@mikeargento/bitgraph-verify";
25
+ import type { BitGraphProof, PlacementId, FuseVerifyResult, FuseMemberResult } from "@mikeargento/bitgraph-verify";
26
+ import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError } from "./fuse.js";
24
27
  import type { FuseTransport } from "./fuse.js";
25
28
 
26
29
  const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1, working name)
@@ -29,12 +32,14 @@ const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1
29
32
  Form A or B over an existing file. The file is never modified.
30
33
  bitgraph-fuse produce [--origin <file>] [options]
31
34
  Form C: a canonical payload naming an optional source.
32
- bitgraph-fuse check <frame-or-proof.json> <file> [--max-positions N]
33
- Verify a Frame (or bare proof) against the fused bytes or the original.
35
+ bitgraph-fuse set <file>... [options]
36
+ N files under one slot; the committed artifact is the set manifest. The files are never modified.
37
+ bitgraph-fuse check <frame-or-proof.json> <file> [--manifest <set.manifest.json>] [--max-positions N]
38
+ Verify a Frame (or bare proof) against the fused bytes or the original; a set proof against a member or its original.
34
39
 
35
- Options for fuse and produce:
36
- --out <dir> where to write the Frame and fused bytes (default: .)
37
- --keep also write the fused bytes for byte-exact placements
40
+ Options for fuse, produce and set:
41
+ --out <dir> where to write the Frame (for set: set.proof.json and set.manifest.json) and fused bytes (default: .)
42
+ --keep also write the fused bytes for byte-exact placements (set: inputs need distinct names)
38
43
  --base-url <url> commit surface (default https://bitgraph.ing)
39
44
  --allocate-path <p> default /api/fuse/allocate (parent-direct: /allocate-slot)
40
45
  --commit-path <p> default /api/fuse/commit (parent-direct: /commit)
@@ -78,6 +83,10 @@ function transportFrom(flags: Map<string, string | true>): FuseTransport {
78
83
  return t;
79
84
  }
80
85
 
86
+ function outDirOf(args: Args): string {
87
+ return resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
88
+ }
89
+
81
90
  async function writeOutputs(outDir: string, label: string, frame: unknown, fusedName: string | null, fused: Uint8Array | undefined): Promise<string[]> {
82
91
  await mkdir(outDir, { recursive: true });
83
92
  const written: string[] = [];
@@ -102,8 +111,7 @@ async function runFuse(args: Args): Promise<number> {
102
111
  const fusedName = `${label.slice(0, label.length - extname(label).length)}.fused${ext}`;
103
112
  const keep = args.flags.get("keep") === true;
104
113
  const r = await fuse(builderFor(placement as PlacementId, original), { placement: placement as PlacementId, original, fusedFile: fusedName, keepFused: keep, transport: transportFrom(args.flags) });
105
- const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
106
- const written = await writeOutputs(outDir, label, r.frame, keep ? fusedName : null, r.fusedBytes);
114
+ const written = await writeOutputs(outDirOf(args), label, r.frame, keep ? fusedName : null, r.fusedBytes);
107
115
  report(r.proof, r.verification.category, r.recovered, true);
108
116
  process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
109
117
  if (!keep) process.stdout.write(`\nThe fused bytes were not kept (${placement} is byte-exact: any verifier rebuilds them from the original and the proof). Pass --keep to write them.\n`);
@@ -114,13 +122,56 @@ async function runProduce(args: Args): Promise<number> {
114
122
  const originPath = args.flags.get("origin");
115
123
  const originDigest = typeof originPath === "string" ? sha256(new Uint8Array(await readFile(resolve(originPath)))) : undefined;
116
124
  const r = await fuse(builderFor("produced/1"), { placement: "produced/1", ...(originDigest !== undefined ? { originDigest } : {}), fusedFile: "produced.json", transport: transportFrom(args.flags) });
117
- const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
118
- const written = await writeOutputs(outDir, "produced", r.frame, "produced.json", r.fusedBytes);
125
+ const written = await writeOutputs(outDirOf(args), "produced", r.frame, "produced.json", r.fusedBytes);
119
126
  report(r.proof, r.verification.category, r.recovered, originDigest !== undefined);
120
127
  process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
121
128
  return 0;
122
129
  }
123
130
 
131
+ /** N files under one slot. Writes the proof and the manifest bytes exactly (they are the committed artifact, so `check` can hash them as it). */
132
+ async function runSet(args: Args): Promise<number> {
133
+ if (args.positional.length === 0) { process.stderr.write(USAGE); return 64; }
134
+ const keep = args.flags.get("keep") === true;
135
+ const members = [];
136
+ for (const file of args.positional) {
137
+ const original = new Uint8Array(await readFile(resolve(file)));
138
+ members.push({ original, placement: placementForBytes(original), name: sanitize(basename(file)) });
139
+ }
140
+ // Under --keep every member's fused bytes go to a file named from its own
141
+ // name and placement; two inputs that would share that name are refused
142
+ // here, before any allocation, rather than one silently overwriting the other.
143
+ if (keep) {
144
+ const seen = new Map<string, number>();
145
+ for (const [i, m] of members.entries()) {
146
+ const { fusedName } = fusedNamesFor(m.name, m.placement);
147
+ const j = seen.get(fusedName);
148
+ if (j !== undefined) { process.stderr.write(`set --keep: members ${j} and ${i} would both be written as ${fusedName}; give the files distinct names\n`); return 64; }
149
+ seen.set(fusedName, i);
150
+ }
151
+ }
152
+ const r = await fuseSet(members, { keepFused: keep, transport: transportFrom(args.flags) });
153
+ const outDir = outDirOf(args);
154
+ await mkdir(outDir, { recursive: true });
155
+ const written: string[] = [];
156
+ const proofPath = join(outDir, "set.proof.json");
157
+ await writeFile(proofPath, JSON.stringify(r.proof, null, 2) + "\n");
158
+ written.push(proofPath);
159
+ const manifestPath = join(outDir, "set.manifest.json");
160
+ await writeFile(manifestPath, r.manifestBytes);
161
+ written.push(manifestPath);
162
+ for (const m of r.members) {
163
+ if (m.fusedBytes === undefined || m.fusedName === null) continue;
164
+ const p = join(outDir, m.fusedName);
165
+ await writeFile(p, m.fusedBytes);
166
+ written.push(p);
167
+ }
168
+ report(r.proof, r.verification.category, r.recovered, false);
169
+ for (const m of r.members) process.stdout.write(`member ${m.index} row ${m.manifestIndex} ${m.placement} ${m.verification.category} ${m.fusedName ?? ""}\n`);
170
+ if (!r.manifestEchoed) process.stdout.write(`note the proof does not carry the manifest; keep set.manifest.json beside it\n`);
171
+ process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
172
+ return 0;
173
+ }
174
+
124
175
  function report(proof: BitGraphProof, category: string, recovered: boolean, hasOrigin: boolean): void {
125
176
  const c = proof.commit;
126
177
  if (hasOrigin) process.stdout.write(COPY_ORIGINAL + "\n\n");
@@ -134,6 +185,22 @@ function report(proof: BitGraphProof, category: string, recovered: boolean, hasO
134
185
  if (recovered) process.stdout.write(`note the commit response was lost; the proof was read back by digest and matched on the held slot\n`);
135
186
  }
136
187
 
188
+ /** The verdict lines shared by both verifiers; the `set` line appears only for a member verdict. */
189
+ function printVerdict(r: FuseVerifyResult | FuseMemberResult): void {
190
+ process.stdout.write(`category ${r.category}\n`);
191
+ process.stdout.write(`proof ${r.proof.valid ? "valid" : `invalid: ${r.proof.reason ?? ""}`}\n`);
192
+ process.stdout.write(`file digest ${r.fileDigestB64}\n`);
193
+ process.stdout.write(`artifact ${r.artifactDigestB64}\n`);
194
+ if (r.originDigestB64) process.stdout.write(`origin ${r.originDigestB64}\n`);
195
+ if (r.placement) process.stdout.write(`placement ${r.placement}\n`);
196
+ if (r.span) process.stdout.write(`span slot ${r.span.slotCounter} to commit ${r.span.commitCounter} (${r.span.positions} positions)\n`);
197
+ if (r.policy.maxPositions !== null) process.stdout.write(`span policy ${r.policy.spanExceeded ? "EXCEEDED" : "within"} ${r.policy.maxPositions} positions\n`);
198
+ if ("set" in r && r.set?.member) process.stdout.write(`set member ${r.set.member.index + 1} of ${r.set.memberCount}\n`);
199
+ if (r.reason) process.stdout.write(`reason ${r.reason}\n`);
200
+ for (const s of r.statements) process.stdout.write(`\n${s}\n`);
201
+ process.stdout.write(`\nfloor computed by the Player from anchors in a bundle (bitgraph-play check); not available here\n`);
202
+ }
203
+
137
204
  async function runCheck(args: Args): Promise<number> {
138
205
  const [frameArg, fileArg] = args.positional;
139
206
  if (frameArg === undefined || fileArg === undefined) { process.stderr.write(USAGE); return 64; }
@@ -148,18 +215,20 @@ async function runCheck(args: Args): Promise<number> {
148
215
  }
149
216
  const bytes = new Uint8Array(await readFile(resolve(fileArg)));
150
217
  const max = args.flags.get("max-positions");
151
- const r = await verifyFuse({ proof, bytes, frame, ...(typeof max === "string" ? { maxPositions: BigInt(max) } : {}) });
152
- process.stdout.write(`category ${r.category}\n`);
153
- process.stdout.write(`proof ${r.proof.valid ? "valid" : `invalid: ${r.proof.reason ?? ""}`}\n`);
154
- process.stdout.write(`file digest ${r.fileDigestB64}\n`);
155
- process.stdout.write(`artifact ${r.artifactDigestB64}\n`);
156
- if (r.originDigestB64) process.stdout.write(`origin ${r.originDigestB64}\n`);
157
- if (r.placement) process.stdout.write(`placement ${r.placement}\n`);
158
- if (r.span) process.stdout.write(`span slot ${r.span.slotCounter} to commit ${r.span.commitCounter} (${r.span.positions} positions)\n`);
159
- if (r.policy.maxPositions !== null) process.stdout.write(`span policy ${r.policy.spanExceeded ? "EXCEEDED" : "within"} ${r.policy.maxPositions} positions\n`);
160
- if (r.reason) process.stdout.write(`reason ${r.reason}\n`);
161
- for (const s of r.statements) process.stdout.write(`\n${s}\n`);
162
- process.stdout.write(`\nfloor computed by the Player from anchors in a bundle (bitgraph-play check); not available here\n`);
218
+ const policy = typeof max === "string" ? { maxPositions: BigInt(max) } : {};
219
+ // A set proof and a file that is not its manifest: the member verifier answers.
220
+ const a = proof.attribution;
221
+ if (a?.name === "bitgraph-fuse/1" && a.title === "set/1" && bytesToBase64(sha256(bytes)) !== proof.artifact.digestB64) {
222
+ const manifestArg = args.flags.get("manifest");
223
+ const manifest = typeof manifestArg === "string" ? new Uint8Array(await readFile(resolve(manifestArg))) : undefined;
224
+ const r = await verifyFuseMember({ proof, bytes, ...(manifest !== undefined ? { manifest } : {}), ...policy });
225
+ printVerdict(r);
226
+ if (r.category === "SET_MEMBER_DIRECT" || r.category === "SET_MEMBER_FROM_ORIGIN") return r.policy.spanExceeded ? 1 : 0;
227
+ if (r.category === "UNDETERMINED_PLACEMENT" || r.category === "NO_MATCH") return 2;
228
+ return 1;
229
+ }
230
+ const r = await verifyFuse({ proof, bytes, frame, ...policy });
231
+ printVerdict(r);
163
232
  if (r.category === "RECORDED" || r.category === "FUSED_DIRECT" || r.category === "FUSED_FROM_ORIGIN") return r.policy.spanExceeded ? 1 : 0;
164
233
  if (r.category === "UNDETERMINED_PLACEMENT" || r.category === "NO_MATCH") return 2;
165
234
  return 1;
@@ -171,12 +240,13 @@ async function main(): Promise<number> {
171
240
  switch (args.command) {
172
241
  case "fuse": return await runFuse(args);
173
242
  case "produce": return await runProduce(args);
243
+ case "set": return await runSet(args);
174
244
  case "check": return await runCheck(args);
175
245
  default: process.stderr.write(USAGE); return 64;
176
246
  }
177
247
  } catch (err) {
178
248
  if (err instanceof FuseError) {
179
- process.stderr.write(`no fused proof was completed (${err.code}${err.status !== null ? `, ${err.status}` : ""}): ${err.message}\n`);
249
+ process.stderr.write(`no fused proof was completed (${err.code}${err.status !== null ? `, ${err.status}` : ""}${err.member !== null ? `, member ${err.member}` : ""}): ${err.message}\n`);
180
250
  return err.code === "tee-restarting" ? 2 : 1;
181
251
  }
182
252
  process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
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,34 @@
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
+ readSetMetadata,
50
+ SET_METADATA_KEY,
37
51
  verifyFuse,
52
+ verifyFuseMember,
38
53
  base64ToBytes,
39
54
  } from "@mikeargento/bitgraph-verify";
40
- import type { BitGraphProof, FuseFrame, PlacementId, SlotAllocation, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
55
+ import type {
56
+ BitGraphProof,
57
+ FuseFrame,
58
+ FuseMemberResult,
59
+ FuseVerifyResult,
60
+ Located,
61
+ Placement,
62
+ PlacementId,
63
+ SetManifest,
64
+ SetMember,
65
+ SlotAllocation,
66
+ } from "@mikeargento/bitgraph-verify";
41
67
 
42
- export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof } from "@mikeargento/bitgraph-verify";
68
+ export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
43
69
 
44
70
  /** What the builder receives. The raw nonce is deliberately absent. */
45
71
  export interface BuilderInput {
@@ -119,11 +145,14 @@ export type FuseErrorCode =
119
145
  export class FuseError extends Error {
120
146
  readonly code: FuseErrorCode;
121
147
  readonly status: number | null;
122
- constructor(code: FuseErrorCode, message: string, status: number | null = null) {
148
+ /** The caller's 0-based index into a set's members when the failure is one member's; null otherwise. fuse() never sets it. */
149
+ readonly member: number | null;
150
+ constructor(code: FuseErrorCode, message: string, status: number | null = null, member: number | null = null) {
123
151
  super(message);
124
152
  this.name = "FuseError";
125
153
  this.code = code;
126
154
  this.status = status;
155
+ this.member = member;
127
156
  }
128
157
  }
129
158
 
@@ -181,6 +210,9 @@ const DEFAULTS = {
181
210
  recoveryDelayMs: 1_500,
182
211
  } as const;
183
212
 
213
+ /** A transport with every default filled in: what the beats below take. */
214
+ type BoundTransport = Required<Pick<FuseTransport, keyof typeof DEFAULTS>> & FuseTransport;
215
+
184
216
  const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
185
217
  const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
186
218
 
@@ -267,23 +299,8 @@ async function recover(
267
299
  return null;
268
300
  }
269
301
 
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
302
+ /** 1. nonce. The signed slot record from the boundary; it must sit on the anchored chain. */
303
+ async function allocateSlot(t: BoundTransport): Promise<SlotAllocation> {
287
304
  const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
288
305
  if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
289
306
  if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
@@ -291,37 +308,29 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
291
308
  const slot = (alloc.json as { slot?: unknown } | null)?.slot;
292
309
  if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
293
310
  if (slot.chainId !== "bitgraph:main") throw new FuseError("allocate-failed", "the slot is not on the anchored chain; a fused floor needs bitgraph:main");
311
+ return slot;
312
+ }
294
313
 
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.
314
+ /**
315
+ * Fail closed: never commit bytes that do not carry the commitment. Returns
316
+ * what the placement located, for any further check. `member` names the
317
+ * set member the bytes belong to; null for a single fused artifact.
318
+ */
319
+ function requireCommitment(placement: Placement, fused: Uint8Array, commitment: Uint8Array, member: number | null = null): Located {
305
320
  const located = placement.locate(fused);
306
321
  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`);
322
+ const label = member !== null ? `member ${member}: ` : "";
323
+ 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
324
  }
325
+ return located;
326
+ }
309
327
 
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
-
328
+ /**
329
+ * 4. fill. Commit under the held slot; on a lost or refused response read
330
+ * back by digest and match the slot record; never allocate again; refuse a
331
+ * proof under any other slot.
332
+ */
333
+ async function commitUnderSlot(t: BoundTransport, body: Record<string, unknown>, artifactDigestB64: string, slot: SlotAllocation): Promise<{ proof: BitGraphProof; recovered: boolean }> {
325
334
  let proof: BitGraphProof | null = null;
326
335
  let recovered = false;
327
336
  let commit: { status: number; json: unknown } | null = null;
@@ -356,6 +365,55 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
356
365
  if (proof.slotAllocation?.nonceB64 !== slot.nonceB64 || proof.commit?.nonceB64 !== slot.nonceB64) {
357
366
  throw new FuseError("slot-mismatch", "the boundary returned a proof under a different slot; nothing is labelled fused");
358
367
  }
368
+ return { proof, recovered };
369
+ }
370
+
371
+ /**
372
+ * Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
373
+ * throws a FuseError; it never returns an ordinary recording in place of a
374
+ * fused one.
375
+ */
376
+ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
377
+ const placement = getPlacement(options.placement);
378
+ if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
379
+ if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
380
+ if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
381
+ if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
382
+
383
+ const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
384
+ const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
385
+ const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
386
+
387
+ // 1. nonce
388
+ const slot = await allocateSlot(t);
389
+
390
+ // 2. fuse
391
+ const commitment = computeSlotCommitment(slot);
392
+ let fused: Uint8Array;
393
+ try {
394
+ fused = await builder({ commitment, commitmentHex: bytesToHex(commitment), ...(originDigest !== undefined ? { originDigest } : {}), slot });
395
+ } catch (err) {
396
+ throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
397
+ }
398
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
399
+ requireCommitment(placement, fused, commitment);
400
+
401
+ // 3. hash
402
+ const artifactDigest = sha256(fused);
403
+ const artifactDigestB64 = bytesToBase64(artifactDigest);
404
+
405
+ // 4. fill
406
+ const attribution = fuseAttribution(placement.id, originDigest);
407
+ const body: Record<string, unknown> = {
408
+ digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
409
+ slotId: slot.nonceB64,
410
+ slot,
411
+ chainId: "bitgraph:main",
412
+ attribution,
413
+ };
414
+ if (options.agency !== undefined) body.agency = options.agency;
415
+ const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
416
+
359
417
  // A minted proof is verified by a reader before it is called a proof.
360
418
  const verification = await verifyFuse({ proof, bytes: fused });
361
419
  if (verification.category !== "FUSED_DIRECT") {
@@ -382,6 +440,224 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
382
440
  };
383
441
  }
384
442
 
443
+ // ---------------------------------------------------------------------------
444
+ // Sets: N files fused under ONE slot
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * The most members one set takes. Measured: one canonical row is 246 bytes
449
+ * (container/1, the longest id this phase), so 2000 rows is 492,174 bytes.
450
+ * The parent refuses raw bodies over 1 MB (server.ts:249), which would land
451
+ * AFTER allocation and burn the slot. Half the cap is left for the slot
452
+ * record, an agency envelope, and future longer placement ids. 4000 rows
453
+ * (984 KB) leaves 63 KB and is refused; 10000 rows (2.4 MB) cannot pass at
454
+ * all. A test pins the budget.
455
+ */
456
+ export const MAX_SET_MEMBERS = 2000;
457
+
458
+ /** The placements a set member takes: Forms A and B, one original per member. */
459
+ export type SetMemberPlacement = "trailer/1" | "container/1";
460
+
461
+ export interface FuseSetMember {
462
+ /** The original bytes. Never modified. */
463
+ original: Uint8Array;
464
+ /** Default: placementForBytes(original). */
465
+ placement?: SetMemberPlacement;
466
+ /** Advisory; feeds fusedNamesFor. */
467
+ name?: string;
468
+ /** Default: builderFor(placement, original). The locate and origin guards run regardless. */
469
+ builder?: FuseBuilder;
470
+ }
471
+
472
+ export interface FuseSetOptions {
473
+ /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
474
+ keepFused?: boolean;
475
+ /** Actor-bound commits: an agency envelope passed through untouched. */
476
+ agency?: unknown;
477
+ transport?: FuseTransport;
478
+ }
479
+
480
+ export interface FuseSetMemberResult {
481
+ /** The caller's index into members. */
482
+ index: number;
483
+ /** The row's position in the sorted manifest; equals verification.set.member.index. */
484
+ manifestIndex: number;
485
+ placement: SetMemberPlacement;
486
+ originDigestB64: string;
487
+ /** SHA-256 of the member's fused bytes; the row's artifact. */
488
+ artifactDigestB64: string;
489
+ /** fusedNamesFor(name, placement); null when the member has no name. */
490
+ fusedName: string | null;
491
+ /** Advisory; no Frame is written for a set member this phase. */
492
+ frameName: string | null;
493
+ /** Present only when keepFused is true. */
494
+ 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;
497
+ }
498
+
499
+ export interface FuseSetResult {
500
+ proof: BitGraphProof;
501
+ /** The committed artifact. Keep it beside the proof. */
502
+ manifestBytes: Uint8Array;
503
+ /** JSON.parse of manifestBytes; the exact object sent under metadata. */
504
+ manifest: SetManifest;
505
+ /** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
506
+ artifactDigestB64: string;
507
+ slotCommitmentB64: string;
508
+ /** In the caller's order. */
509
+ members: FuseSetMemberResult[];
510
+ /** True when the commit response was lost and the proof was read back by the manifest digest. */
511
+ recovered: boolean;
512
+ /** True only when readSetMetadata(proof) is byte-equal to manifestBytes. */
513
+ manifestEchoed: boolean;
514
+ /** verifyFuse over manifestBytes. Always FUSED_DIRECT under "set/1" on success. */
515
+ verification: FuseVerifyResult;
516
+ }
517
+
518
+ /**
519
+ * Allocate once, fuse every member with the one commitment, hash the set
520
+ * manifest, fill the slot with it. Returns the proof with the manifest bytes
521
+ * beside it, or throws a FuseError; it never commits a partial set and never
522
+ * allocates a second slot.
523
+ */
524
+ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
525
+ // 0. validate, before any request. A refusal here burns nothing.
526
+ if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
527
+ 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 }
529
+ const checked: Checked[] = [];
530
+ const seen = new Map<string, number>();
531
+ 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);
536
+ const placement = getPlacement(id);
537
+ 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);
542
+ // The same original under the same placement fuses to the same bytes, which one manifest lists once.
543
+ const key = `${id}:${bytesToHex(originDigest)}`;
544
+ const j = seen.get(key);
545
+ if (j !== undefined) {
546
+ 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
+ }
548
+ seen.set(key, i);
549
+ checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
550
+ }
551
+ const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
552
+
553
+ // 1. nonce: one slot for the whole set
554
+ const slot = await allocateSlot(t);
555
+
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.
558
+ const commitment = computeSlotCommitment(slot);
559
+ const commitmentHex = bytesToHex(commitment);
560
+ const fusedBytes: Uint8Array[] = [];
561
+ const rows: SetMember[] = [];
562
+ for (let i = 0; i < checked.length; i++) {
563
+ 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);
582
+ }
583
+ fusedBytes.push(fused);
584
+ rows.push({ artifact: sha256(fused), origin: c.originDigest, placement: c.id });
585
+ }
586
+
587
+ // 3. hash: the canonical manifest is the artifact
588
+ let manifestBytes: Uint8Array;
589
+ try {
590
+ manifestBytes = buildSetManifest(commitment, rows);
591
+ } 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`);
593
+ }
594
+ const artifactDigestB64 = bytesToBase64(sha256(manifestBytes));
595
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
596
+
597
+ // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
598
+ const body: Record<string, unknown> = {
599
+ digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
600
+ slotId: slot.nonceB64,
601
+ slot,
602
+ chainId: "bitgraph:main",
603
+ attribution: fuseAttribution("set/1"),
604
+ metadata: { [SET_METADATA_KEY]: manifest },
605
+ };
606
+ if (options.agency !== undefined) body.agency = options.agency;
607
+ const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
608
+
609
+ // The manifest is verified by a reader before the proof is called a set proof.
610
+ const verification = await verifyFuse({ proof, bytes: manifestBytes });
611
+ if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
612
+ throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
613
+ }
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.
618
+ const echoed = readSetMetadata(proof);
619
+ if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
620
+ throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
621
+ }
622
+ 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;
625
+ const results: FuseSetMemberResult[] = [];
626
+ for (let i = 0; i < checked.length; i++) {
627
+ 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);
634
+ }
635
+ const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
636
+ results.push({
637
+ index: i,
638
+ manifestIndex: member.index,
639
+ placement: c.id,
640
+ originDigestB64: bytesToBase64(c.originDigest),
641
+ artifactDigestB64: memberArtifactB64,
642
+ fusedName: names?.fusedName ?? null,
643
+ frameName: names?.frameName ?? null,
644
+ ...(keep ? { fusedBytes: fused } : {}),
645
+ verification: v,
646
+ });
647
+ }
648
+ return {
649
+ proof,
650
+ manifestBytes,
651
+ manifest,
652
+ artifactDigestB64,
653
+ slotCommitmentB64: bytesToBase64(commitment),
654
+ members: results,
655
+ recovered,
656
+ manifestEchoed,
657
+ verification,
658
+ };
659
+ }
660
+
385
661
  /** Decode a standard-base64 digest, for callers holding one as text. */
386
662
  export function digestFromBase64(b64: string): Uint8Array {
387
663
  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, 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 {