@mikeargento/bitgraph-mcp 0.3.0 → 0.4.1

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.
@@ -3,19 +3,21 @@
3
3
  /**
4
4
  * End-to-end tool tests: a real MCP client drives the real server over an
5
5
  * in-memory transport, against a mock bitgraph.ing that asserts the exact
6
- * wire shapes. No real ledger writes ever happen here.
6
+ * wire shapes, with the set pipeline replaced by a stand-in. No real ledger
7
+ * writes ever happen here.
7
8
  */
8
9
 
9
10
  import { test, before, after } from "node:test";
10
11
  import { strict as assert } from "node:assert";
11
12
  import { createHash } from "node:crypto";
12
13
  import { createServer, type Server } from "node:http";
13
- import { writeFile, mkdtemp } from "node:fs/promises";
14
+ import { writeFile, mkdtemp, mkdir, symlink } from "node:fs/promises";
14
15
  import { tmpdir } from "node:os";
15
16
  import { join } from "node:path";
16
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
18
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
18
- import { buildServer, type FusedSummary } from "../server.js";
19
+ import { FuseError } from "@mikeargento/bitgraph";
20
+ import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseFileFn, type FuseSetFn } from "../server.js";
19
21
  import { toUrlSafeB64 } from "../encoding.js";
20
22
 
21
23
  interface Recorded {
@@ -30,10 +32,16 @@ let mock: Server;
30
32
  let fileA = "";
31
33
  let fileB = "";
32
34
  let fileC = "";
35
+ let copyB = "";
36
+ let dir = "";
37
+ let bigDir = "";
38
+ const BIG = SET_INDEX_CHUNK + 100;
33
39
  let digestA = ""; // standard b64 of fileA bytes
34
40
  let digestB = "";
41
+ let digestC = "";
35
42
  const EPOCH = createHash("sha256").update("test-epoch").digest("base64");
36
43
  let mintCounter = 100;
44
+ let indexMode: "ok" | "fail" = "ok";
37
45
 
38
46
  function proofFor(digestB64: string): Record<string, unknown> {
39
47
  mintCounter += 2; // slot consumes one position, commit lands on the next
@@ -43,19 +51,34 @@ function proofFor(digestB64: string): Record<string, unknown> {
43
51
  commit: { counter: String(mintCounter), epochId: EPOCH },
44
52
  signer: { publicKeyB64: "pk", signatureB64: "sig" },
45
53
  environment: { enforcement: "measured-tee", measurement: "m" },
54
+ attribution: { name: "bitgraph-fuse/1", title: "set/1" },
46
55
  };
47
56
  }
48
57
 
49
58
  before(async () => {
50
- const dir = await mkdtemp(join(tmpdir(), "bitgraph-mcp-e2e-"));
51
- fileA = join(dir, "a.txt");
52
- fileB = join(dir, "b.txt");
53
- fileC = join(dir, "c.txt");
59
+ const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-e2e-"));
60
+ fileA = join(root, "a.txt");
61
+ fileB = join(root, "b.txt");
62
+ fileC = join(root, "c.txt");
63
+ copyB = join(root, "b-copy.txt");
54
64
  await writeFile(fileA, "alpha bytes");
55
65
  await writeFile(fileB, "beta bytes");
56
66
  await writeFile(fileC, "gamma bytes");
67
+ await writeFile(copyB, "beta bytes");
57
68
  digestA = createHash("sha256").update("alpha bytes").digest("base64");
58
69
  digestB = createHash("sha256").update("beta bytes").digest("base64");
70
+ digestC = createHash("sha256").update("gamma bytes").digest("base64");
71
+ // A folder: two files, a hidden one, a nested one, a symbolic link.
72
+ dir = join(root, "folder");
73
+ await mkdir(join(dir, "sub"), { recursive: true });
74
+ await writeFile(join(dir, "a1.txt"), "folder one");
75
+ await writeFile(join(dir, ".hidden.txt"), "hidden");
76
+ await writeFile(join(dir, "sub", "a2.txt"), "folder two");
77
+ await symlink(join(dir, "a1.txt"), join(dir, "link.txt"));
78
+ // A folder above the set/1 cap.
79
+ bigDir = join(root, "big");
80
+ await mkdir(bigDir);
81
+ await Promise.all(Array.from({ length: BIG }, (_, i) => writeFile(join(bigDir, `m${String(i).padStart(5, "0")}.txt`), `member ${i}\n`)));
59
82
 
60
83
  mock = createServer((req, res) => {
61
84
  const chunks: Buffer[] = [];
@@ -72,14 +95,14 @@ before(async () => {
72
95
  };
73
96
 
74
97
  if (url.pathname === "/api/proofs/batch") {
75
- // fileA is already on record (two positions); fileB is not.
98
+ // fileA is already on record (two positions, the first as a set member); nothing else is.
76
99
  const digests = (body as { digests: string[] }).digests;
77
- const results: Record<string, { proofs: Array<{ proof: unknown }> }> = {};
100
+ const results: Record<string, { proofs: Array<{ proof: unknown; member?: unknown }> }> = {};
78
101
  for (const d of digests) {
79
102
  if (d === toUrlSafeB64(digestA)) {
80
103
  results[d] = {
81
104
  proofs: [
82
- { proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } } },
105
+ { proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } }, member: { index: 2, count: 10, role: "origin" } },
83
106
  { proof: { artifact: { digestB64: digestA }, commit: { counter: "55", epochId: EPOCH } } },
84
107
  ],
85
108
  };
@@ -88,9 +111,16 @@ before(async () => {
88
111
  }
89
112
  }
90
113
  send(200, { results });
91
- } else if (url.pathname === "/api/commit") {
92
- // Ordinary recording is no longer what the record tool does; nothing here should call it.
93
- send(500, { error: "unexpected /api/commit" });
114
+ } else if (url.pathname === "/api/fuse/set-index") {
115
+ if (indexMode === "fail") {
116
+ send(500, { error: "indexing failed" });
117
+ } else {
118
+ const members = (body as { members: unknown[] }).members;
119
+ send(200, { count: BIG, written: members.length, failed: 0, rejected: 0 });
120
+ }
121
+ } else if (url.pathname === "/api/commit" || url.pathname.startsWith("/api/fuse/allocate") || url.pathname.startsWith("/api/fuse/commit")) {
122
+ // The stand-in pipeline never reaches the boundary; nothing here may.
123
+ send(500, { error: `unexpected ${url.pathname}` });
94
124
  } else if (url.pathname.startsWith("/api/proofs/digest/")) {
95
125
  const digest = decodeURIComponent(url.pathname.split("/").pop() ?? "");
96
126
  if (digest === toUrlSafeB64(digestA)) {
@@ -105,6 +135,12 @@ before(async () => {
105
135
  anchorAfter: { blockTime: "2026-07-01T00:00:12.000Z", blockNumber: 2 },
106
136
  },
107
137
  });
138
+ } else if (digest === toUrlSafeB64(digestB)) {
139
+ send(200, {
140
+ proofs: [{ proof: { artifact: { digestB64: "c2V0" }, commit: { counter: "1386", epochId: EPOCH } } }],
141
+ positions: [{ counter: "1386", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null, kind: "fused", placement: "container/2", member: { index: 4, count: 50, role: "origin" } }],
142
+ causalWindow: null,
143
+ });
108
144
  } else {
109
145
  send(200, { proofs: [] });
110
146
  }
@@ -133,30 +169,64 @@ after(() => {
133
169
  delete process.env["BITGRAPH_API_KEY"];
134
170
  });
135
171
 
136
- /** A stand-in for the fuse pipeline: no slot, no enclave; records what it was asked to fuse. */
137
- const fusedCalls: Array<{ name: string; digestB64: string }> = [];
172
+ /** A stand-in for the set pipeline: no slot, no boundary; records what it was asked to fuse and answers like a set. */
173
+ const setCalls: Array<{ names: string[]; digests: string[]; set: string }> = [];
138
174
  let fuseMode: "ok" | "fail" = "ok";
139
- async function fakeFuse(bytes: Uint8Array, name: string): Promise<FusedSummary> {
140
- const digestB64 = createHash("sha256").update(bytes).digest("base64");
141
- fusedCalls.push({ name, digestB64 });
142
- if (fuseMode === "fail") throw new Error("the boundary is restarting");
143
- const artifactDigestB64 = createHash("sha256").update("fused:" + digestB64).digest("base64");
175
+ const fakeFuseSet: FuseSetFn = async (files, _config, opts) => {
176
+ setCalls.push({ names: files.map((f) => f.name), digests: files.map((f) => f.digestB64), set: opts.set });
177
+ if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
178
+ opts.onProgress?.({ phase: "commit", done: 1, total: 1 });
179
+ const rows = files.map((f, index) => ({ index, artifact: createHash("sha256").update("fused:" + f.digestB64).digest("base64"), origin: f.digestB64, placement: f.placement }));
180
+ const sorted = [...rows].sort((a, b) => Buffer.from(a.artifact, "base64").compare(Buffer.from(b.artifact, "base64")));
181
+ const manifestIndex = new Map(sorted.map((r, k) => [r.index, k]));
182
+ const setDigest = createHash("sha256").update("set:" + sorted.map((r) => r.artifact).join(",")).digest("base64");
144
183
  return {
145
- proof: proofFor(artifactDigestB64) as unknown as FusedSummary["proof"],
146
- frame: { type: "bitgraph-fuse/1" },
147
- placement: "container/1",
148
- artifactDigestB64,
149
- originDigestB64: digestB64,
184
+ set: opts.set,
185
+ proof: proofFor(setDigest),
186
+ artifactDigestB64: setDigest,
187
+ count: files.length,
188
+ manifestEchoed: true,
189
+ recovered: false,
190
+ members: rows.map((r) => {
191
+ const k = manifestIndex.get(r.index) as number;
192
+ return {
193
+ index: r.index,
194
+ manifestIndex: k,
195
+ placement: r.placement,
196
+ originDigestB64: r.origin,
197
+ artifactDigestB64: r.artifact,
198
+ ...(opts.set === "set/2"
199
+ ? { memberProof: { count: files.length, index: k, member: { artifact: { algorithm: "sha256", digest: "" }, origin: { algorithm: "sha256", digest: "" }, placement: r.placement }, path: [], placement: "set/2", type: "bitgraph-fuse/1" } }
200
+ : {}),
201
+ };
202
+ }),
150
203
  };
151
- }
204
+ };
205
+ /** A stand-in for the single-file pipeline: records what it was asked to fuse. */
206
+ const soloCalls: Array<{ name: string; digestB64: string }> = [];
207
+ const fakeFuseFile: FuseFileFn = async (file) => {
208
+ soloCalls.push({ name: file.name, digestB64: file.digestB64 });
209
+ if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
210
+ const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
211
+ return { proof: proofFor(artifactDigestB64), frame: { type: "bitgraph-fuse/1" }, placement: file.placement, artifactDigestB64, originDigestB64: file.digestB64 };
212
+ };
152
213
  async function connectedClient(): Promise<Client> {
153
- const server = buildServer({ fuseFile: fakeFuse });
214
+ const server = buildServer({ fuseSet: fakeFuseSet, fuseFile: fakeFuseFile });
154
215
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
155
216
  const client = new Client({ name: "test-client", version: "0.0.0" });
156
217
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
157
218
  return client;
158
219
  }
159
220
 
221
+ interface RecordStructured {
222
+ set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
223
+ frames: Record<string, unknown>;
224
+ results: Array<{ path: string; outcome: string; counter: string | null; placement: string | null; artifact_digest: string | null; member: number | null; member_count: number | null; total_positions: number; proof_url: string | null; error?: string }>;
225
+ omitted: number;
226
+ summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
227
+ }
228
+ const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
229
+
160
230
  test("lists the three tools", async () => {
161
231
  const client = await connectedClient();
162
232
  const tools = await client.listTools();
@@ -164,53 +234,206 @@ test("lists the three tools", async () => {
164
234
  assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
165
235
  });
166
236
 
167
- test("record dedups on-record files and fuses only fresh ones", async () => {
237
+ test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
168
238
  const client = await connectedClient();
169
239
  requests.length = 0;
170
- fusedCalls.length = 0;
240
+ setCalls.length = 0;
171
241
  const result = await client.callTool({
172
242
  name: "bitgraph_record",
173
- arguments: { paths: [fileA, fileB] },
243
+ arguments: { paths: [fileA, fileB, fileC] },
174
244
  });
175
245
  assert.ok(!result.isError, JSON.stringify(result.content));
176
- assert.equal(requests.filter((r) => r.path === "/api/commit").length, 0, "nothing is committed as bytes-only");
177
- assert.deepEqual(fusedCalls.map((c) => c.digestB64), [digestB], "only fileB fused; fileA was on record");
178
- assert.equal(fusedCalls[0]?.name, "b.txt", "the artifact is named after the file");
246
+ assert.equal(requests.filter((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")).length, 0, "the stand-in never reaches the boundary; nothing is committed as bytes-only");
247
+ assert.equal(setCalls.length, 1, "one set for the whole call");
248
+ assert.deepEqual(setCalls[0]?.digests, [digestB, digestC], "only the fresh files are members; fileA was on record");
249
+ assert.deepEqual(setCalls[0]?.names, ["b.txt", "c.txt"], "members are named after the files");
250
+ assert.equal(setCalls[0]?.set, "set/1");
179
251
  const batch = requests.find((r) => r.path === "/api/proofs/batch");
180
- if (!batch) throw new Error("no batch check preceded the fuse");
252
+ if (!batch) throw new Error("no batch check preceded the set");
181
253
  const batchBody = batch.body as { digests: string[] };
182
254
  assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
183
- const structured = result.structuredContent as {
184
- results: Array<{ path: string; outcome: string; counter: string | null; placement: string | null; artifact_digest: string | null }>;
185
- frames: Record<string, unknown>;
186
- summary: { fused: number; on_record: number; not_fused: number };
187
- };
188
- assert.equal(structured.summary.fused, 1);
189
- assert.equal(structured.summary.on_record, 1);
190
- assert.equal(structured.summary.not_fused, 0);
255
+ const structured = result.structuredContent as RecordStructured;
256
+ assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
257
+ assert.ok(structured.set, "the set is reported");
258
+ assert.equal(structured.set?.count, 2);
259
+ assert.ok(structured.set?.proof_url.includes("/proof/") && structured.set?.proof_url.includes("counter="), "the set's proof page is pinned to its position");
191
260
  const a = structured.results.find((r) => r.path === fileA);
192
261
  const b = structured.results.find((r) => r.path === fileB);
262
+ const c = structured.results.find((r) => r.path === fileC);
193
263
  assert.equal(a?.outcome, "on record");
194
264
  assert.equal(a?.counter, "10", "on-record outcome reports the EARLIEST position");
265
+ assert.equal(a?.member, 3, "an on-record set member reports its row, 1-based");
266
+ assert.equal(a?.member_count, 10);
195
267
  assert.equal(b?.outcome, "fused");
196
- assert.equal(b?.placement, "container/1");
197
- assert.ok(b?.artifact_digest && structured.frames[b.artifact_digest], "the Frame rides in the structured result under the fused digest");
268
+ assert.equal(b?.counter, structured.set?.counter, "a fused file's position is the set's");
269
+ assert.equal(b?.placement, "container/2", "text goes in a container");
270
+ assert.equal(b?.member_count, 2);
271
+ assert.ok(b?.member === 1 || b?.member === 2);
272
+ assert.notEqual(b?.member, c?.member, "two members, two rows");
273
+ assert.ok(b?.artifact_digest && !b.artifact_digest.includes("+"), "the member's fused digest, url-safe");
274
+ assert.ok(b?.proof_url?.includes(encodeURIComponent(toUrlSafeB64(digestB))), "the file's own digest finds the set");
275
+ const text = textOf(result);
276
+ assert.ok(text.startsWith("2 files BitGraphed as one set at #"), text);
277
+ assert.ok(text.includes("set of 2"), text);
198
278
  });
199
279
 
200
- test("record with again=true fuses even on-record files", async () => {
280
+ test("a single file is fused on its own, with its Frame; again=true fuses an on-record one", async () => {
201
281
  const client = await connectedClient();
202
- fusedCalls.length = 0;
282
+ setCalls.length = 0;
283
+ soloCalls.length = 0;
203
284
  const result = await client.callTool({
204
285
  name: "bitgraph_record",
205
286
  arguments: { paths: [fileA], again: true },
206
287
  });
207
288
  assert.ok(!result.isError, JSON.stringify(result.content));
208
- assert.deepEqual(fusedCalls.map((c) => c.digestB64), [digestA]);
209
- const structured = result.structuredContent as {
210
- results: Array<{ outcome: string; total_positions: number }>;
211
- };
212
- assert.equal(structured.results[0]?.outcome, "fused");
213
- assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new artifact");
289
+ assert.equal(setCalls.length, 0, "one file is not a set");
290
+ assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestA]);
291
+ const structured = result.structuredContent as RecordStructured;
292
+ assert.equal(structured.set, null);
293
+ const row = structured.results[0];
294
+ assert.equal(row?.outcome, "fused");
295
+ assert.equal(row?.member, null);
296
+ assert.equal(row?.total_positions, 3, "two prior positions plus the new BitGraph");
297
+ assert.ok(row?.artifact_digest && structured.frames[row.artifact_digest], "the Frame rides in the structured result under the fused digest");
298
+ assert.ok(row?.proof_url?.includes(encodeURIComponent(row.artifact_digest ?? "")), "a single file's proof page is its own");
299
+ const text = textOf(result);
300
+ assert.ok(text.startsWith("1 fused, 0 already on record."), text);
301
+ assert.ok(text.includes("its Frame is in the structured result"), text);
302
+ });
303
+
304
+ test("a directory is its regular files, hidden entries and links left out", async () => {
305
+ const client = await connectedClient();
306
+ setCalls.length = 0;
307
+ const result = await client.callTool({
308
+ name: "bitgraph_record",
309
+ arguments: { paths: [dir] },
310
+ });
311
+ assert.ok(!result.isError, JSON.stringify(result.content));
312
+ assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
313
+ const structured = result.structuredContent as RecordStructured;
314
+ assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
315
+ assert.equal(structured.set?.count, 2);
316
+ });
317
+
318
+ test("the same bytes under two paths are one file, fused once and reported twice", async () => {
319
+ const client = await connectedClient();
320
+ setCalls.length = 0;
321
+ soloCalls.length = 0;
322
+ const result = await client.callTool({
323
+ name: "bitgraph_record",
324
+ arguments: { paths: [fileB, copyB] },
325
+ });
326
+ assert.ok(!result.isError, JSON.stringify(result.content));
327
+ assert.equal(setCalls.length, 0, "one distinct file is fused on its own");
328
+ assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestB]);
329
+ const structured = result.structuredContent as RecordStructured;
330
+ assert.equal(structured.set, null);
331
+ assert.equal(structured.summary.fused, 2);
332
+ assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
333
+ });
334
+
335
+ test("above the set/1 cap the set is a set/2 and its evidence is indexed in chunks", async () => {
336
+ const client = await connectedClient();
337
+ requests.length = 0;
338
+ setCalls.length = 0;
339
+ indexMode = "ok";
340
+ const progress: string[] = [];
341
+ const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } }, undefined, {
342
+ onprogress: (p) => {
343
+ if (p.message !== undefined) progress.push(p.message);
344
+ },
345
+ });
346
+ assert.ok(!result.isError, JSON.stringify(result.content).slice(0, 500));
347
+ assert.equal(setCalls[0]?.set, "set/2");
348
+ assert.equal(setCalls[0]?.digests.length, BIG);
349
+ const index = requests.filter((r) => r.path === "/api/fuse/set-index");
350
+ assert.deepEqual(index.map((r) => (r.body as { members: unknown[] }).members.length), [SET_INDEX_CHUNK, BIG - SET_INDEX_CHUNK], "evidence goes in the site's chunks");
351
+ const body = index[0]?.body as { setDigest: string; epoch: string; counter: string };
352
+ assert.ok(!body.setDigest.includes("+") && !body.setDigest.includes("="), "the set digest travels url-safe");
353
+ assert.ok(!body.epoch.includes("+") && !body.epoch.includes("="), "so does the epoch");
354
+ const structured = result.structuredContent as RecordStructured;
355
+ assert.equal(body.counter, structured.set?.counter);
356
+ assert.equal(structured.set?.set, "set/2");
357
+ assert.deepEqual(structured.set?.index, { written: BIG, pending: 0 });
358
+ assert.equal(structured.summary.fused, BIG);
359
+ assert.equal(structured.results.length, ROW_CAP, "the structured rows are capped");
360
+ assert.equal(structured.omitted, BIG - ROW_CAP);
361
+ assert.equal(pendingIndexCount(), 0);
362
+ const text = textOf(result);
363
+ assert.ok(text.includes(`set of ${BIG.toLocaleString("en-US")}`), text.slice(0, 300));
364
+ assert.ok(text.includes("more files in the same set"), "the markdown lists a few rows and counts the rest");
365
+ assert.ok(progress.some((m) => /^hashed \d+ of \d+$/.test(m)), `progress notifications arrived: ${progress.slice(0, 3).join(" | ")}`);
366
+ assert.ok(progress.some((m) => /^indexing \d+ of \d+$/.test(m)), "indexing reports progress too");
367
+ });
368
+
369
+ test("evidence the site cannot index waits, blocks a new set, and is sent first once the site is back", async () => {
370
+ const client = await connectedClient();
371
+ indexMode = "fail";
372
+ const first = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } });
373
+ assert.ok(!first.isError, "the set was made; a failed index is reported, not an error");
374
+ const s1 = first.structuredContent as RecordStructured;
375
+ assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
376
+ assert.equal(pendingIndexCount(), BIG);
377
+ assert.ok(textOf(first).includes("not findable by hash"), textOf(first).slice(0, 400));
378
+
379
+ // Still down: nothing new is made, so the members cannot be made again by mistake.
380
+ requests.length = 0;
381
+ setCalls.length = 0;
382
+ const blocked = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
383
+ assert.ok(blocked.isError);
384
+ assert.ok(textOf(blocked).includes("waiting to be indexed"), textOf(blocked));
385
+ assert.equal(setCalls.length, 0, "no set was made");
386
+ assert.ok(!requests.some((r) => r.path === "/api/proofs/batch"), "the ledger was not even checked");
387
+ assert.equal(pendingIndexCount(), BIG);
388
+
389
+ // Back: the waiting evidence goes first, then the call proceeds.
390
+ indexMode = "ok";
391
+ requests.length = 0;
392
+ const next = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
393
+ assert.ok(!next.isError, JSON.stringify(next.content).slice(0, 400));
394
+ const firstBatch = requests.findIndex((r) => r.path === "/api/proofs/batch");
395
+ const indexCalls = requests.map((r, i) => ({ i, path: r.path })).filter((r) => r.path === "/api/fuse/set-index");
396
+ assert.equal(indexCalls.length, 2);
397
+ assert.ok(indexCalls.every((r) => r.i < firstBatch), "pending evidence is sent before the ledger check");
398
+ assert.equal(pendingIndexCount(), 0);
399
+ assert.equal((next.structuredContent as RecordStructured).summary.fused, 1);
400
+ });
401
+
402
+ test("a set failure labels every attempted file 'not fused', never 'on record'", async () => {
403
+ const client = await connectedClient();
404
+ fuseMode = "fail";
405
+ try {
406
+ const result = await client.callTool({
407
+ name: "bitgraph_record",
408
+ arguments: { paths: [fileA, fileC], again: true },
409
+ });
410
+ assert.ok(result.isError, "a failure must be an error result");
411
+ const text = textOf(result);
412
+ assert.ok(text.includes("Nothing was BitGraphed"), text);
413
+ assert.ok(text.includes("the boundary is restarting"), text);
414
+ const structured = result.structuredContent as RecordStructured;
415
+ assert.equal(structured.set, null);
416
+ assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
417
+ assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
418
+ } finally {
419
+ fuseMode = "ok";
420
+ }
421
+ });
422
+
423
+ test("record surfaces unreadable paths before any network call", async () => {
424
+ const client = await connectedClient();
425
+ requests.length = 0;
426
+ setCalls.length = 0;
427
+ const result = await client.callTool({
428
+ name: "bitgraph_record",
429
+ arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
430
+ });
431
+ assert.ok(result.isError);
432
+ assert.equal(requests.length, 0, "no API call happened; nothing was minted");
433
+ assert.equal(setCalls.length, 0);
434
+ const text = textOf(result);
435
+ assert.ok(text.includes("nothing was BitGraphed"), text);
436
+ assert.ok(text.includes("/definitely/missing/file.bin"), text);
214
437
  });
215
438
 
216
439
  test("check reports positions without ever committing", async () => {
@@ -221,15 +444,26 @@ test("check reports positions without ever committing", async () => {
221
444
  arguments: { paths: [fileB], digests: [toUrlSafeB64(digestA)] },
222
445
  });
223
446
  assert.ok(!result.isError, JSON.stringify(result.content));
224
- assert.ok(!requests.some((r) => r.path === "/api/commit"), "check never commits");
447
+ assert.ok(!requests.some((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")), "check never commits");
225
448
  const structured = result.structuredContent as {
226
- results: Array<{ input: string; on_record: boolean; positions: unknown[] }>;
449
+ results: Array<{ input: string; on_record: boolean; positions: Array<{ member?: { index: number; count: number } }> }>;
227
450
  };
228
451
  const a = structured.results.find((r) => r.input === toUrlSafeB64(digestA));
229
452
  const b = structured.results.find((r) => r.input === fileB);
230
453
  assert.equal(a?.on_record, true);
231
454
  assert.equal(a?.positions.length, 2);
455
+ assert.deepEqual(a?.positions[0]?.member, { index: 2, count: 10, role: "origin" });
232
456
  assert.equal(b?.on_record, false);
457
+ assert.ok(textOf(result).includes("member 3 of 10"), textOf(result));
458
+ });
459
+
460
+ test("check accepts a directory", async () => {
461
+ const client = await connectedClient();
462
+ const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [dir] } });
463
+ assert.ok(!result.isError, JSON.stringify(result.content));
464
+ const structured = result.structuredContent as { results: Array<{ input: string }>; summary: { not_on_record: number } };
465
+ assert.deepEqual(structured.results.map((r) => r.input), [join(dir, "a1.txt"), join(dir, "sub", "a2.txt")]);
466
+ assert.equal(structured.summary.not_on_record, 2);
233
467
  });
234
468
 
235
469
  test("check rejects malformed digests with an actionable error", async () => {
@@ -239,8 +473,7 @@ test("check rejects malformed digests with an actionable error", async () => {
239
473
  arguments: { digests: ["zzz"] },
240
474
  });
241
475
  assert.ok(result.isError);
242
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
243
- assert.ok(text.includes("not a base64 SHA-256 digest"));
476
+ assert.ok(textOf(result).includes("not a base64 SHA-256 digest"));
244
477
  });
245
478
 
246
479
  test("get_proof by number resolves through search and renders the window", async () => {
@@ -250,68 +483,40 @@ test("get_proof by number resolves through search and renders the window", async
250
483
  arguments: { number: "#10" },
251
484
  });
252
485
  assert.ok(!result.isError, JSON.stringify(result.content));
253
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
486
+ const text = textOf(result);
254
487
  assert.ok(text.includes("# BitGraph #10"));
255
488
  assert.ok(text.includes("BitGraphed between 2026-07-01T00:00:00.000Z"));
256
489
  assert.ok(text.includes("Causal positions (2)"));
257
490
  assert.ok(text.includes("/proof/"), "includes the proof page url");
258
491
  });
259
492
 
260
- test("get_proof for an unknown digest fails with guidance", async () => {
493
+ test("get_proof renders a set member's row", async () => {
261
494
  const client = await connectedClient();
262
495
  const result = await client.callTool({
263
496
  name: "bitgraph_get_proof",
264
- arguments: { digest: toUrlSafeB64(digestB) },
497
+ arguments: { path: fileB },
265
498
  });
266
- assert.ok(result.isError);
267
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
268
- assert.ok(text.includes("Not on record"));
499
+ assert.ok(!result.isError, JSON.stringify(result.content));
500
+ const text = textOf(result);
501
+ assert.ok(text.includes("# BitGraph #1386"), text);
502
+ assert.ok(text.includes("Set: member 5 of 50, as the original (container/2)"), text);
269
503
  });
270
504
 
271
- test("get_proof requires exactly one selector", async () => {
505
+ test("get_proof for an unknown digest fails with guidance", async () => {
272
506
  const client = await connectedClient();
273
507
  const result = await client.callTool({
274
508
  name: "bitgraph_get_proof",
275
- arguments: { digest: toUrlSafeB64(digestA), number: "10" },
509
+ arguments: { digest: toUrlSafeB64(digestC) },
276
510
  });
277
511
  assert.ok(result.isError);
512
+ assert.ok(textOf(result).includes("Not on record"));
278
513
  });
279
514
 
280
- test("a fuse failure labels the file 'not fused', never 'on record'", async () => {
281
- const client = await connectedClient();
282
- fuseMode = "fail";
283
- try {
284
- const result = await client.callTool({
285
- name: "bitgraph_record",
286
- arguments: { paths: [fileC] },
287
- });
288
- assert.ok(result.isError, "a failure must be an error result");
289
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
290
- assert.ok(text.includes("0 of 1"), text);
291
- assert.ok(text.includes("the boundary is restarting"), text);
292
- const structured = result.structuredContent as {
293
- results: Array<{ outcome: string; proof_url: string | null; error?: string }>;
294
- summary: { fused: number; on_record: number; not_fused: number };
295
- };
296
- assert.equal(structured.results[0]?.outcome, "not fused");
297
- assert.equal(structured.results[0]?.proof_url, null);
298
- assert.equal(structured.summary.not_fused, 1);
299
- assert.equal(structured.summary.fused, 0);
300
- assert.equal(structured.summary.on_record, 0);
301
- } finally {
302
- fuseMode = "ok";
303
- }
304
- });
305
-
306
- test("record surfaces unreadable paths before any network call", async () => {
515
+ test("get_proof requires exactly one selector", async () => {
307
516
  const client = await connectedClient();
308
- requests.length = 0;
309
517
  const result = await client.callTool({
310
- name: "bitgraph_record",
311
- arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
518
+ name: "bitgraph_get_proof",
519
+ arguments: { digest: toUrlSafeB64(digestA), number: "10" },
312
520
  });
313
521
  assert.ok(result.isError);
314
- assert.equal(requests.length, 0, "no API call happened; nothing was minted");
315
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
316
- assert.ok(text.includes("nothing was BitGraphed"));
317
522
  });