@mikeargento/bitgraph-mcp 0.3.0 → 0.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.
@@ -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 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,55 @@ 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
+ };
152
205
  async function connectedClient(): Promise<Client> {
153
- const server = buildServer({ fuseFile: fakeFuse });
206
+ const server = buildServer({ fuseSet: fakeFuseSet });
154
207
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
155
208
  const client = new Client({ name: "test-client", version: "0.0.0" });
156
209
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
157
210
  return client;
158
211
  }
159
212
 
213
+ interface RecordStructured {
214
+ set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
215
+ 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 }>;
216
+ omitted: number;
217
+ summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
218
+ }
219
+ const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
220
+
160
221
  test("lists the three tools", async () => {
161
222
  const client = await connectedClient();
162
223
  const tools = await client.listTools();
@@ -164,53 +225,194 @@ test("lists the three tools", async () => {
164
225
  assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
165
226
  });
166
227
 
167
- test("record dedups on-record files and fuses only fresh ones", async () => {
228
+ test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
168
229
  const client = await connectedClient();
169
230
  requests.length = 0;
170
- fusedCalls.length = 0;
231
+ setCalls.length = 0;
171
232
  const result = await client.callTool({
172
233
  name: "bitgraph_record",
173
- arguments: { paths: [fileA, fileB] },
234
+ arguments: { paths: [fileA, fileB, fileC] },
174
235
  });
175
236
  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");
237
+ 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");
238
+ assert.equal(setCalls.length, 1, "one set for the whole call");
239
+ assert.deepEqual(setCalls[0]?.digests, [digestB, digestC], "only the fresh files are members; fileA was on record");
240
+ assert.deepEqual(setCalls[0]?.names, ["b.txt", "c.txt"], "members are named after the files");
241
+ assert.equal(setCalls[0]?.set, "set/1");
179
242
  const batch = requests.find((r) => r.path === "/api/proofs/batch");
180
- if (!batch) throw new Error("no batch check preceded the fuse");
243
+ if (!batch) throw new Error("no batch check preceded the set");
181
244
  const batchBody = batch.body as { digests: string[] };
182
245
  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);
246
+ const structured = result.structuredContent as RecordStructured;
247
+ assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
248
+ assert.ok(structured.set, "the set is reported");
249
+ assert.equal(structured.set?.count, 2);
250
+ 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
251
  const a = structured.results.find((r) => r.path === fileA);
192
252
  const b = structured.results.find((r) => r.path === fileB);
253
+ const c = structured.results.find((r) => r.path === fileC);
193
254
  assert.equal(a?.outcome, "on record");
194
255
  assert.equal(a?.counter, "10", "on-record outcome reports the EARLIEST position");
256
+ assert.equal(a?.member, 3, "an on-record set member reports its row, 1-based");
257
+ assert.equal(a?.member_count, 10);
195
258
  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");
259
+ assert.equal(b?.counter, structured.set?.counter, "a fused file's position is the set's");
260
+ assert.equal(b?.placement, "container/2", "text goes in a container");
261
+ assert.equal(b?.member_count, 2);
262
+ assert.ok(b?.member === 1 || b?.member === 2);
263
+ assert.notEqual(b?.member, c?.member, "two members, two rows");
264
+ assert.ok(b?.artifact_digest && !b.artifact_digest.includes("+"), "the member's fused digest, url-safe");
265
+ assert.ok(b?.proof_url?.includes(encodeURIComponent(toUrlSafeB64(digestB))), "the file's own digest finds the set");
266
+ const text = textOf(result);
267
+ assert.ok(text.startsWith("2 files BitGraphed as one set at #"), text);
268
+ assert.ok(text.includes("set of 2"), text);
198
269
  });
199
270
 
200
- test("record with again=true fuses even on-record files", async () => {
271
+ test("record with again=true puts on-record files in the set", async () => {
201
272
  const client = await connectedClient();
202
- fusedCalls.length = 0;
273
+ setCalls.length = 0;
203
274
  const result = await client.callTool({
204
275
  name: "bitgraph_record",
205
276
  arguments: { paths: [fileA], again: true },
206
277
  });
207
278
  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
- };
279
+ assert.deepEqual(setCalls.map((c) => c.digests), [[digestA]]);
280
+ const structured = result.structuredContent as RecordStructured;
212
281
  assert.equal(structured.results[0]?.outcome, "fused");
213
- assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new artifact");
282
+ assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new set");
283
+ });
284
+
285
+ test("a directory is its regular files, hidden entries and links left out", async () => {
286
+ const client = await connectedClient();
287
+ setCalls.length = 0;
288
+ const result = await client.callTool({
289
+ name: "bitgraph_record",
290
+ arguments: { paths: [dir] },
291
+ });
292
+ assert.ok(!result.isError, JSON.stringify(result.content));
293
+ assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
294
+ const structured = result.structuredContent as RecordStructured;
295
+ assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
296
+ assert.equal(structured.set?.count, 2);
297
+ });
298
+
299
+ test("the same bytes under two paths are one member, reported twice", async () => {
300
+ const client = await connectedClient();
301
+ setCalls.length = 0;
302
+ const result = await client.callTool({
303
+ name: "bitgraph_record",
304
+ arguments: { paths: [fileB, copyB] },
305
+ });
306
+ assert.ok(!result.isError, JSON.stringify(result.content));
307
+ assert.deepEqual(setCalls[0]?.digests, [digestB]);
308
+ const structured = result.structuredContent as RecordStructured;
309
+ assert.equal(structured.set?.count, 1);
310
+ assert.equal(structured.summary.fused, 2);
311
+ assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
312
+ });
313
+
314
+ test("above the set/1 cap the set is a set/2 and its evidence is indexed in chunks", async () => {
315
+ const client = await connectedClient();
316
+ requests.length = 0;
317
+ setCalls.length = 0;
318
+ indexMode = "ok";
319
+ const progress: string[] = [];
320
+ const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } }, undefined, {
321
+ onprogress: (p) => {
322
+ if (p.message !== undefined) progress.push(p.message);
323
+ },
324
+ });
325
+ assert.ok(!result.isError, JSON.stringify(result.content).slice(0, 500));
326
+ assert.equal(setCalls[0]?.set, "set/2");
327
+ assert.equal(setCalls[0]?.digests.length, BIG);
328
+ const index = requests.filter((r) => r.path === "/api/fuse/set-index");
329
+ 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");
330
+ const body = index[0]?.body as { setDigest: string; epoch: string; counter: string };
331
+ assert.ok(!body.setDigest.includes("+") && !body.setDigest.includes("="), "the set digest travels url-safe");
332
+ assert.ok(!body.epoch.includes("+") && !body.epoch.includes("="), "so does the epoch");
333
+ const structured = result.structuredContent as RecordStructured;
334
+ assert.equal(body.counter, structured.set?.counter);
335
+ assert.equal(structured.set?.set, "set/2");
336
+ assert.deepEqual(structured.set?.index, { written: BIG, pending: 0 });
337
+ assert.equal(structured.summary.fused, BIG);
338
+ assert.equal(structured.results.length, ROW_CAP, "the structured rows are capped");
339
+ assert.equal(structured.omitted, BIG - ROW_CAP);
340
+ assert.equal(pendingIndexCount(), 0);
341
+ const text = textOf(result);
342
+ assert.ok(text.includes(`set of ${BIG.toLocaleString("en-US")}`), text.slice(0, 300));
343
+ assert.ok(text.includes("more files in the same set"), "the markdown lists a few rows and counts the rest");
344
+ assert.ok(progress.some((m) => /^hashed \d+ of \d+$/.test(m)), `progress notifications arrived: ${progress.slice(0, 3).join(" | ")}`);
345
+ assert.ok(progress.some((m) => /^indexing \d+ of \d+$/.test(m)), "indexing reports progress too");
346
+ });
347
+
348
+ test("evidence the site cannot index waits, blocks a new set, and is sent first once the site is back", async () => {
349
+ const client = await connectedClient();
350
+ indexMode = "fail";
351
+ const first = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } });
352
+ assert.ok(!first.isError, "the set was made; a failed index is reported, not an error");
353
+ const s1 = first.structuredContent as RecordStructured;
354
+ assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
355
+ assert.equal(pendingIndexCount(), BIG);
356
+ assert.ok(textOf(first).includes("not findable by hash"), textOf(first).slice(0, 400));
357
+
358
+ // Still down: nothing new is made, so the members cannot be made again by mistake.
359
+ requests.length = 0;
360
+ setCalls.length = 0;
361
+ const blocked = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
362
+ assert.ok(blocked.isError);
363
+ assert.ok(textOf(blocked).includes("waiting to be indexed"), textOf(blocked));
364
+ assert.equal(setCalls.length, 0, "no set was made");
365
+ assert.ok(!requests.some((r) => r.path === "/api/proofs/batch"), "the ledger was not even checked");
366
+ assert.equal(pendingIndexCount(), BIG);
367
+
368
+ // Back: the waiting evidence goes first, then the call proceeds.
369
+ indexMode = "ok";
370
+ requests.length = 0;
371
+ const next = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
372
+ assert.ok(!next.isError, JSON.stringify(next.content).slice(0, 400));
373
+ const firstBatch = requests.findIndex((r) => r.path === "/api/proofs/batch");
374
+ const indexCalls = requests.map((r, i) => ({ i, path: r.path })).filter((r) => r.path === "/api/fuse/set-index");
375
+ assert.equal(indexCalls.length, 2);
376
+ assert.ok(indexCalls.every((r) => r.i < firstBatch), "pending evidence is sent before the ledger check");
377
+ assert.equal(pendingIndexCount(), 0);
378
+ assert.equal((next.structuredContent as RecordStructured).summary.fused, 1);
379
+ });
380
+
381
+ test("a set failure labels every attempted file 'not fused', never 'on record'", async () => {
382
+ const client = await connectedClient();
383
+ fuseMode = "fail";
384
+ try {
385
+ const result = await client.callTool({
386
+ name: "bitgraph_record",
387
+ arguments: { paths: [fileA, fileC], again: true },
388
+ });
389
+ assert.ok(result.isError, "a failure must be an error result");
390
+ const text = textOf(result);
391
+ assert.ok(text.includes("Nothing was BitGraphed"), text);
392
+ assert.ok(text.includes("the boundary is restarting"), text);
393
+ const structured = result.structuredContent as RecordStructured;
394
+ assert.equal(structured.set, null);
395
+ assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
396
+ assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
397
+ } finally {
398
+ fuseMode = "ok";
399
+ }
400
+ });
401
+
402
+ test("record surfaces unreadable paths before any network call", async () => {
403
+ const client = await connectedClient();
404
+ requests.length = 0;
405
+ setCalls.length = 0;
406
+ const result = await client.callTool({
407
+ name: "bitgraph_record",
408
+ arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
409
+ });
410
+ assert.ok(result.isError);
411
+ assert.equal(requests.length, 0, "no API call happened; nothing was minted");
412
+ assert.equal(setCalls.length, 0);
413
+ const text = textOf(result);
414
+ assert.ok(text.includes("nothing was BitGraphed"), text);
415
+ assert.ok(text.includes("/definitely/missing/file.bin"), text);
214
416
  });
215
417
 
216
418
  test("check reports positions without ever committing", async () => {
@@ -221,15 +423,26 @@ test("check reports positions without ever committing", async () => {
221
423
  arguments: { paths: [fileB], digests: [toUrlSafeB64(digestA)] },
222
424
  });
223
425
  assert.ok(!result.isError, JSON.stringify(result.content));
224
- assert.ok(!requests.some((r) => r.path === "/api/commit"), "check never commits");
426
+ assert.ok(!requests.some((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")), "check never commits");
225
427
  const structured = result.structuredContent as {
226
- results: Array<{ input: string; on_record: boolean; positions: unknown[] }>;
428
+ results: Array<{ input: string; on_record: boolean; positions: Array<{ member?: { index: number; count: number } }> }>;
227
429
  };
228
430
  const a = structured.results.find((r) => r.input === toUrlSafeB64(digestA));
229
431
  const b = structured.results.find((r) => r.input === fileB);
230
432
  assert.equal(a?.on_record, true);
231
433
  assert.equal(a?.positions.length, 2);
434
+ assert.deepEqual(a?.positions[0]?.member, { index: 2, count: 10, role: "origin" });
232
435
  assert.equal(b?.on_record, false);
436
+ assert.ok(textOf(result).includes("member 3 of 10"), textOf(result));
437
+ });
438
+
439
+ test("check accepts a directory", async () => {
440
+ const client = await connectedClient();
441
+ const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [dir] } });
442
+ assert.ok(!result.isError, JSON.stringify(result.content));
443
+ const structured = result.structuredContent as { results: Array<{ input: string }>; summary: { not_on_record: number } };
444
+ assert.deepEqual(structured.results.map((r) => r.input), [join(dir, "a1.txt"), join(dir, "sub", "a2.txt")]);
445
+ assert.equal(structured.summary.not_on_record, 2);
233
446
  });
234
447
 
235
448
  test("check rejects malformed digests with an actionable error", async () => {
@@ -239,8 +452,7 @@ test("check rejects malformed digests with an actionable error", async () => {
239
452
  arguments: { digests: ["zzz"] },
240
453
  });
241
454
  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"));
455
+ assert.ok(textOf(result).includes("not a base64 SHA-256 digest"));
244
456
  });
245
457
 
246
458
  test("get_proof by number resolves through search and renders the window", async () => {
@@ -250,68 +462,40 @@ test("get_proof by number resolves through search and renders the window", async
250
462
  arguments: { number: "#10" },
251
463
  });
252
464
  assert.ok(!result.isError, JSON.stringify(result.content));
253
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
465
+ const text = textOf(result);
254
466
  assert.ok(text.includes("# BitGraph #10"));
255
467
  assert.ok(text.includes("BitGraphed between 2026-07-01T00:00:00.000Z"));
256
468
  assert.ok(text.includes("Causal positions (2)"));
257
469
  assert.ok(text.includes("/proof/"), "includes the proof page url");
258
470
  });
259
471
 
260
- test("get_proof for an unknown digest fails with guidance", async () => {
472
+ test("get_proof renders a set member's row", async () => {
261
473
  const client = await connectedClient();
262
474
  const result = await client.callTool({
263
475
  name: "bitgraph_get_proof",
264
- arguments: { digest: toUrlSafeB64(digestB) },
476
+ arguments: { path: fileB },
265
477
  });
266
- assert.ok(result.isError);
267
- const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
268
- assert.ok(text.includes("Not on record"));
478
+ assert.ok(!result.isError, JSON.stringify(result.content));
479
+ const text = textOf(result);
480
+ assert.ok(text.includes("# BitGraph #1386"), text);
481
+ assert.ok(text.includes("Set: member 5 of 50, as the original (container/2)"), text);
269
482
  });
270
483
 
271
- test("get_proof requires exactly one selector", async () => {
484
+ test("get_proof for an unknown digest fails with guidance", async () => {
272
485
  const client = await connectedClient();
273
486
  const result = await client.callTool({
274
487
  name: "bitgraph_get_proof",
275
- arguments: { digest: toUrlSafeB64(digestA), number: "10" },
488
+ arguments: { digest: toUrlSafeB64(digestC) },
276
489
  });
277
490
  assert.ok(result.isError);
491
+ assert.ok(textOf(result).includes("Not on record"));
278
492
  });
279
493
 
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 () => {
494
+ test("get_proof requires exactly one selector", async () => {
307
495
  const client = await connectedClient();
308
- requests.length = 0;
309
496
  const result = await client.callTool({
310
- name: "bitgraph_record",
311
- arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
497
+ name: "bitgraph_get_proof",
498
+ arguments: { digest: toUrlSafeB64(digestA), number: "10" },
312
499
  });
313
500
  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
501
  });