@nanobpm/nano-workforce 0.91.0 → 0.92.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,572 @@
1
+ // Integration + unit coverage for the SET/BATCH admission door (issue #292, slice S2) — driven
2
+ // through the operation EDGE `startEpicSet`. Proves the all-or-nothing admission contract:
3
+ // • a valid DAG of epics admits every member AND stages every edge into `admitted_plan_deps`
4
+ // (S2's FK-free staging), writing NOTHING to the durable `plans` / `plan_deps` graph (that is S3);
5
+ // • a submitted cycle is rejected at the offending edge with NO partial start / NO edge staged;
6
+ // • an edge naming an epic outside the set is a clean 400;
7
+ // • a per-epic admission failure (base rules / shared-base) maps to the same 4xx as the single door;
8
+ // • re-submitting the identical set is a no-op (no duplicate edge, no double-admit).
9
+ // It runs the real delegate against an in-memory app/data/engine and a faked github transport, exactly
10
+ // like startPlanFanout.admission.integration.test.ts — no network, deterministic on a single run.
11
+ // A SQLite-backed FK regression (foreign_keys=ON, migrations 041+043 applied) additionally proves S2
12
+ // admits with NO `plans` row present and stages FK-free, never FK-failing on `plan_deps`.
13
+ import { readFileSync } from "node:fs";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { test } from "node:test";
16
+ import { fileURLToPath } from "node:url";
17
+ import { assertEquals } from "#test-assert";
18
+ import type { AppApi } from "@nanobpm/urban";
19
+ import { resetDefaultBranchCache } from "../app/github.ts";
20
+ import { noopLog } from "../test/log.ts";
21
+ import startEpicSet from "./startEpicSet.ts";
22
+
23
+ // ── in-memory github model (mirrors startPlanFanout.admission.integration.test.ts) ───────────────
24
+ interface GithubState {
25
+ repo: string;
26
+ defaultBranch: string;
27
+ branches: Set<string>;
28
+ creates: { ref: string; sha: string }[];
29
+ }
30
+
31
+ function githubFetch(state: GithubState) {
32
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
33
+ const u = new URL(String(url));
34
+ const method = (init?.method ?? "GET").toUpperCase();
35
+ const path = u.pathname;
36
+ const json = (obj: unknown, status = 200) =>
37
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
38
+ if (method === "GET" && path === `/repos/${state.repo}`) {
39
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
40
+ }
41
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
42
+ if (method === "GET" && path.startsWith(refPrefix)) {
43
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
44
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
45
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
46
+ }
47
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
48
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
49
+ const bodyObj = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
50
+ const ref = String(bodyObj.ref ?? "");
51
+ const sha = String(bodyObj.sha ?? "");
52
+ const branch = ref.replace(/^refs\/heads\//, "");
53
+ if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
54
+ state.creates.push({ ref, sha });
55
+ state.branches.add(branch);
56
+ return Promise.resolve(json({ ref }, 201));
57
+ }
58
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
59
+ };
60
+ }
61
+
62
+ async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
63
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
64
+ const prevTok = process.env["GITHUB_TOKEN"];
65
+ const prevFetch = globalThis.fetch;
66
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
67
+ process.env["GITHUB_TOKEN"] = "tok";
68
+ resetDefaultBranchCache();
69
+ globalThis.fetch = githubFetch(state) as typeof fetch;
70
+ try {
71
+ return await fn();
72
+ } finally {
73
+ resetDefaultBranchCache();
74
+ globalThis.fetch = prevFetch;
75
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
76
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
77
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
78
+ else process.env["GITHUB_TOKEN"] = prevTok;
79
+ }
80
+ }
81
+
82
+ // ── in-memory app (data + engine) ────────────────────────────────────────────────────────────────
83
+ // The delegate reads `plans` (admitPlan's shared-base guard) and STAGES into `admitted_epics` +
84
+ // `admitted_plan_deps` (recordAdmittedEpic / recordAdmittedPlanDep) — it never writes the durable
85
+ // `plan_deps`. `admitted_plan_deps` is keyed on `plan_key` but holds MANY rows per key (composite
86
+ // edge), so the generic table's `get` (first row for the key) is not used for it — the delegate only
87
+ // `find`s + `insert`s.
88
+ function makeApp(seedPlans: Record<string, unknown>[] = []) {
89
+ const tables = new Map<string, Record<string, unknown>[]>();
90
+ tables.set("plans", [...seedPlans]);
91
+ const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
92
+ const table = (name: string, key: string) => {
93
+ const rows = tables.get(name) ?? (() => {
94
+ const fresh: Record<string, unknown>[] = [];
95
+ tables.set(name, fresh);
96
+ return fresh;
97
+ })();
98
+ return {
99
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
100
+ find: (q: Record<string, unknown>) =>
101
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
102
+ insert: (r: Record<string, unknown>) => {
103
+ rows.push(r);
104
+ return Promise.resolve(r);
105
+ },
106
+ update: (k: unknown, patch: Record<string, unknown>) => {
107
+ const row = rows.find((r) => r[key] === k);
108
+ if (row) Object.assign(row, patch);
109
+ return Promise.resolve(row);
110
+ },
111
+ delete: (k: unknown) => {
112
+ const i = rows.findIndex((r) => r[key] === k);
113
+ if (i >= 0) rows.splice(i, 1);
114
+ return Promise.resolve();
115
+ },
116
+ };
117
+ };
118
+ const app = {
119
+ data: { table },
120
+ engine: {
121
+ createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
122
+ started.push(req);
123
+ return Promise.resolve({ processInstanceKey: "PI-1" });
124
+ },
125
+ },
126
+ log: noopLog(),
127
+ } as any as AppApi;
128
+ return { app, started, tables };
129
+ }
130
+
131
+ function input(body: unknown) {
132
+ return {
133
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
134
+ params: {},
135
+ query: {},
136
+ body,
137
+ };
138
+ }
139
+
140
+ function freshGithub(repo: string, extraBranches: string[] = []): GithubState {
141
+ return { repo, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
142
+ }
143
+
144
+ const REPO = "owner/repo";
145
+ const call = (app: AppApi, body: unknown) => startEpicSet(input(body), app) as Promise<any>;
146
+ // S2 stages into `admitted_plan_deps` / `admitted_epics` and must NEVER touch the durable `plan_deps`.
147
+ const admittedDepRows = (tables: Map<string, Record<string, unknown>[]>) =>
148
+ tables.get("admitted_plan_deps") ?? [];
149
+ const admittedEpicRows = (tables: Map<string, Record<string, unknown>[]>) =>
150
+ tables.get("admitted_epics") ?? [];
151
+ const planDepsRows = (tables: Map<string, Record<string, unknown>[]>) => tables.get("plan_deps") ?? [];
152
+
153
+ // ── Happy path: a valid DAG admits every member and persists every edge ──────────────────────────
154
+ test("valid DAG: admits all epics and persists all edges", async () => {
155
+ const gh = freshGithub(REPO); // epic/* bases auto-created
156
+ await withGithub(gh, async () => {
157
+ const { app, started, tables } = makeApp();
158
+ const res = await call(app, {
159
+ epics: [
160
+ { issue: `${REPO}#1`, baseBranch: "epic/producer" },
161
+ { issue: `${REPO}#2`, baseBranch: "epic/consumer" },
162
+ ],
163
+ deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
164
+ });
165
+ assertEquals(res.status, 202);
166
+ assertEquals(res.body.epics.length, 2);
167
+ assertEquals(res.body.roots, [`${REPO}#1`]); // #1 has no inbound edge
168
+ assertEquals(res.body.edges, [
169
+ { consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` },
170
+ ]);
171
+ // S2 admits + STAGES (epics and edges) but NEVER starts an epic and NEVER writes the durable
172
+ // `plans` / `plan_deps` graph (that is S3).
173
+ assertEquals(started.length, 0);
174
+ assertEquals(planDepsRows(tables).length, 0); // durable plan_deps untouched by S2
175
+ const epicRows = admittedEpicRows(tables);
176
+ assertEquals(epicRows.length, 2); // both epics staged (roots included) for S3 to materialize
177
+ const rows = admittedDepRows(tables);
178
+ assertEquals(rows.length, 1);
179
+ assertEquals(rows[0].plan_key, `${REPO}#2`);
180
+ assertEquals(rows[0].depends_on_plan_key, `${REPO}#1`);
181
+ assertEquals(rows[0].package, "@scope/pkg");
182
+ assertEquals(rows[0].capability_ref, `${REPO}#1`);
183
+ });
184
+ });
185
+
186
+ test("independent roots: a set with no deps admits every epic as a root, no edges", async () => {
187
+ const gh = freshGithub(REPO);
188
+ await withGithub(gh, async () => {
189
+ const { app, tables } = makeApp();
190
+ const res = await call(app, {
191
+ epics: [
192
+ { issue: `${REPO}#1`, baseBranch: "epic/a" },
193
+ { issue: `${REPO}#2`, baseBranch: "epic/b" },
194
+ ],
195
+ });
196
+ assertEquals(res.status, 202);
197
+ assertEquals(res.body.roots.sort(), [`${REPO}#1`, `${REPO}#2`]);
198
+ assertEquals(res.body.edges, []);
199
+ assertEquals(admittedDepRows(tables).length, 0);
200
+ assertEquals(admittedEpicRows(tables).length, 2); // both roots staged
201
+ assertEquals(planDepsRows(tables).length, 0);
202
+ });
203
+ });
204
+
205
+ // ── Cycle: rejected at the offending edge, nothing half-started ──────────────────────────────────
206
+ test("cycle: rejected 400 with no edge persisted and no branch created", async () => {
207
+ const gh = freshGithub(REPO);
208
+ await withGithub(gh, async () => {
209
+ const { app, tables } = makeApp();
210
+ const res = await call(app, {
211
+ epics: [
212
+ { issue: `${REPO}#1`, baseBranch: "epic/a" },
213
+ { issue: `${REPO}#2`, baseBranch: "epic/b" },
214
+ ],
215
+ deps: [
216
+ { consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` },
217
+ { consumer: `${REPO}#1`, producer: `${REPO}#2`, package: "p", capabilityRef: `${REPO}#2` },
218
+ ],
219
+ });
220
+ assertEquals(res.status, 400);
221
+ assertEquals(typeof res.body.error, "string");
222
+ assertEquals(admittedDepRows(tables).length, 0); // nothing staged
223
+ assertEquals(gh.creates, []); // cycle rejected BEFORE any admitPlan side effect
224
+ });
225
+ });
226
+
227
+ // ── Dangling edge: an endpoint not in the set is a clean 400, before admission ───────────────────
228
+ test("edge naming an epic outside the set: 400, nothing persisted/created", async () => {
229
+ const gh = freshGithub(REPO);
230
+ await withGithub(gh, async () => {
231
+ const { app, tables } = makeApp();
232
+ const res = await call(app, {
233
+ epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
234
+ deps: [{ consumer: `${REPO}#1`, producer: `${REPO}#999`, package: "p", capabilityRef: `${REPO}#999` }],
235
+ });
236
+ assertEquals(res.status, 400);
237
+ assertEquals(typeof res.body.error, "string");
238
+ assertEquals(admittedDepRows(tables).length, 0);
239
+ assertEquals(gh.creates, []);
240
+ });
241
+ });
242
+
243
+ test("self-edge: 400", async () => {
244
+ const gh = freshGithub(REPO);
245
+ await withGithub(gh, async () => {
246
+ const { app } = makeApp();
247
+ const res = await call(app, {
248
+ epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
249
+ deps: [{ consumer: `${REPO}#1`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
250
+ });
251
+ assertEquals(res.status, 400);
252
+ });
253
+ });
254
+
255
+ // ── Per-epic admission failure maps to the same 4xx as the single door, nothing persisted ────────
256
+ test("unadmittable epic (default base without confirm): 400, no edge persisted", async () => {
257
+ const gh = freshGithub(REPO);
258
+ await withGithub(gh, async () => {
259
+ const { app, tables } = makeApp();
260
+ const res = await call(app, {
261
+ epics: [
262
+ { issue: `${REPO}#1`, baseBranch: "epic/ok" },
263
+ { issue: `${REPO}#2`, baseBranch: "main" }, // default branch without confirmDefaultBase → 400
264
+ ],
265
+ deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
266
+ });
267
+ assertEquals(res.status, 400);
268
+ assertEquals(admittedDepRows(tables).length, 0); // edges staged only after ALL epics admit
269
+ assertEquals(admittedEpicRows(tables).length, 0); // no epic staged on a partial-admit reject
270
+ });
271
+ });
272
+
273
+ test("shared custom base with an active plan: 409 (shared-base guard)", async () => {
274
+ const gh = freshGithub(REPO, ["epic/shared"]);
275
+ await withGithub(gh, async () => {
276
+ const { app } = makeApp([
277
+ { plan_key: `${REPO}#98`, repo: REPO, base_branch: "epic/shared", status: "planning" },
278
+ ]);
279
+ const res = await call(app, {
280
+ epics: [{ issue: `${REPO}#1`, baseBranch: "epic/shared" }],
281
+ });
282
+ assertEquals(res.status, 409);
283
+ assertEquals(typeof res.body.error, "string");
284
+ });
285
+ });
286
+
287
+ // ── Intra-set shared-base collision: two members reaching for the SAME custom base ───────────────
288
+ // admitPlan's rule 4 only sees DURABLE `plans` rows, and S2 materializes none, so without an
289
+ // in-request guard two epics in one set could both grab the same custom integration branch and
290
+ // silently defeat ADR 0003 rule 4. The door must reject the collision itself, all-or-nothing.
291
+ test("intra-set: two epics on the same custom base, neither opting in → 409, nothing staged", async () => {
292
+ const gh = freshGithub(REPO, ["epic/shared"]);
293
+ await withGithub(gh, async () => {
294
+ const { app, tables } = makeApp();
295
+ const res = await call(app, {
296
+ epics: [
297
+ { issue: `${REPO}#1`, baseBranch: "epic/shared" },
298
+ { issue: `${REPO}#2`, baseBranch: "epic/shared" },
299
+ ],
300
+ });
301
+ assertEquals(res.status, 409);
302
+ assertEquals(typeof res.body.error, "string");
303
+ assertEquals(admittedEpicRows(tables).length, 0); // all-or-nothing: nothing staged on reject
304
+ assertEquals(admittedDepRows(tables).length, 0);
305
+ });
306
+ });
307
+
308
+ test("intra-set: same custom base admitted when the later epic opts in with allowSharedBase", async () => {
309
+ const gh = freshGithub(REPO, ["epic/shared"]);
310
+ await withGithub(gh, async () => {
311
+ const { app, tables } = makeApp();
312
+ const res = await call(app, {
313
+ epics: [
314
+ { issue: `${REPO}#1`, baseBranch: "epic/shared" },
315
+ { issue: `${REPO}#2`, baseBranch: "epic/shared", allowSharedBase: true },
316
+ ],
317
+ });
318
+ assertEquals(res.status, 202);
319
+ assertEquals(admittedEpicRows(tables).length, 2);
320
+ });
321
+ });
322
+
323
+ test("intra-set: two epics on the DEFAULT base (both confirmed) do NOT collide", async () => {
324
+ const gh = freshGithub(REPO);
325
+ await withGithub(gh, async () => {
326
+ const { app, tables } = makeApp();
327
+ const res = await call(app, {
328
+ epics: [
329
+ { issue: `${REPO}#1`, baseBranch: "main", confirmDefaultBase: true },
330
+ { issue: `${REPO}#2`, baseBranch: "main", confirmDefaultBase: true },
331
+ ],
332
+ });
333
+ assertEquals(res.status, 202); // default branch is exempt from the shared-base guard (rule 3/4)
334
+ assertEquals(admittedEpicRows(tables).length, 2);
335
+ });
336
+ });
337
+
338
+ // ── Idempotency: re-submitting the identical set records no duplicate edge ────────────────────────
339
+ test("idempotent: re-submitting the identical set does not duplicate edges", async () => {
340
+ const gh = freshGithub(REPO);
341
+ await withGithub(gh, async () => {
342
+ const { app, tables } = makeApp();
343
+ const set = {
344
+ epics: [
345
+ { issue: `${REPO}#1`, baseBranch: "epic/producer" },
346
+ { issue: `${REPO}#2`, baseBranch: "epic/consumer" },
347
+ ],
348
+ deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
349
+ };
350
+ const first = await call(app, set);
351
+ assertEquals(first.status, 202);
352
+ assertEquals(admittedDepRows(tables).length, 1);
353
+ assertEquals(admittedEpicRows(tables).length, 2);
354
+ const second = await call(app, set);
355
+ assertEquals(second.status, 202);
356
+ assertEquals(admittedDepRows(tables).length, 1); // no duplicate edge on retry
357
+ assertEquals(admittedEpicRows(tables).length, 2); // no duplicate epic on retry
358
+ });
359
+ });
360
+
361
+ // ── Malformed body / references ──────────────────────────────────────────────────────────────────
362
+ test("empty epics array: 400", async () => {
363
+ const gh = freshGithub(REPO);
364
+ await withGithub(gh, async () => {
365
+ const { app } = makeApp();
366
+ const res = await call(app, { epics: [] });
367
+ assertEquals(res.status, 400);
368
+ });
369
+ });
370
+
371
+ test("unparseable epic reference: 400", async () => {
372
+ const gh = freshGithub(REPO);
373
+ await withGithub(gh, async () => {
374
+ const { app } = makeApp();
375
+ const res = await call(app, { epics: [{ issue: "not-an-issue", baseBranch: "epic/a" }] });
376
+ assertEquals(res.status, 400);
377
+ });
378
+ });
379
+
380
+ // `deps`, when provided, MUST be an array. A non-array `deps` (e.g. an object) must be a clean 400,
381
+ // not silently coerced to `[]` — which would admit the set while dropping every declared edge.
382
+ test("non-array deps: 400, nothing admitted", async () => {
383
+ const gh = freshGithub(REPO);
384
+ await withGithub(gh, async () => {
385
+ const { app } = makeApp();
386
+ const res = await call(app, {
387
+ epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
388
+ deps: { consumer: `${REPO}#1`, producer: `${REPO}#1` },
389
+ });
390
+ assertEquals(res.status, 400);
391
+ assertEquals(typeof res.body.error, "string");
392
+ assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
393
+ });
394
+ });
395
+
396
+ // ── Reference extraction enforces EXACTLY-ONE-of issue|url (the operation contract) ──────────────
397
+ test("epic naming BOTH issue and url: 400, nothing created", async () => {
398
+ const gh = freshGithub(REPO);
399
+ await withGithub(gh, async () => {
400
+ const { app } = makeApp();
401
+ const res = await call(app, {
402
+ epics: [{ issue: `${REPO}#1`, url: `https://github.com/${REPO}/issues/1`, baseBranch: "epic/a" }],
403
+ });
404
+ assertEquals(res.status, 400);
405
+ assertEquals(typeof res.body.error, "string");
406
+ assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
407
+ });
408
+ });
409
+
410
+ test("epic with issue:null falls through to a valid url (key-presence must not win)", async () => {
411
+ const gh = freshGithub(REPO);
412
+ await withGithub(gh, async () => {
413
+ const { app } = makeApp();
414
+ const res = await call(app, {
415
+ epics: [{ issue: null, url: `https://github.com/${REPO}/issues/7`, baseBranch: "epic/a" }],
416
+ });
417
+ assertEquals(res.status, 202);
418
+ assertEquals(res.body.epics, [{ planKey: `${REPO}#7`, baseBranch: "epic/a" }]);
419
+ });
420
+ });
421
+
422
+ test("epic with neither issue nor url (both null): 400", async () => {
423
+ const gh = freshGithub(REPO);
424
+ await withGithub(gh, async () => {
425
+ const { app } = makeApp();
426
+ const res = await call(app, { epics: [{ issue: null, url: null, baseBranch: "epic/a" }] });
427
+ assertEquals(res.status, 400);
428
+ });
429
+ });
430
+
431
+ test("missing body: 400", async () => {
432
+ const gh = freshGithub(REPO);
433
+ await withGithub(gh, async () => {
434
+ const { app } = makeApp();
435
+ const res = await call(app, undefined);
436
+ assertEquals(res.status, 400);
437
+ });
438
+ });
439
+
440
+ // ── Malformed deps[] entries map to a clean 400 (never an uncaught TypeError/500), nothing persisted.
441
+ // `deps` arrives from an untyped request body, so a null/non-object entry or a non-string field must
442
+ // be rejected as an EpicSetValidationError → 400, with no edge persisted and no branch created.
443
+ for (const [label, badDep] of [
444
+ ["null entry", null],
445
+ ["non-object entry (string)", "owner/repo#1"],
446
+ ["empty object (missing endpoints)", {}],
447
+ ["non-string consumer", { consumer: 1, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
448
+ ["non-string package", { consumer: `${REPO}#2`, producer: `${REPO}#1`, package: 7, capabilityRef: `${REPO}#1` }],
449
+ ] as const) {
450
+ test(`malformed dep (${label}): 400, nothing persisted/created`, async () => {
451
+ const gh = freshGithub(REPO);
452
+ await withGithub(gh, async () => {
453
+ const { app, tables } = makeApp();
454
+ const res = await call(app, {
455
+ epics: [
456
+ { issue: `${REPO}#1`, baseBranch: "epic/a" },
457
+ { issue: `${REPO}#2`, baseBranch: "epic/b" },
458
+ ],
459
+ deps: [badDep],
460
+ });
461
+ assertEquals(res.status, 400);
462
+ assertEquals(typeof res.body.error, "string");
463
+ assertEquals(admittedDepRows(tables).length, 0);
464
+ assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
465
+ });
466
+ });
467
+ }
468
+
469
+ // ── SQLite-backed FK regression (issue #292 S2) ──────────────────────────────────────────────────
470
+ // The in-memory data layer above does NOT enforce SQLite constraints — notably `plan_deps.plan_key`'s
471
+ // FK to `plans` (041). So it could not have caught the FK-violation the S2 door originally shipped:
472
+ // it wrote validated edges into `plan_deps` while admitting via `admitPlan` (which creates NO `plans`
473
+ // row), so a first-time set submission FK-failed (500). The fix (design decision on #292): S2 admits
474
+ // + STAGES into its own FK-free `admitted_epics` / `admitted_plan_deps` (043); S3 materializes the
475
+ // durable graph. These tests drive the real delegate against a real `node:sqlite` db with
476
+ // foreign_keys=ON and migrations 041+043 applied, proving the door no longer FK-fails and never
477
+ // writes `plan_deps`.
478
+
479
+ /** A DataLayer over a real in-memory SQLite db with foreign_keys ON, migrations 041+043 applied, and a
480
+ * minimal `plans` shape (the columns admitPlan's shared-base guard reads + the PK 041's FK references).
481
+ * Seeds NO plans rows by default — that is exactly the first-submission condition the old code
482
+ * FK-failed on. */
483
+ function makeSqliteApp(
484
+ seedPlans: { plan_key: string; repo: string; base_branch: string; status: string }[] = [],
485
+ ) {
486
+ const db = new DatabaseSync(":memory:");
487
+ db.exec("PRAGMA foreign_keys = ON;");
488
+ db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, base_branch TEXT, status TEXT);");
489
+ for (const p of seedPlans) {
490
+ db.prepare("INSERT INTO plans (plan_key, repo, base_branch, status) VALUES (?, ?, ?, ?)")
491
+ .run(p.plan_key, p.repo, p.base_branch, p.status);
492
+ }
493
+ for (const f of ["041_inter_epic_plan_deps.sql", "045_epic_set_admission_staging.sql"]) {
494
+ db.exec(readFileSync(fileURLToPath(new URL(`../db/migrations/${f}`, import.meta.url)), "utf8"));
495
+ }
496
+ const q = (id: string) => `"${id.replace(/"/g, '""')}"`;
497
+ const coerce = (v: unknown) => (v === null ? null : typeof v === "boolean" ? (v ? 1 : 0) : v) as any;
498
+ const table = (name: string, key: string) => ({
499
+ get: (k: unknown) =>
500
+ Promise.resolve(db.prepare(`SELECT * FROM ${q(name)} WHERE ${q(key)} = ?`).get(coerce(k)) ?? null),
501
+ find: (query: Record<string, unknown>) => {
502
+ const keys = Object.keys(query);
503
+ const clause = keys.length ? `WHERE ${keys.map((k) => `${q(k)} = ?`).join(" AND ")}` : "";
504
+ return Promise.resolve(db.prepare(`SELECT * FROM ${q(name)} ${clause}`).all(...keys.map((k) => coerce(query[k]))));
505
+ },
506
+ insert: (r: Record<string, unknown>) => {
507
+ const keys = Object.keys(r).filter((k) => r[k] !== undefined);
508
+ db.prepare(`INSERT INTO ${q(name)} (${keys.map(q).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`)
509
+ .run(...keys.map((k) => coerce(r[k])));
510
+ return Promise.resolve(r);
511
+ },
512
+ update: () => Promise.resolve(null),
513
+ delete: () => Promise.resolve(),
514
+ });
515
+ const app = {
516
+ data: { table },
517
+ engine: { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) },
518
+ log: noopLog(),
519
+ } as any as AppApi;
520
+ return { app, db };
521
+ }
522
+
523
+ test("SQLite (FK ON): admits a set with NO plans row, stages FK-free, never writes plan_deps", async () => {
524
+ const gh = freshGithub(REPO);
525
+ await withGithub(gh, async () => {
526
+ const { app, db } = makeSqliteApp(); // no plans rows — the first-submission FK-failure condition
527
+ try {
528
+ const res = await call(app, {
529
+ epics: [
530
+ { issue: `${REPO}#1`, baseBranch: "epic/producer" },
531
+ { issue: `${REPO}#2`, baseBranch: "epic/consumer" },
532
+ ],
533
+ deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
534
+ });
535
+ // Under the old code this was a 500 FK violation on `plan_deps.plan_key`.
536
+ assertEquals(res.status, 202);
537
+ const planDepN = db.prepare("SELECT COUNT(*) AS n FROM plan_deps").get() as { n: number };
538
+ assertEquals(planDepN.n, 0); // durable plan_deps untouched by S2
539
+ const staged = db
540
+ .prepare("SELECT plan_key, depends_on_plan_key, package FROM admitted_plan_deps")
541
+ .all() as { plan_key: string; depends_on_plan_key: string; package: string }[];
542
+ assertEquals(staged.length, 1);
543
+ assertEquals(staged[0].plan_key, `${REPO}#2`);
544
+ assertEquals(staged[0].depends_on_plan_key, `${REPO}#1`);
545
+ const epicN = db.prepare("SELECT COUNT(*) AS n FROM admitted_epics").get() as { n: number };
546
+ assertEquals(epicN.n, 2); // both epics staged for S3 to materialize
547
+ } finally {
548
+ db.close();
549
+ }
550
+ });
551
+ });
552
+
553
+ test("SQLite: plan_deps FK rejects an unbacked edge, but the admitted_plan_deps staging twin accepts it", () => {
554
+ const { db } = makeSqliteApp();
555
+ try {
556
+ const edge = (t: string) =>
557
+ `INSERT INTO ${t} (plan_key, depends_on_plan_key, package, capability_ref, created_at) VALUES ('o/r#2','o/r#1','p','o/r#1','t')`;
558
+ let fkThrew = false;
559
+ try {
560
+ db.prepare(edge("plan_deps")).run(); // no plans row for o/r#2 → FK violation
561
+ } catch {
562
+ fkThrew = true;
563
+ }
564
+ assertEquals(fkThrew, true); // proves plan_deps.plan_key REFERENCES plans(plan_key) is real + ON
565
+ db.prepare(edge("admitted_plan_deps")).run(); // FK-free staging twin accepts the same unbacked edge
566
+ const n = db.prepare("SELECT COUNT(*) AS n FROM admitted_plan_deps").get() as { n: number };
567
+ assertEquals(n.n, 1);
568
+ } finally {
569
+ db.close();
570
+ }
571
+ });
572
+