@nanobpm/urban 0.68.1 → 0.69.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.
@@ -0,0 +1,898 @@
1
+ // Slice S3 (git/governance) — integration tests for the write + governance
2
+ // layer. Every test drives a LOCAL temporary git repository as the substrate;
3
+ // nothing touches the network.
4
+
5
+ import assert from "node:assert/strict";
6
+ import { execFile } from "node:child_process";
7
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { after, test } from "node:test";
11
+ import { promisify } from "node:util";
12
+ import { PiiGuardError } from "../pii/index.ts";
13
+ import type { MemoryRecord } from "../schema/index.ts";
14
+ import { LAYOUT_ROOT } from "./layout.ts";
15
+ import { type CommitAuthor, GitWriteSubstrate, type WriteSubstrate } from "./substrate.ts";
16
+ import { ContextWriter, GovernanceError, serialiseRecord } from "./writer.ts";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const TEMP_ROOTS: string[] = [];
20
+
21
+ after(async () => {
22
+ await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
23
+ });
24
+
25
+ async function git(cwd: string, ...args: string[]): Promise<string> {
26
+ const { stdout } = await execFileAsync("git", args, { cwd });
27
+ return stdout.trim();
28
+ }
29
+
30
+ /** Create a temp git repo with an initial commit on `main` as the substrate. */
31
+ async function makeSubstrate(): Promise<string> {
32
+ const dir = await mkdtemp(join(tmpdir(), "s3-substrate-"));
33
+ TEMP_ROOTS.push(dir);
34
+ await git(dir, "init");
35
+ await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
36
+ await git(dir, "add", "-A");
37
+ await git(
38
+ dir,
39
+ "-c",
40
+ "user.name=seed",
41
+ "-c",
42
+ "user.email=seed@nanobpm.local",
43
+ "commit",
44
+ "--no-gpg-sign",
45
+ "-m",
46
+ "seed",
47
+ );
48
+ // Normalise the default branch name to `main` regardless of git's default.
49
+ await git(dir, "branch", "-M", "main");
50
+ return dir;
51
+ }
52
+
53
+ function record(overrides: Partial<MemoryRecord>): MemoryRecord {
54
+ const base: MemoryRecord = {
55
+ schemaVersion: 1,
56
+ id: "rec-1",
57
+ scope: "epic",
58
+ scopeRef: "issue-303",
59
+ mode: "empirical",
60
+ provenance: "measured",
61
+ authority: "authoritative",
62
+ statement: "the measured throughput was 42 rps",
63
+ createdAt: "2026-01-01T00:00:00Z",
64
+ };
65
+ return { ...base, ...overrides };
66
+ }
67
+
68
+ async function exists(path: string): Promise<boolean> {
69
+ try {
70
+ await access(path);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A minimal in-memory {@link WriteSubstrate} that fails at a chosen step, used to
79
+ * prove the writer restores the shared working tree to its resolved base after a
80
+ * mid-operation failure (without needing to force a real git failure).
81
+ */
82
+ class FailingSubstrate implements WriteSubstrate {
83
+ readonly rootPath = "/virtual/substrate";
84
+ readonly restored: string[] = [];
85
+ #current = "main";
86
+ readonly #failAt: "stageAndCommit";
87
+
88
+ constructor(failAt: "stageAndCommit") {
89
+ this.#failAt = failAt;
90
+ }
91
+
92
+ get current(): string {
93
+ return this.#current;
94
+ }
95
+
96
+ async writeRecordFile(_relPath: string, _content: string): Promise<void> {}
97
+ async readFileAtRef(_ref: string, _relPath: string): Promise<string> {
98
+ return "{}";
99
+ }
100
+ async diffPaths(_baseRef: string, _branch: string): Promise<readonly string[]> {
101
+ return [];
102
+ }
103
+ async currentBranch(): Promise<string> {
104
+ return this.#current;
105
+ }
106
+ async createBranch(name: string, _from: string): Promise<void> {
107
+ this.#current = name;
108
+ }
109
+ async checkout(ref: string): Promise<void> {
110
+ this.#current = ref;
111
+ }
112
+ async stageAndCommit(
113
+ _pathspec: string,
114
+ _message: string,
115
+ _author: CommitAuthor,
116
+ ): Promise<string> {
117
+ if (this.#failAt === "stageAndCommit") {
118
+ throw new Error("simulated commit failure");
119
+ }
120
+ return "commit-sha";
121
+ }
122
+ async mergeBranch(_branch: string, _message: string, _author: CommitAuthor): Promise<string> {
123
+ return "merge-sha";
124
+ }
125
+ async branchExists(_name: string): Promise<boolean> {
126
+ return true;
127
+ }
128
+ async isMerged(_commitish: string, _ref: string): Promise<boolean> {
129
+ return false;
130
+ }
131
+ async restoreClean(ref: string, _pathspec: string): Promise<void> {
132
+ this.#current = ref;
133
+ this.restored.push(ref);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Wraps a REAL {@link GitWriteSubstrate} but makes `mergeBranch` throw, so a
139
+ * ratifying merge fails AFTER its `checkout(base)` has already moved the shared
140
+ * working tree. Every other step (including the `restoreClean` cleanup) runs
141
+ * against the real repo, and `restored` records whether the `finally` cleanup
142
+ * fired — proving `ratify` restores the tree like `appendRecord`/`proposePrior`.
143
+ */
144
+ class MergeFailingSubstrate implements WriteSubstrate {
145
+ readonly restored: string[] = [];
146
+ readonly #inner: GitWriteSubstrate;
147
+
148
+ constructor(inner: GitWriteSubstrate) {
149
+ this.#inner = inner;
150
+ }
151
+
152
+ get rootPath(): string {
153
+ return this.#inner.rootPath;
154
+ }
155
+ writeRecordFile(relPath: string, content: string): Promise<void> {
156
+ return this.#inner.writeRecordFile(relPath, content);
157
+ }
158
+ readFileAtRef(ref: string, relPath: string): Promise<string> {
159
+ return this.#inner.readFileAtRef(ref, relPath);
160
+ }
161
+ diffPaths(baseRef: string, branch: string): Promise<readonly string[]> {
162
+ return this.#inner.diffPaths(baseRef, branch);
163
+ }
164
+ currentBranch(): Promise<string> {
165
+ return this.#inner.currentBranch();
166
+ }
167
+ createBranch(name: string, from: string): Promise<void> {
168
+ return this.#inner.createBranch(name, from);
169
+ }
170
+ checkout(ref: string): Promise<void> {
171
+ return this.#inner.checkout(ref);
172
+ }
173
+ stageAndCommit(pathspec: string, message: string, author: CommitAuthor): Promise<string> {
174
+ return this.#inner.stageAndCommit(pathspec, message, author);
175
+ }
176
+ async mergeBranch(
177
+ _branch: string,
178
+ _message: string,
179
+ _author: CommitAuthor,
180
+ ): Promise<string> {
181
+ throw new Error("simulated merge failure");
182
+ }
183
+ branchExists(name: string): Promise<boolean> {
184
+ return this.#inner.branchExists(name);
185
+ }
186
+ isMerged(commitish: string, ref: string): Promise<boolean> {
187
+ return this.#inner.isMerged(commitish, ref);
188
+ }
189
+ async restoreClean(ref: string, pathspec: string): Promise<void> {
190
+ this.restored.push(ref);
191
+ await this.#inner.restoreClean(ref, pathspec);
192
+ }
193
+ }
194
+
195
+ test("appendRecord persists a measured fact on the base branch", async () => {
196
+ const dir = await makeSubstrate();
197
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
198
+
199
+ const result = await writer.appendRecord(record({ id: "measured-1" }));
200
+
201
+ assert.equal(result.branch, "main");
202
+ assert.equal(result.ratified, true);
203
+ assert.equal(result.path, `${LAYOUT_ROOT}/epic/issue-303/measured-1.json`);
204
+
205
+ // The record file is on the base branch working tree...
206
+ const onDisk = join(dir, result.path);
207
+ assert.ok(await exists(onDisk), "record file should exist on main");
208
+ const parsed = JSON.parse(await readFile(onDisk, "utf8"));
209
+ assert.equal(parsed.id, "measured-1");
210
+ assert.equal(parsed.provenance, "measured");
211
+
212
+ // ...and the merge landed a real commit.
213
+ const head = await git(dir, "rev-parse", "HEAD");
214
+ assert.equal(head, result.mergeCommit);
215
+ });
216
+
217
+ test("stageAndCommit stages ONLY the given pathspec — no blanket `git add -A` PII-guard bypass", async () => {
218
+ const dir = await makeSubstrate();
219
+ const substrate = new GitWriteSubstrate(dir);
220
+ const relPath = `${LAYOUT_ROOT}/epic/issue-303/scoped-1.json`;
221
+ await substrate.writeRecordFile(relPath, '{"id":"scoped-1"}\n');
222
+ // A stray, unrelated file the per-record PII guard never inspected. A blanket
223
+ // `git add -A` would sweep it (and any PII in it) into the commit; scoped
224
+ // staging must leave it untracked.
225
+ await writeFile(join(dir, "STRAY_UNGUARDED.txt"), "ssn 123-45-6789\n", "utf8");
226
+
227
+ const author: CommitAuthor = { name: "bot", email: "bot@nanobpm.local" };
228
+ const sha = await substrate.stageAndCommit(relPath, "context(test): scoped stage", author);
229
+
230
+ // Only the record path is in the commit...
231
+ const committed = await git(dir, "show", "--name-only", "--format=", sha);
232
+ const files = committed.split("\n").filter((line) => line.length > 0);
233
+ assert.deepEqual(files, [relPath], "only the record path may be committed");
234
+ // ...and the unguarded stray file was NOT swept in; it stays untracked.
235
+ const status = await git(dir, "status", "--porcelain", "--", "STRAY_UNGUARDED.txt");
236
+ assert.equal(status, "?? STRAY_UNGUARDED.txt", "stray file must remain untracked");
237
+ });
238
+
239
+ test("baseBranch defaults to the resolved handle's ref, not the checked-out branch", async () => {
240
+ const dir = await makeSubstrate();
241
+ // Diverge the working copy onto another branch...
242
+ await git(dir, "checkout", "-b", "scratch");
243
+ // ...but the handle was resolved (by S1) against `main`.
244
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
245
+
246
+ const result = await writer.appendRecord(record({ id: "on-main" }));
247
+
248
+ // The record lands on the S1-resolved ref (main), never the checked-out branch.
249
+ assert.equal(result.branch, "main");
250
+ assert.equal(await git(dir, "rev-parse", "main"), result.mergeCommit);
251
+ });
252
+
253
+ test("appendRecord REJECTS an agent-retro record (must go through governance)", async () => {
254
+ const dir = await makeSubstrate();
255
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
256
+
257
+ await assert.rejects(
258
+ () =>
259
+ writer.appendRecord(
260
+ record({
261
+ id: "retro-1",
262
+ provenance: "agent-retro",
263
+ authority: "hypothesis",
264
+ }),
265
+ ),
266
+ GovernanceError,
267
+ );
268
+
269
+ // Nothing was committed — governance rejected the agent-retro record via
270
+ // GovernanceError (in #assertDirectlyAppendable, ahead of the PII guard),
271
+ // before any git write.
272
+ assert.ok(!(await exists(join(dir, `${LAYOUT_ROOT}/epic/issue-303/retro-1.json`))));
273
+ });
274
+
275
+ test("proposePrior writes an UNMERGED bot proposal; ratify merges it", async () => {
276
+ const dir = await makeSubstrate();
277
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
278
+
279
+ const proposal = await writer.proposePrior(
280
+ record({ id: "retro-2", provenance: "agent-retro", authority: "hypothesis" }),
281
+ );
282
+
283
+ assert.equal(proposal.ratified, false);
284
+ assert.equal(proposal.baseBranch, "main");
285
+ assert.ok(proposal.branch.startsWith("context/proposal/"));
286
+
287
+ // The proposal is NOT on main yet — it is an unratified hypothesis.
288
+ assert.equal(await writer.isRatified(proposal), false);
289
+ assert.ok(
290
+ !(await exists(join(dir, proposal.path))),
291
+ "unratified proposal must not appear on the base branch",
292
+ );
293
+ // ...but it IS committed on the proposal branch (work is preserved).
294
+ const onBranch = await git(dir, "show", `${proposal.branch}:${proposal.path}`);
295
+ assert.ok(onBranch.includes("retro-2"));
296
+
297
+ // Ratify == merge the bot PR.
298
+ const ratified = await writer.ratify(proposal);
299
+ assert.equal(ratified.baseBranch, "main");
300
+ assert.equal(await writer.isRatified(proposal), true);
301
+ assert.ok(await exists(join(dir, proposal.path)), "ratified record must be on main");
302
+
303
+ // The record's authority is still a hypothesis — ratification accepts it onto
304
+ // the line, it never forges it into an authoritative fact.
305
+ const parsed = JSON.parse(await readFile(join(dir, proposal.path), "utf8"));
306
+ assert.equal(parsed.provenance, "agent-retro");
307
+ assert.equal(parsed.authority, "hypothesis");
308
+ });
309
+
310
+ test("proposePrior builds a git-ref-valid branch for an id git would otherwise reject (e.g. a `.lock` suffix)", async () => {
311
+ const dir = await makeSubstrate();
312
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
313
+
314
+ // A schema-valid (non-empty-string) id whose sanitized segment would end in
315
+ // `.lock` — a suffix git reserves for its lock files and rejects in a refname.
316
+ // sanitizeRef must normalise it so the proposal branch is a name git accepts.
317
+ const proposal = await writer.proposePrior(
318
+ record({ id: "foo.lock", provenance: "agent-retro", authority: "hypothesis" }),
319
+ );
320
+
321
+ assert.ok(proposal.branch.startsWith("context/proposal/"));
322
+ // git itself must accept the generated branch name.
323
+ await git(dir, "check-ref-format", "--branch", proposal.branch);
324
+ // No path component of the ref ends in `.lock`.
325
+ assert.ok(
326
+ !proposal.branch.split("/").some((seg) => seg.endsWith(".lock")),
327
+ "no ref component may end in `.lock`",
328
+ );
329
+ // The proposal still round-trips through ratify onto the base branch.
330
+ await writer.ratify(proposal);
331
+ assert.ok(await exists(join(dir, proposal.path)), "ratified `.lock`-id record must be on main");
332
+ });
333
+
334
+ test("DEFAULT write path rejects a PII-carrying record (guard active by construction)", async () => {
335
+ const dir = await makeSubstrate();
336
+ // NO special guard wiring — the mandatory S6 guard must be active by default.
337
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
338
+
339
+ const pii = record({
340
+ id: "pii-1",
341
+ provenance: "human",
342
+ mode: "normative",
343
+ authority: "hypothesis",
344
+ statement: "escalate to the on-call at jane.doe@example.com immediately",
345
+ });
346
+
347
+ await assert.rejects(() => writer.appendRecord(pii), PiiGuardError);
348
+
349
+ // The mandatory PII guard is present in the default registry...
350
+ assert.ok(writer.guards.length >= 1);
351
+ // ...and no commit was made — PII is rejected BEFORE any write.
352
+ assert.ok(!(await exists(join(dir, `${LAYOUT_ROOT}/epic/issue-303/pii-1.json`))));
353
+
354
+ // The proposal path is guarded identically.
355
+ await assert.rejects(
356
+ () =>
357
+ writer.proposePrior(
358
+ record({
359
+ id: "pii-2",
360
+ provenance: "agent-retro",
361
+ authority: "hypothesis",
362
+ statement: "reach the owner on 415-555-0132 about the retro",
363
+ }),
364
+ ),
365
+ PiiGuardError,
366
+ );
367
+ });
368
+
369
+ test("clean content still passes the default guard", async () => {
370
+ const dir = await makeSubstrate();
371
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
372
+ const result = await writer.appendRecord(
373
+ record({ id: "clean-1", statement: "the retry budget is three attempts" }),
374
+ );
375
+ assert.ok(await exists(join(dir, result.path)));
376
+ });
377
+
378
+ test("ratify RE-RUNS the mandatory PII guard against the proposal branch content", async () => {
379
+ const dir = await makeSubstrate();
380
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
381
+
382
+ // A clean proposal lands on its bot branch (passes the guard at propose time).
383
+ const proposal = await writer.proposePrior(
384
+ record({ id: "retro-tamper", provenance: "agent-retro", authority: "hypothesis" }),
385
+ );
386
+
387
+ // Simulate a proposal branch created/amended OUTSIDE proposePrior: overwrite the
388
+ // proposed record with PII-carrying content and commit it on the proposal branch.
389
+ await git(dir, "checkout", proposal.branch);
390
+ const tampered = record({
391
+ id: "retro-tamper",
392
+ provenance: "agent-retro",
393
+ authority: "hypothesis",
394
+ statement: "page the owner at jane.doe@example.com about the retro",
395
+ });
396
+ await writeFile(join(dir, proposal.path), `${JSON.stringify(tampered, null, 2)}\n`, "utf8");
397
+ await git(dir, "add", "-A");
398
+ await git(
399
+ dir,
400
+ "-c",
401
+ "user.name=attacker",
402
+ "-c",
403
+ "user.email=attacker@nanobpm.local",
404
+ "commit",
405
+ "--no-gpg-sign",
406
+ "-m",
407
+ "tamper: inject PII",
408
+ );
409
+ await git(dir, "checkout", "main");
410
+
411
+ // Ratify must re-guard the proposal content and REFUSE to merge the PII record.
412
+ await assert.rejects(() => writer.ratify(proposal), PiiGuardError);
413
+
414
+ // The PII record never reached the authoritative line.
415
+ assert.equal(await writer.isRatified(proposal), false);
416
+ assert.ok(
417
+ !(await exists(join(dir, proposal.path))),
418
+ "a PII-carrying proposal must never be ratified onto the base branch",
419
+ );
420
+ // The working tree is left clean for the next operation (no dangling merge).
421
+ assert.equal(await git(dir, "status", "--porcelain"), "");
422
+ });
423
+
424
+ test("ratify REJECTS a proposal branch that smuggles extra changes past the single-file guard", async () => {
425
+ const dir = await makeSubstrate();
426
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
427
+
428
+ const proposal = await writer.proposePrior(
429
+ record({ id: "retro-smuggle", provenance: "agent-retro", authority: "hypothesis" }),
430
+ );
431
+
432
+ // Amend the proposal branch to ALSO add an unrelated, PII-carrying record that a
433
+ // single-file re-guard of `proposal.path` would never inspect — the merge would
434
+ // otherwise land it on the authoritative line UNGUARDED.
435
+ await git(dir, "checkout", proposal.branch);
436
+ const smuggled = record({
437
+ id: "smuggled",
438
+ provenance: "agent-retro",
439
+ authority: "hypothesis",
440
+ statement: "page the owner at jane.doe@example.com now",
441
+ });
442
+ const smuggledPath = `${LAYOUT_ROOT}/epic/issue-303/smuggled.json`;
443
+ await writeFile(join(dir, smuggledPath), `${JSON.stringify(smuggled, null, 2)}\n`, "utf8");
444
+ await git(dir, "add", "-A");
445
+ await git(
446
+ dir,
447
+ "-c",
448
+ "user.name=attacker",
449
+ "-c",
450
+ "user.email=attacker@nanobpm.local",
451
+ "commit",
452
+ "--no-gpg-sign",
453
+ "-m",
454
+ "smuggle: add extra unguarded record",
455
+ );
456
+ await git(dir, "checkout", "main");
457
+
458
+ // ratify must refuse: the branch↔base diff is not limited to the one proposed file.
459
+ await assert.rejects(() => writer.ratify(proposal), GovernanceError);
460
+
461
+ // Neither the proposed nor the smuggled record reached the authoritative line.
462
+ assert.equal(await writer.isRatified(proposal), false);
463
+ assert.ok(
464
+ !(await exists(join(dir, smuggledPath))),
465
+ "a smuggled extra file must never be ratified onto the base branch",
466
+ );
467
+ assert.ok(!(await exists(join(dir, proposal.path))));
468
+ assert.equal(await git(dir, "status", "--porcelain"), "");
469
+ });
470
+
471
+ test("ratify REJECTS a proposal whose on-disk path mismatches the record's canonical layout", async () => {
472
+ const dir = await makeSubstrate();
473
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
474
+
475
+ const proposal = await writer.proposePrior(
476
+ record({ id: "retro-path", provenance: "agent-retro", authority: "hypothesis" }),
477
+ );
478
+
479
+ // Amend the record at the SAME path but with a different id, so its canonical
480
+ // layout path (recordRelativePath) no longer matches `proposal.path`. The content
481
+ // is clean (passes the PII guard), isolating the canonical-path defence.
482
+ await git(dir, "checkout", proposal.branch);
483
+ const moved = record({
484
+ id: "different-id",
485
+ provenance: "agent-retro",
486
+ authority: "hypothesis",
487
+ });
488
+ await writeFile(join(dir, proposal.path), `${JSON.stringify(moved, null, 2)}\n`, "utf8");
489
+ await git(dir, "add", "-A");
490
+ await git(
491
+ dir,
492
+ "-c",
493
+ "user.name=x",
494
+ "-c",
495
+ "user.email=x@nanobpm.local",
496
+ "commit",
497
+ "--no-gpg-sign",
498
+ "-m",
499
+ "swap id under the same path",
500
+ );
501
+ await git(dir, "checkout", "main");
502
+
503
+ await assert.rejects(() => writer.ratify(proposal), GovernanceError);
504
+ assert.equal(await writer.isRatified(proposal), false);
505
+ assert.equal(await git(dir, "status", "--porcelain"), "");
506
+ });
507
+
508
+ test("ratify REFUSES a branch outside the proposal namespace", async () => {
509
+ const dir = await makeSubstrate();
510
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
511
+
512
+ const proposal = await writer.proposePrior(
513
+ record({ id: "retro-ns", provenance: "agent-retro", authority: "hypothesis" }),
514
+ );
515
+
516
+ // Re-point the proposal handle at an arbitrary, non-proposal branch.
517
+ await git(dir, "branch", "rogue", proposal.branch);
518
+ await assert.rejects(
519
+ () => writer.ratify({ ...proposal, branch: "rogue" }),
520
+ GovernanceError,
521
+ );
522
+ assert.equal(await git(dir, "status", "--porcelain"), "");
523
+ });
524
+
525
+ test("ratify REFUSES a proposal whose baseBranch was retargeted off the writer's resolved base", async () => {
526
+ const dir = await makeSubstrate();
527
+ // The writer is bound (by S1) to `main` as its resolved base.
528
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
529
+
530
+ const proposal = await writer.proposePrior(
531
+ record({ id: "retro-rebase", provenance: "agent-retro", authority: "hypothesis" }),
532
+ );
533
+
534
+ // A real branch the caller tries to steer the merge onto instead of `main`.
535
+ await git(dir, "branch", "rogue-base", "main");
536
+
537
+ // Mutate the plain-object handle to point at a different base than the writer
538
+ // resolved. ratify must refuse — the merge target is the writer's own base, not
539
+ // whatever the (untrusted) handle carries.
540
+ await assert.rejects(
541
+ () => writer.ratify({ ...proposal, baseBranch: "rogue-base" }),
542
+ GovernanceError,
543
+ );
544
+
545
+ assert.equal(await writer.isRatified(proposal), false);
546
+ assert.ok(
547
+ !(await exists(join(dir, proposal.path))),
548
+ "a proposal retargeted off the resolved base must never be ratified",
549
+ );
550
+ assert.equal(await git(dir, "status", "--porcelain"), "");
551
+ });
552
+
553
+ test("concurrent proposals use separate branches without clobbering", async () => {
554
+ const dir = await makeSubstrate();
555
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
556
+
557
+ const [p1, p2] = await Promise.all([
558
+ writer.proposePrior(
559
+ record({ id: "conc-a", provenance: "agent-retro", authority: "hypothesis" }),
560
+ ),
561
+ writer.proposePrior(
562
+ record({ id: "conc-b", provenance: "agent-retro", authority: "hypothesis" }),
563
+ ),
564
+ ]);
565
+
566
+ assert.notEqual(p1.branch, p2.branch);
567
+
568
+ await writer.ratify(p1);
569
+ await writer.ratify(p2);
570
+
571
+ // Both records survived the concurrent writes + merges.
572
+ assert.ok(await exists(join(dir, p1.path)));
573
+ assert.ok(await exists(join(dir, p2.path)));
574
+ });
575
+
576
+ test("ratify derives the merge message from the guarded record id, not the caller-controlled proposalId", async () => {
577
+ const dir = await makeSubstrate();
578
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
579
+
580
+ const proposal = await writer.proposePrior(
581
+ record({ id: "retro-msg", provenance: "agent-retro", authority: "hypothesis" }),
582
+ );
583
+
584
+ // A caller mutates the plain-object handle to smuggle PII (and a newline) into
585
+ // the field ratify previously interpolated into the merge commit message — a
586
+ // bypass of the mandatory PII guard, which only inspects record CONTENT.
587
+ const tainted = { ...proposal, proposalId: "inject\nleak jane.doe@example.com" };
588
+
589
+ const ratified = await writer.ratify(tainted);
590
+
591
+ // The merge message is derived from the guarded record.id, so the injected
592
+ // content never reaches the commit trailer.
593
+ const message = await git(dir, "log", "-1", "--format=%B", ratified.mergeCommit);
594
+ assert.ok(message.includes("retro-msg"), "message must carry the guarded record id");
595
+ assert.ok(
596
+ !message.includes("jane.doe@example.com"),
597
+ "injected PII must not reach the merge message",
598
+ );
599
+ assert.ok(!message.includes("inject"), "injected proposalId content must not reach the message");
600
+ });
601
+
602
+ test("isRatified REFUSES a proposal whose baseBranch was retargeted off the writer's resolved base", async () => {
603
+ const dir = await makeSubstrate();
604
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
605
+
606
+ const proposal = await writer.proposePrior(
607
+ record({ id: "retro-isr", provenance: "agent-retro", authority: "hypothesis" }),
608
+ );
609
+
610
+ // The proposal is UNratified onto main, but its commit is an ancestor of its own
611
+ // branch. A caller mutates the handle to claim that branch as the base, which —
612
+ // without the base-binding guard — would make the untrusted-base ancestry check
613
+ // report the hypothesis as ratified.
614
+ await git(dir, "branch", "rogue-base", proposal.branch);
615
+
616
+ await assert.rejects(
617
+ () => writer.isRatified({ ...proposal, baseBranch: "rogue-base" }),
618
+ GovernanceError,
619
+ );
620
+ // The honest query still reports the truth: not ratified onto the resolved base.
621
+ assert.equal(await writer.isRatified(proposal), false);
622
+ });
623
+
624
+ test("isRatified IGNORES a forged proposal.commit and checks the real branch tip", async () => {
625
+ const dir = await makeSubstrate();
626
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
627
+
628
+ const proposal = await writer.proposePrior(
629
+ record({ id: "retro-forge", provenance: "agent-retro", authority: "hypothesis" }),
630
+ );
631
+
632
+ // ATTACK: the proposal branch is genuinely UNmerged, but a consumer forges the
633
+ // plain-object handle's `commit` to point at base's own HEAD — a commit that IS an
634
+ // ancestor of `main`. A `commit`-trusting ancestry check would report this unmerged
635
+ // hypothesis as ratified. The check must instead derive from the real branch tip.
636
+ const baseHead = await git(dir, "rev-parse", "main");
637
+ assert.equal(
638
+ await writer.isRatified({ ...proposal, commit: baseHead }),
639
+ false,
640
+ "a forged commit must not make an unmerged proposal read as ratified",
641
+ );
642
+
643
+ // A genuine ratification flips the honest answer to true — proving the branch-tip
644
+ // check is not merely rejecting everything.
645
+ await writer.ratify(proposal);
646
+ assert.equal(await writer.isRatified(proposal), true);
647
+ });
648
+
649
+ test("isRatified reads false for a branch outside the proposal namespace or absent", async () => {
650
+ const dir = await makeSubstrate();
651
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
652
+
653
+ const proposal = await writer.proposePrior(
654
+ record({ id: "retro-ns", provenance: "agent-retro", authority: "hypothesis" }),
655
+ );
656
+
657
+ // A branch that is fully merged into main but lives OUTSIDE the proposal namespace
658
+ // must never read as a ratified proposal, even though its tip is an ancestor of base.
659
+ await git(dir, "branch", "rogue-merged", "main");
660
+ assert.equal(await writer.isRatified({ ...proposal, branch: "rogue-merged" }), false);
661
+
662
+ // A non-existent branch is, by definition, not a ratified proposal.
663
+ assert.equal(await writer.isRatified({ ...proposal, branch: "context/proposal/ghost" }), false);
664
+ });
665
+
666
+ test("appendRecord restores the working tree to base after a mid-operation failure", async () => {
667
+ const substrate = new FailingSubstrate("stageAndCommit");
668
+ const writer = new ContextWriter({ localPath: "/virtual", ref: "main" }, { substrate });
669
+
670
+ await assert.rejects(
671
+ () => writer.appendRecord(record({ id: "midfail" })),
672
+ /simulated commit failure/,
673
+ );
674
+
675
+ // The `finally` cleanup returned the shared working tree to the resolved base, so
676
+ // the next serialised operation does not inherit the aborted write's branch.
677
+ assert.equal(substrate.current, "main");
678
+ assert.deepEqual(substrate.restored, ["main"]);
679
+ });
680
+
681
+ test("proposePrior restores the working tree to base after a mid-operation failure", async () => {
682
+ const substrate = new FailingSubstrate("stageAndCommit");
683
+ const writer = new ContextWriter({ localPath: "/virtual", ref: "main" }, { substrate });
684
+
685
+ await assert.rejects(
686
+ () =>
687
+ writer.proposePrior(
688
+ record({ id: "midfail", provenance: "agent-retro", authority: "hypothesis" }),
689
+ ),
690
+ /simulated commit failure/,
691
+ );
692
+
693
+ assert.equal(substrate.current, "main");
694
+ assert.deepEqual(substrate.restored, ["main"]);
695
+ });
696
+
697
+ test("ratify restores the working tree to base after a merge failure", async () => {
698
+ const dir = await makeSubstrate();
699
+ // A real proposal on its bot branch, created via the normal path.
700
+ const proposer = new ContextWriter({ localPath: dir, ref: "main" });
701
+ const proposal = await proposer.proposePrior(
702
+ record({ id: "retro-mergefail", provenance: "agent-retro", authority: "hypothesis" }),
703
+ );
704
+
705
+ // Ratify through a substrate whose merge fails AFTER checkout(base) has moved the
706
+ // shared tree — the exact window where a missing `finally` would strand it.
707
+ const substrate = new MergeFailingSubstrate(new GitWriteSubstrate(dir));
708
+ const writer = new ContextWriter({ localPath: dir, ref: "main" }, { substrate });
709
+
710
+ await assert.rejects(() => writer.ratify(proposal), /simulated merge failure/);
711
+
712
+ // The `finally` cleanup fired (like appendRecord/proposePrior) so the next
713
+ // serialised op inherits a clean tree on the resolved base, not a half-merged one.
714
+ assert.deepEqual(substrate.restored, ["main"]);
715
+ assert.equal(await git(dir, "rev-parse", "--abbrev-ref", "HEAD"), "main");
716
+ assert.equal(await git(dir, "status", "--porcelain"), "");
717
+ });
718
+
719
+ test("commit subject line is capped so an unbounded id cannot bloat the message", async () => {
720
+ const dir = await makeSubstrate();
721
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
722
+
723
+ // The schema only requires `id` to be a non-empty string, so an accidental or
724
+ // adversarial extremely long id must not produce a huge commit subject.
725
+ const longId = "x".repeat(500);
726
+ const result = await writer.appendRecord(record({ id: longId }));
727
+
728
+ const subject = await git(dir, "log", "-1", "--format=%s", result.mergeCommit);
729
+ assert.ok(subject.length < 200, `commit subject must stay bounded, got ${subject.length}`);
730
+ assert.ok(subject.includes("…"), "an over-long id must be truncated with an ellipsis");
731
+ assert.ok(!subject.includes("x".repeat(200)), "the full over-long id must not reach the subject");
732
+ });
733
+
734
+ test("restoreClean discards untracked record residue and returns to the base branch", async () => {
735
+ const dir = await makeSubstrate();
736
+ const substrate = new GitWriteSubstrate(dir);
737
+
738
+ // Simulate a failed write: parked on a stray branch with an untracked record file
739
+ // left behind under the layout root.
740
+ await git(dir, "checkout", "-b", "stray");
741
+ const residue = `${LAYOUT_ROOT}/epic/issue-303/residue.json`;
742
+ await substrate.writeRecordFile(residue, "{}\n");
743
+ assert.ok(await exists(join(dir, residue)), "residue should exist before cleanup");
744
+
745
+ await substrate.restoreClean("main", LAYOUT_ROOT);
746
+
747
+ assert.equal(await git(dir, "rev-parse", "--abbrev-ref", "HEAD"), "main");
748
+ assert.ok(
749
+ !(await exists(join(dir, residue))),
750
+ "untracked residue under the layout root must be cleaned",
751
+ );
752
+ assert.equal(await git(dir, "status", "--porcelain"), "");
753
+ // Unrelated tracked files are untouched.
754
+ assert.ok(await exists(join(dir, "README.md")));
755
+ });
756
+
757
+ test("appendRecord sanitises a newline-bearing id so it cannot inject commit-message lines", async () => {
758
+ const dir = await makeSubstrate();
759
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
760
+
761
+ // `id` is schema-validated only as a non-empty string, so a newline passes. A
762
+ // raw interpolation would split the subject and inject a forged trailer into
763
+ // both the append commit and its merge.
764
+ const result = await writer.appendRecord(
765
+ record({ id: "good-id\nInjected-Trailer: malicious-value" }),
766
+ );
767
+
768
+ const commitMsg = await git(dir, "log", "-1", "--format=%B", result.commit);
769
+ const mergeMsg = await git(dir, "log", "-1", "--format=%B", result.mergeCommit);
770
+ for (const [label, msg] of [
771
+ ["commit", commitMsg],
772
+ ["merge", mergeMsg],
773
+ ] as const) {
774
+ assert.ok(msg.includes("good-id"), `${label} message must carry the id`);
775
+ assert.ok(
776
+ !msg.split("\n").some((l) => l.trimStart().startsWith("Injected-Trailer:")),
777
+ `${label} message must not carry the injected content as a standalone trailer`,
778
+ );
779
+ // The whole subject collapsed to a single line — no injected extra lines.
780
+ assert.equal(
781
+ msg.split("\n").filter((l) => l.trim() !== "").length,
782
+ 1,
783
+ `${label} message must be a single non-empty line`,
784
+ );
785
+ }
786
+ });
787
+
788
+ test("proposePrior sanitises a newline-bearing id in the hypothesis commit message", async () => {
789
+ const dir = await makeSubstrate();
790
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
791
+
792
+ const proposal = await writer.proposePrior(
793
+ record({
794
+ id: "retro-nl\nInjected-Trailer: malicious-value",
795
+ provenance: "agent-retro",
796
+ authority: "hypothesis",
797
+ }),
798
+ );
799
+
800
+ const commitMsg = await git(dir, "log", "-1", "--format=%B", proposal.branch);
801
+ assert.ok(commitMsg.includes("retro-nl"), "propose message must carry the id");
802
+ assert.ok(
803
+ !commitMsg.split("\n").some((l) => l.trimStart().startsWith("Injected-Trailer:")),
804
+ "propose message must not carry the injected content as a standalone trailer",
805
+ );
806
+ assert.equal(
807
+ commitMsg.split("\n").filter((l) => l.trim() !== "").length,
808
+ 1,
809
+ "propose message must be a single non-empty line",
810
+ );
811
+
812
+ // Ratify re-reads the guarded record; its merge subject must sanitise the id too.
813
+ const ratified = await writer.ratify(proposal);
814
+ const mergeMsg = await git(dir, "log", "-1", "--format=%B", ratified.mergeCommit);
815
+ assert.ok(mergeMsg.includes("retro-nl"), "ratify merge must carry the id");
816
+ assert.ok(
817
+ !mergeMsg.split("\n").some((l) => l.trimStart().startsWith("Injected-Trailer:")),
818
+ "ratify merge must not carry the injected content as a standalone trailer",
819
+ );
820
+ assert.equal(
821
+ mergeMsg.split("\n").filter((l) => l.trim() !== "").length,
822
+ 1,
823
+ "ratify merge message must be a single non-empty line",
824
+ );
825
+ });
826
+
827
+ test("serialiseRecord is canonical — key INSERTION order cannot change the bytes", () => {
828
+ // Same record, two different construction orders. `JSON.stringify` preserves
829
+ // insertion order, so a naive serialiser would emit differing bytes; the
830
+ // canonical (sorted-key) serialiser must not.
831
+ const forward = record({ id: "canon-1", statement: "the canonical form is stable" });
832
+ const shuffled: MemoryRecord = {
833
+ supersedes: forward.supersedes,
834
+ statement: forward.statement,
835
+ createdAt: forward.createdAt,
836
+ authority: forward.authority,
837
+ provenance: forward.provenance,
838
+ mode: forward.mode,
839
+ scopeRef: forward.scopeRef,
840
+ scope: forward.scope,
841
+ id: forward.id,
842
+ schemaVersion: forward.schemaVersion,
843
+ };
844
+
845
+ assert.equal(
846
+ serialiseRecord(forward),
847
+ serialiseRecord(shuffled),
848
+ "records equal up to key insertion order must serialise identically",
849
+ );
850
+ // It is still valid, round-trippable JSON with the promised trailing LF.
851
+ assert.ok(serialiseRecord(forward).endsWith("\n"));
852
+ assert.deepEqual(JSON.parse(serialiseRecord(shuffled)), JSON.parse(serialiseRecord(forward)));
853
+ // Keys are emitted in sorted order.
854
+ const keys = Object.keys(JSON.parse(serialiseRecord(forward)));
855
+ assert.deepEqual(keys, [...keys].sort(), "serialised keys must be in canonical sorted order");
856
+ });
857
+
858
+ test("isMerged passes `--end-of-options` so an option-style ref is never parsed as a flag", async () => {
859
+ const calls: (readonly string[])[] = [];
860
+ const substrate = new GitWriteSubstrate("/virtual/substrate", async (args) => {
861
+ calls.push(args);
862
+ return "";
863
+ });
864
+
865
+ await substrate.isMerged("--not-a-flag", "main");
866
+
867
+ assert.equal(calls.length, 1);
868
+ const args = calls[0];
869
+ const eoo = args.indexOf("--end-of-options");
870
+ assert.ok(eoo >= 0, "isMerged must pass --end-of-options");
871
+ // The end-of-options marker must precede BOTH positional revisions.
872
+ assert.ok(
873
+ eoo < args.indexOf("--not-a-flag") && eoo < args.lastIndexOf("main"),
874
+ "--end-of-options must come before the commitish/ref operands",
875
+ );
876
+ });
877
+
878
+ test("appendRecord gives a whitespace-only id a placeholder so the commit subject is never empty", async () => {
879
+ const dir = await makeSubstrate();
880
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
881
+
882
+ // The schema validates `id` only as a NON-EMPTY string (`length > 0`), so a
883
+ // whitespace-only id passes. Collapsing+trimming it yields the empty string,
884
+ // which would leave `context(append): prov/scope ` with a MISSING id. The
885
+ // placeholder keeps every subject auditable and log-correlatable.
886
+ const result = await writer.appendRecord(record({ id: " \t \n " }));
887
+
888
+ const commitMsg = await git(dir, "log", "-1", "--format=%s", result.commit);
889
+ const mergeMsg = await git(dir, "log", "-1", "--format=%s", result.mergeCommit);
890
+ for (const [label, subject] of [
891
+ ["commit", commitMsg],
892
+ ["merge", mergeMsg],
893
+ ] as const) {
894
+ assert.ok(subject.includes("<empty>"), `${label} subject must carry the placeholder id`);
895
+ // No trailing whitespace where the id would have been — the id token is present.
896
+ assert.equal(subject, subject.trimEnd(), `${label} subject must not end in blank id`);
897
+ }
898
+ });