@nanobpm/nano-workforce 0.136.0 → 0.138.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,470 @@
1
+ // Read-model coverage for the plan-family (Epic) projections — the wave rollups, the slice-PR
2
+ // delivery counts, and the per-row delivery/bucket/ack signals — now authored via Urban's ADR-0065
3
+ // declare-once primitives (`defineRollup`, app/planRollups.ts; `defineReadModel` + key-correlated
4
+ // rollup lookups, app/planReadModel.ts). Issue #493, the plan-family twin of app/featureReadModel.test.ts.
5
+ //
6
+ // 059/060/061 hand-authored the three GROUP-BY aggregates as SQL VIEWs AND a second time in the runtime
7
+ // TS; 074/080 hand-authored the per-row `delivery`/`list_bucket`/`ack_open` signals as SQL CASEs AND a
8
+ // TS oracle (`deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable`, app/delivery.ts) — kept in
9
+ // lockstep by bespoke parity tests (app/plansReadModel.test.ts, app/planWaveSummary.test.ts,
10
+ // app/delivery.test.ts, the ADR-0065 drift surface #2). Migrations 082/083 supersede them: every
11
+ // rollup VIEW is emitted from its ONE `defineRollup`, and every derived read-model column from the ONE
12
+ // `defineReadModel` — both of which ALSO drive the runtime TS (`reduce`/`fnFor`, behind app/delivery.ts).
13
+ // This suite retires those hand-written parity tests in favour of the framework parity guard, and
14
+ // guards FOUR things:
15
+ //
16
+ // 1. DRIFT GUARD — migration 082 embeds each rollup's VIEW DDL VERBATIM from `rollup.viewDdl()`, and
17
+ // migration 083 embeds each derived column VERBATIM from `planReadModel.sqlSelectFor(...)`, so the
18
+ // checked-in VIEWs cannot drift from the declarations.
19
+ // 2. FRAMEWORK PARITY GUARD — `assertRollupParity` / `assertReadModelParity` prove the SQL and TS
20
+ // lowerings each declaration compiles to agree (the role the retired hand-written tests played).
21
+ // 3. END-TO-END BEHAVIOUR on the REAL migration VIEWs (059→083 applied to an in-memory DB): the full
22
+ // status × slice-PR × acknowledgement matrix vs the app/delivery.ts adapters, the hand-authored
23
+ // display strings (`delivery_label`/`wave_label`, and 059's `bar` glyph), and the reconciler
24
+ // `derived_status`-bypass.
25
+ // 4. PAGE BINDINGS — the operator pages bind the derived VIEWs (`plan_read_model`, `plan_wave_summary`,
26
+ // `plan_wave_tasks`), never the raw `plans` table.
27
+ import { readFileSync } from "node:fs";
28
+ import { DatabaseSync } from "node:sqlite";
29
+ import { test } from "node:test";
30
+ import { fileURLToPath } from "node:url";
31
+ import { assertReadModelParity, assertRollupParity, type ParityDb, type ParitySample, type RollupInputs } from "@nanobpm/urban";
32
+ import { assert, assertEquals } from "#test-assert";
33
+ import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
34
+ import { planReadModel, PLAN_READ_MODEL_BASE_ALIAS, PLAN_READ_MODEL_DERIVED } from "./planReadModel.ts";
35
+ import { PLAN_ROLLUPS, planDeliveryCounts, planWaveCounts, planWaveProgress } from "./planRollups.ts";
36
+
37
+ const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
38
+ const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
39
+
40
+ const ROLLUPS_MIGRATION = "082_plan_rollups_declare_once.sql";
41
+ const READ_MODEL_MIGRATION = "083_plan_read_model_declare_once.sql";
42
+ // The forward chain whose net effect the end-to-end tests exercise: the original hand-authored VIEWs
43
+ // (059/060/061/074/080) then the declare-once supersessions (082/083). Mirrors the runtime migrator.
44
+ const MIGRATION_CHAIN = [
45
+ "059_plan_wave_summary.sql",
46
+ "060_plan_wave_rollup.sql",
47
+ "061_plan_delivery_rollup.sql",
48
+ "074_plan_read_model_derive_bucket.sql",
49
+ "080_plan_read_model_derive_terminal.sql",
50
+ ROLLUPS_MIGRATION,
51
+ READ_MODEL_MIGRATION,
52
+ ];
53
+
54
+ // The base `plans` / `plan_tasks` / `pull_requests` shapes the VIEWs read, plus a stand-in for the
55
+ // managed `plans__tracking` derived VIEW (ADR-0065) the read model reads its terminal-folded
56
+ // `derived_status` off. `derived_status_override` lets a test model the reconciler's derive edge (a
57
+ // terminated instance ⇒ `abandoned` while base `status` stays frozen). The vestigial stored
58
+ // `list_bucket`/`ack_open` columns (#439) are present so a test can seed STALE values and prove the
59
+ // VIEW ignores them.
60
+ function viewDb(): DatabaseSync {
61
+ const db = new DatabaseSync(":memory:");
62
+ db.exec(
63
+ `CREATE TABLE plans (
64
+ plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
65
+ status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT, updated_at TEXT,
66
+ epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT, promotion_pr TEXT,
67
+ promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER,
68
+ derived_status_override TEXT);
69
+ CREATE TABLE plan_tasks (
70
+ id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT, prompt TEXT,
71
+ status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT, wave INTEGER,
72
+ open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
73
+ CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT,
74
+ derived_status_override TEXT);`,
75
+ );
76
+ // Stand-in for the managed `plans__tracking` / `pull_requests__tracking` VIEWs urban provisions at
77
+ // mount: each re-exports `<base>.*` plus the terminal-folded `derived_status`. A test seeds
78
+ // `derived_status_override` to model the reconciler's derive edge (a terminated instance ⇒ `abandoned`
79
+ // while base `status` stays frozen); absent, it falls through to the base `status`. The plan-family
80
+ // count rollups join `pull_requests__tracking.derived_status` (ADR-0065 derive-only), the SAME column
81
+ // the canonical runtime reads (service.ts `prsTracking`), so the VIEW counts track a cancelled slice.
82
+ db.exec(
83
+ `CREATE VIEW plans__tracking AS
84
+ SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM plans p;
85
+ CREATE VIEW pull_requests__tracking AS
86
+ SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM pull_requests p;`,
87
+ );
88
+ for (const m of MIGRATION_CHAIN) db.exec(MIG(m));
89
+ return db;
90
+ }
91
+
92
+ interface SamplePlan {
93
+ status: string;
94
+ acknowledged_at?: string | null;
95
+ derived_status_override?: string | null;
96
+ /** Deliberately-stale STORED projection columns (a row the gateway last projected in another status).
97
+ * The VIEW must ignore these and re-derive. */
98
+ stored?: Partial<Record<"list_bucket" | "ack_open", string | number>>;
99
+ }
100
+
101
+ let taskId = 0;
102
+ function addPlan(db: DatabaseSync, plan_key: string, plan: SamplePlan): void {
103
+ const s = plan.stored ?? {};
104
+ db.prepare(
105
+ `INSERT INTO plans (plan_key, repo, issue_number, issue_url, title, status, acknowledged_at,
106
+ derived_status_override, list_bucket, ack_open, created_at, updated_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
108
+ ).run(
109
+ plan_key,
110
+ "o/r",
111
+ 1,
112
+ `https://gh/${plan_key}`,
113
+ `Epic ${plan_key}`,
114
+ plan.status,
115
+ plan.acknowledged_at ?? null,
116
+ plan.derived_status_override ?? null,
117
+ s.list_bucket ?? null,
118
+ s.ack_open ?? null,
119
+ "2026-01-01T00:00:00Z",
120
+ "2026-01-01T00:00:00Z",
121
+ );
122
+ }
123
+
124
+ /** Add one slice task, optionally with a PR. `prStatus === undefined` ⇒ no PR row (an un-opened slice);
125
+ * `prStatus === "missing"` ⇒ a `pr_key` with NO `pull_requests` row (the poller's dangling-PR sentinel).
126
+ * `prDerivedOverride` seeds the PR's `derived_status_override` (a DERIVE-ONLY terminated PR whose base
127
+ * `status` stays frozen but whose tracking `derived_status` recomputes, e.g. `abandoned`). */
128
+ function addTask(db: DatabaseSync, plan_key: string, opts: { status?: string; wave?: number | null; prStatus?: string; prDerivedOverride?: string | null }): void {
129
+ const id = taskId++;
130
+ const prKey = opts.prStatus === undefined ? null : `pr${id}`;
131
+ db.prepare(
132
+ "INSERT INTO plan_tasks (id, plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?, ?)",
133
+ ).run(id, plan_key, id, `t${id}`, opts.status ?? "opened", prKey, opts.wave ?? null);
134
+ if (prKey !== null && opts.prStatus !== "missing") {
135
+ db.prepare("INSERT INTO pull_requests (pr_key, url, status, process_key, derived_status_override) VALUES (?, ?, ?, ?, ?)").run(
136
+ prKey,
137
+ `https://gh/${prKey}`,
138
+ opts.prStatus,
139
+ `P${id}`,
140
+ opts.prDerivedOverride ?? null,
141
+ );
142
+ }
143
+ }
144
+
145
+ function readModel(db: DatabaseSync, plan_key: string): Record<string, unknown> {
146
+ return db.prepare("SELECT * FROM plan_read_model WHERE plan_key = ?").get(plan_key) as Record<string, unknown>;
147
+ }
148
+
149
+ // A `ParityDb` over node:sqlite's `DatabaseSync` for the framework parity guards (which need positional
150
+ // `exec`/`all`/`run`, whereas `DatabaseSync` exposes query methods on prepared statements).
151
+ function parityDb(db: DatabaseSync): ParityDb {
152
+ return {
153
+ exec: (sql) => db.exec(sql),
154
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...(params as never[])) as T[],
155
+ run: (sql, params: unknown[] = []) => {
156
+ const r = db.prepare(sql).run(...(params as never[]));
157
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
158
+ },
159
+ };
160
+ }
161
+
162
+ test("DRIFT GUARD: migration 082 embeds each rollup's VIEW DDL VERBATIM from rollup.viewDdl() (the VIEWs cannot drift from defineRollup)", () => {
163
+ const sql = MIG(ROLLUPS_MIGRATION);
164
+ for (const rollup of PLAN_ROLLUPS) {
165
+ assert(
166
+ sql.includes(rollup.viewDdl()),
167
+ `migration ${ROLLUPS_MIGRATION} no longer embeds the declaration's VIEW DDL for rollup "${rollup.decl.name}" — ` +
168
+ `regenerate it from app/planRollups.ts (or add a new superseding migration). Expected to contain:\n${rollup.viewDdl()}`,
169
+ );
170
+ // Each superseded VIEW is DROP+CREATEd (059/060/061 are immutable — this supersedes their bodies).
171
+ assert(new RegExp(`DROP VIEW IF EXISTS ${rollup.decl.name};`).test(sql), `082 must DROP the superseded "${rollup.decl.name}" first`);
172
+ }
173
+ });
174
+
175
+ test("DRIFT GUARD: migration 083 embeds each derived column VERBATIM from planReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
176
+ const sql = MIG(READ_MODEL_MIGRATION);
177
+ for (const col of PLAN_READ_MODEL_DERIVED) {
178
+ const emitted = planReadModel.sqlSelectFor(col, { baseAlias: PLAN_READ_MODEL_BASE_ALIAS });
179
+ assert(
180
+ sql.includes(`${emitted} AS ${col}`),
181
+ `migration ${READ_MODEL_MIGRATION} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
182
+ `from app/planReadModel.ts (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
183
+ );
184
+ }
185
+ // DROP+CREATE that supersedes 080 and folds in (drops) the now-redundant intermediate VIEWs, keeping
186
+ // every base column an aliased pass-through so the static pages↔schema contract guard still sees them.
187
+ assert(/DROP VIEW IF EXISTS plan_read_model;/.test(sql), "083 must DROP the superseded plan_read_model first");
188
+ assert(/DROP VIEW IF EXISTS plan_delivery;/.test(sql), "083 must fold in (drop) the retired plan_delivery");
189
+ assert(/DROP VIEW IF EXISTS plan_wave_label;/.test(sql), "083 must fold in (drop) the retired plan_wave_label");
190
+ assert(/CREATE VIEW plan_read_model AS/.test(sql), "083 must (re)create plan_read_model");
191
+ for (const base of ["plan_key", "repo", "issue_number", "title", "process_key", "epic_phase", "promotion_pr", "promotion_state"]) {
192
+ assert(sql.includes(`pl.${base} AS ${base}`), `083 must pass base column "${base}" through the VIEW`);
193
+ }
194
+ // The hand-authored display strings (D3 — no TS twin) live in this VIEW over the derived columns.
195
+ assert(sql.includes("AS delivery_label"), "083 must carry the hand-authored delivery_label display column");
196
+ assert(sql.includes("AS wave_label"), "083 must carry the hand-authored wave_label display column");
197
+ // The FROM/JOIN relation names are DERIVED from the declaration (baseTable + each lookup's rollup name
198
+ // + join keys), not hand-hardcoded — so renaming `baseTable` or a rollup `.name` (which would make 082
199
+ // create a different-named VIEW) breaks this guard instead of silently leaving 083 pointing at a
200
+ // stale/missing relation.
201
+ const alias = PLAN_READ_MODEL_BASE_ALIAS;
202
+ assert(sql.includes(`FROM ${planReadModel.decl.baseTable} ${alias}`), `083's FROM must be the declaration's baseTable "${planReadModel.decl.baseTable}" (aliased ${alias})`);
203
+ for (const lk of planReadModel.decl.lookups) {
204
+ const rollupName = lk.rollup.decl.name;
205
+ const on = lk.on.map((k) => `${alias}.${k.base} = ${lk.as}.${k.rollup}`).join(" AND ");
206
+ const join = `LEFT JOIN ${rollupName} ${lk.as} ON ${on}`;
207
+ assert(sql.includes(join), `083 must LEFT JOIN the declaration's "${rollupName}" lookup exactly as "${join}"`);
208
+ }
209
+ });
210
+
211
+ test("FRAMEWORK PARITY GUARD: each plan-family rollup's VIEW and TS reduce agree (assertRollupParity)", () => {
212
+ // Sample leaf rows spanning: multi-wave plans, every task/PR status, dangling pr_key (no PR row),
213
+ // un-levelized (NULL wave) tasks, and taskless plans — the predicates the rollups turn on.
214
+ const sampleSets: RollupInputs[] = [
215
+ {
216
+ plan_tasks: [
217
+ { plan_key: "a", pr_key: "a0", wave: 0, status: "opened" },
218
+ { plan_key: "a", pr_key: "a1", wave: 0, status: "opened" },
219
+ { plan_key: "a", pr_key: "a2", wave: 1, status: "escalated" },
220
+ { plan_key: "a", pr_key: null, wave: 1, status: "blocked" },
221
+ { plan_key: "a", pr_key: "a3", wave: null, status: "pending" },
222
+ { plan_key: "b", pr_key: "b0", wave: 0, status: "skipped" },
223
+ { plan_key: "b", pr_key: "bMissing", wave: 0, status: "opened" },
224
+ ],
225
+ pull_requests__tracking: [
226
+ { pr_key: "a0", derived_status: "merged" },
227
+ { pr_key: "a1", derived_status: "converging" },
228
+ { pr_key: "a2", derived_status: "merged" },
229
+ { pr_key: "a3", derived_status: "waiting_review" },
230
+ { pr_key: "b0", derived_status: "abandoned" },
231
+ ],
232
+ },
233
+ { plan_tasks: [], pull_requests__tracking: [] },
234
+ {
235
+ plan_tasks: [
236
+ { plan_key: "c", pr_key: "c0", wave: 0, status: "opened" },
237
+ { plan_key: "c", pr_key: "c1", wave: 2, status: "opened" },
238
+ ],
239
+ pull_requests__tracking: [
240
+ { pr_key: "c0", derived_status: "merged" },
241
+ { pr_key: "c1", derived_status: "converged" },
242
+ ],
243
+ },
244
+ ];
245
+ for (const rollup of PLAN_ROLLUPS) {
246
+ const db = new DatabaseSync(":memory:");
247
+ assertRollupParity(rollup, parityDb(db), sampleSets);
248
+ db.close();
249
+ }
250
+ });
251
+
252
+ test("FRAMEWORK PARITY GUARD: planReadModel's SQL and TS lowerings agree over the status × counts × ack matrix (assertReadModelParity)", () => {
253
+ const samples: ParitySample[] = [];
254
+ for (const status of ["planning", "dispatched", "done", "failed", "abandoned"]) {
255
+ for (const derived_status of [status, "abandoned"]) {
256
+ for (const acknowledged_at of [null, "2026-02-02T00:00:00Z"]) {
257
+ for (const dc of [
258
+ { prs_opened: 0, prs_merged: 0, prs_in_flight: 0 },
259
+ { prs_opened: 3, prs_merged: 1, prs_in_flight: 2 },
260
+ { prs_opened: 3, prs_merged: 3, prs_in_flight: 0 },
261
+ { prs_opened: 2, prs_merged: 1, prs_in_flight: 0 },
262
+ ]) {
263
+ for (const wp of [[], [{ plan_key: "self", wave_count: 5, current_wave: 2 }]]) {
264
+ samples.push({
265
+ baseRow: { plan_key: "self", status, derived_status, acknowledged_at },
266
+ lookups: { dc: [{ plan_key: "self", ...dc }], wp },
267
+ });
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ const db = new DatabaseSync(":memory:");
274
+ assertReadModelParity(planReadModel, parityDb(db), samples, { sql: { baseAlias: PLAN_READ_MODEL_BASE_ALIAS } });
275
+ db.close();
276
+ });
277
+
278
+ test("the migration 083 VIEW derives delivery / list_bucket / ack_open EXACTLY like the app/delivery.ts adapters, over the status × slice-PR × ack matrix", () => {
279
+ const db = viewDb();
280
+ const inFlightPr = "waiting_review";
281
+ // Slice-PR shapes exercising every delivery arm: none, all merged (landed), one in-flight
282
+ // (converging), all terminal-not-merged (resolved-null), and a dangling pr_key (in-flight).
283
+ const prSets: Record<string, string[]> = {
284
+ none: [],
285
+ landed: ["merged", "merged"],
286
+ converging: ["merged", inFlightPr],
287
+ resolved: ["merged", "abandoned"],
288
+ convergedOnly: ["merged", "converged"],
289
+ dangling: ["merged", "missing"],
290
+ };
291
+ const cases: Array<{ key: string; status: string; ackAt: string | null; override: string | null; prStatuses: string[] }> = [];
292
+ let i = 0;
293
+ for (const status of ["planning", "dispatched", "done", "failed", "abandoned"]) {
294
+ for (const [shape, prStatuses] of Object.entries(prSets)) {
295
+ for (const ackAt of [null, "2026-02-02T00:00:00Z"]) {
296
+ for (const override of [null, "abandoned"]) {
297
+ const key = `o/r#${i++}`;
298
+ cases.push({ key, status, ackAt, override, prStatuses });
299
+ addPlan(db, key, { status, acknowledged_at: ackAt, derived_status_override: override });
300
+ prStatuses.forEach((ps, w) => addTask(db, key, { status: "opened", wave: w, prStatus: ps }));
301
+ void shape;
302
+ }
303
+ }
304
+ }
305
+ }
306
+ for (const { key, status, ackAt, override, prStatuses } of cases) {
307
+ const row = readModel(db, key);
308
+ const effectiveStatus = override ?? status;
309
+ // `delivery` reads the BASE status (`done` is terminal, so base/effective agree on the gate).
310
+ const expected = deriveDelivery(status, prStatuses);
311
+ assertEquals(row.delivery, expected.delivery, `${key} (status=${status}): delivery`);
312
+ assertEquals(row.delivery_label, expected.label, `${key} (status=${status}): delivery_label`);
313
+ // `list_bucket`/`ack_open` classify on the terminal-folded effective status.
314
+ assertEquals(row.list_bucket, deriveEpicBucket(effectiveStatus, expected.delivery, ackAt), `${key} (status=${effectiveStatus}): list_bucket`);
315
+ const expectedAck = epicIsAcknowledgeable(effectiveStatus, expected.delivery) && ackAt === null ? 1 : 0;
316
+ assertEquals(row.ack_open, expectedAck, `${key} (status=${effectiveStatus}): ack_open`);
317
+ }
318
+ });
319
+
320
+ test("the migration 083 VIEW projects the wave frontier + 1-based wave_label from plan_wave_progress (taskless plan ⇒ all NULL)", () => {
321
+ const db = viewDb();
322
+ // 3 waves; wave 0 fully merged, wave 1 in-flight (the frontier), wave 2 pending ⇒ current_wave = 1.
323
+ addPlan(db, "o/r#w", { status: "done" });
324
+ addTask(db, "o/r#w", { status: "opened", wave: 0, prStatus: "merged" });
325
+ addTask(db, "o/r#w", { status: "opened", wave: 1, prStatus: "waiting_review" });
326
+ addTask(db, "o/r#w", { status: "opened", wave: 2, prStatus: "waiting_review" });
327
+ const w = readModel(db, "o/r#w");
328
+ assertEquals(w.wave_count, 3);
329
+ assertEquals(w.current_wave, 1);
330
+ assertEquals(w.wave_label, "2/3");
331
+
332
+ // A settled plan (every wave merged) pins current_wave to the last index ⇒ "N/N".
333
+ addPlan(db, "o/r#done", { status: "done" });
334
+ addTask(db, "o/r#done", { status: "opened", wave: 0, prStatus: "merged" });
335
+ addTask(db, "o/r#done", { status: "opened", wave: 1, prStatus: "merged" });
336
+ assertEquals(readModel(db, "o/r#done").wave_label, "2/2");
337
+
338
+ // A taskless plan has no rollup row ⇒ the LEFT JOIN reads NULL through every wave/delivery column.
339
+ addPlan(db, "o/r#empty", { status: "done" });
340
+ const empty = readModel(db, "o/r#empty");
341
+ assertEquals({ wave_label: empty.wave_label, wave_count: empty.wave_count, delivery: empty.delivery }, { wave_label: null, wave_count: null, delivery: null });
342
+ });
343
+
344
+ test("the migration 083 VIEW IGNORES stale STORED list_bucket / ack_open columns — it reads only the derived signals", () => {
345
+ const db = viewDb();
346
+ // A settled+acknowledged epic whose STORED bucket lies (frozen while it was live). The VIEW re-derives.
347
+ addPlan(db, "o/r#stale", { status: "done", acknowledged_at: "2026-02-02T00:00:00Z", stored: { list_bucket: "active", ack_open: 1 } });
348
+ addTask(db, "o/r#stale", { status: "opened", wave: 0, prStatus: "merged" });
349
+ const row = readModel(db, "o/r#stale");
350
+ assertEquals(row.list_bucket, "history", "an acknowledged landed epic is History regardless of the stale stored value");
351
+ assertEquals(row.ack_open, 0, "already acknowledged ⇒ no open Dismiss");
352
+ });
353
+
354
+ test("RED/GREEN #503: a DERIVE-ONLY terminated epic (base status frozen 'dispatched', derived_status='abandoned') drops out of Active with no worker write", () => {
355
+ // ADR-0065: cancel/terminate is DERIVE-ONLY — `plans__tracking.derived_status` recomputes `abandoned`
356
+ // on READ while the base `plans.status` stays frozen at its last transient. 083 classifies the bucket
357
+ // off `derived_status`, so a terminated epic renders History (not wedged Active) with no poller pass.
358
+ const db = viewDb();
359
+ addPlan(db, "o/r#term", { status: "dispatched", stored: { list_bucket: "active" } });
360
+ assertEquals(readModel(db, "o/r#term").list_bucket, "active", "precondition: a live dispatched epic is Active");
361
+
362
+ db.prepare("UPDATE plans SET derived_status_override = 'abandoned' WHERE plan_key = ?").run("o/r#term");
363
+ const row = readModel(db, "o/r#term");
364
+ assertEquals(row.list_bucket, "history", "a derive-only terminated epic is History (the #503 phantom fix)");
365
+ assertEquals(row.list_bucket, deriveEpicBucket("abandoned", row.delivery === "converging" ? "converging" : null, null), "list_bucket tracks derived_status via the VIEW");
366
+ });
367
+
368
+ test("REGRESSION (Copilot #493): a DERIVE-ONLY terminated slice PR (base status frozen 'converging', derived_status='abandoned') is counted RESOLVED — the VIEW joins pull_requests__tracking.derived_status", () => {
369
+ // ADR-0065 derive-only: cancelling a PR instance recomputes `pull_requests__tracking.derived_status`
370
+ // to `abandoned` on READ while the base `pull_requests.status` stays frozen at its last transient
371
+ // (`converging`). The runtime (`service.ts` `derivePlanDelivery`) reads `prsTracking(...).derived_status`,
372
+ // so the `plan_delivery_counts` rollup MUST join the SAME derived column — otherwise the VIEW would
373
+ // read the frozen `converging` base, count the slice `prs_in_flight`, and WEDGE the epic at
374
+ // `delivery='converging'` forever after its PR was cancelled. This asserts the rollup resolves it.
375
+ const db = viewDb();
376
+ addPlan(db, "o/r#cancel", { status: "done" });
377
+ addTask(db, "o/r#cancel", { status: "opened", wave: 0, prStatus: "merged" });
378
+ // A slice whose base PR row is frozen 'converging' but derived (terminal-folded) to 'abandoned'.
379
+ addTask(db, "o/r#cancel", { status: "opened", wave: 1, prStatus: "converging", prDerivedOverride: "abandoned" });
380
+ const row = readModel(db, "o/r#cancel");
381
+ // Both slice PRs are terminal (one merged, one derived-abandoned) ⇒ nothing in flight, not all merged.
382
+ assertEquals(row.delivery, null, "a cancelled slice is resolved-not-landed, not a wedged 'converging'");
383
+ // The pre-derive/raw-status VIEW would have read 'converging' → prs_in_flight=1 → delivery='converging'.
384
+ assert(row.delivery !== "converging", "the derived_status join closes the raw-status wedge (Copilot #493)");
385
+
386
+ // A NON-overridden corpus stays byte-identical: derived_status falls through to base status, so a
387
+ // genuinely in-flight 'converging' slice still reads converging (the fix only moves overridden rows).
388
+ addPlan(db, "o/r#live", { status: "done" });
389
+ addTask(db, "o/r#live", { status: "opened", wave: 0, prStatus: "merged" });
390
+ addTask(db, "o/r#live", { status: "opened", wave: 1, prStatus: "converging" });
391
+ assertEquals(readModel(db, "o/r#live").delivery, "converging", "a live (non-overridden) converging slice is unchanged");
392
+ });
393
+
394
+ test("the migration 082 plan_wave_summary VIEW still pre-formats the `bar` glyph over the (now framework-emitted) plan_wave_counts", () => {
395
+ const db = viewDb();
396
+ const plan = "o/r#bar";
397
+ addPlan(db, plan, { status: "done" });
398
+ // Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
399
+ addTask(db, plan, { status: "opened", wave: 0, prStatus: "merged" });
400
+ addTask(db, plan, { status: "opened", wave: 0, prStatus: "merged" });
401
+ addTask(db, plan, { status: "opened", wave: 0, prStatus: "merged" });
402
+ addTask(db, plan, { status: "opened", wave: 0, prStatus: "converging" });
403
+ addTask(db, plan, { status: "blocked", wave: 0 });
404
+ // Wave 1 — an escalated slice (its PR still escalated) and a skipped slice.
405
+ addTask(db, plan, { status: "escalated", wave: 1, prStatus: "escalated" });
406
+ addTask(db, plan, { status: "skipped", wave: 1 });
407
+
408
+ const rows = db.prepare("SELECT wave, total, merged, in_flight, blocked, escalated, skipped, bar FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave").all(plan) as Array<Record<string, unknown>>;
409
+ assertEquals(rows.length, 2);
410
+ assertEquals(rows[0].bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
411
+ assertEquals(rows[1].bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
412
+
413
+ // A merged PR wins over the task's own status (the PR-merged predicate), and un-levelized (NULL wave)
414
+ // tasks are excluded from every wave row.
415
+ addPlan(db, "o/r#bar2", { status: "done" });
416
+ addTask(db, "o/r#bar2", { status: "escalated", wave: 0, prStatus: "merged" });
417
+ addTask(db, "o/r#bar2", { status: "pending", wave: null });
418
+ const r2 = db.prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?").all("o/r#bar2") as Array<Record<string, unknown>>;
419
+ assertEquals(r2.length, 1);
420
+ assertEquals({ ...r2[0] }, { wave: 0, total: 1, merged: 1, escalated: 0 });
421
+ });
422
+
423
+ test("plan_wave_tasks carries each task's PR url + process_key link targets (unchanged display VIEW)", () => {
424
+ const db = viewDb();
425
+ addPlan(db, "o/r#lt", { status: "done" });
426
+ addTask(db, "o/r#lt", { status: "opened", wave: 0, prStatus: "converging" });
427
+ addTask(db, "o/r#lt", { status: "blocked", wave: 0 }); // no PR → null link targets
428
+ const rows = db.prepare("SELECT pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index").all("o/r#lt") as Array<Record<string, unknown>>;
429
+ assert(rows[0].pr_url != null && rows[0].process_key != null, "an opened slice links to its PR url + process instance");
430
+ assertEquals({ pr_url: rows[1].pr_url, process_key: rows[1].process_key }, { pr_url: null, process_key: null });
431
+ });
432
+
433
+ test("the operator pages bind the derived plan-family VIEWs (never the raw plans table)", () => {
434
+ // Overview + Epic index + Epic detail all read the composite `plan_read_model`; the epic-detail
435
+ // per-wave summary reads `plan_wave_summary` (the bar), and the wave-state grid `plan_wave_tasks`.
436
+ const overview = PAGE("overview.page.json");
437
+ const epics = (overview.nodes ?? []).find((n: { id: string }) => n.id === "overview-epics");
438
+ assert(epics, "overview must keep the epics grid");
439
+ assertEquals(epics.props.data.table, "plan_read_model");
440
+
441
+ const epicIndex = PAGE("epic.page.json");
442
+ const epicPlans = (epicIndex.nodes ?? []).find((n: { props?: { data?: { table?: string } } }) => n.props?.data?.table === "plan_read_model");
443
+ assert(epicPlans, "the Epics index grid must read the derived plan_read_model VIEW");
444
+
445
+ const detail = PAGE("epic-detail.page.json");
446
+ const byId = (id: string) => (detail.nodes ?? []).find((n: { id: string }) => n.id === id);
447
+ assertEquals(byId("wave-banner").props.data.table, "plan_read_model");
448
+ // The primary epic detail grid must also read the derived read model, not the raw `plans` table — a
449
+ // regression pointing it at `plans` would otherwise pass this guard (suppressed advisory test:400).
450
+ assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
451
+ assertEquals(byId("wave-summary").props.data.table, "plan_wave_summary");
452
+ const summaryCols: string[] = byId("wave-summary").props.columns.map((c: { field: string }) => c.field);
453
+ for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
454
+ assert(summaryCols.includes(f), `the summary grid shows ${f}`);
455
+ }
456
+ assertEquals(byId("wave-state").props.data.table, "plan_wave_tasks");
457
+ });
458
+
459
+ test("the app/delivery.ts adapters route through the framework backends (planDeliveryCounts.reduce + planReadModel.evaluate) — the wave rollups compose", () => {
460
+ // A guard that the declared rollups are wired as the adapters' engine: the two count rollups fold the
461
+ // same slice-PR partition, and the composed wave-progress rollup reads the wave-counts rollup.
462
+ assertEquals(planWaveProgress.sourceRelations.slice().sort(), ["plan_tasks", "pull_requests__tracking"]);
463
+ assertEquals(planWaveCounts.groupBy, ["plan_key", "wave"]);
464
+ assertEquals(planDeliveryCounts.groupBy, ["plan_key"]);
465
+ // The adapters produce the framework-folded counts (deriveDelivery is the reduce()+evaluate() façade).
466
+ const r = deriveDelivery("done", ["merged", "waiting_review", "missing"]);
467
+ assertEquals({ prsOpened: r.prsOpened, prsMerged: r.prsMerged, prsInFlight: r.prsInFlight }, { prsOpened: 3, prsMerged: 1, prsInFlight: 2 });
468
+ assertEquals(r.delivery, "converging");
469
+ assertEquals(r.label, "1/3 slices merged, 2 converging");
470
+ });
@@ -0,0 +1,159 @@
1
+ // The `plan_read_model` per-row signals — DECLARED ONCE and compiled to BOTH backends via Urban's
2
+ // ADR-0065 reconciling-read-model primitive (`defineReadModel` + key-correlated rollup lookups,
3
+ // `@nanobpm/urban`, capability nano-ide#468 / `@nanobpm/urban@0.82.0`). The exemplar is
4
+ // app/featureReadModel.ts; this is its plan-family twin (issue #493, the sole remaining ADR-0065
5
+ // surface-#2 slice — nano-ide#452 step 2).
6
+ //
7
+ // Background (issues #171 → #298 → #412 → #439 → #503). The Epic surfaces render DERIVED per-row state
8
+ // off each `plans` row plus its slice-PR/wave aggregates: the delivery signal (`delivery`), the wave
9
+ // frontier (`wave_count`/`current_wave`), and the Active/History partition + operator tick-off
10
+ // (`list_bucket`/`ack_open`). None are ground truth: each is a pure function of the plan's own
11
+ // effective status + acknowledgement, correlated with the plan-family GROUP-BY rollups
12
+ // (app/planRollups.ts). 061/074/080 authored these TWICE — a SQL `CASE` inside the `plan_delivery` /
13
+ // `plan_read_model` VIEWs AND a TS oracle (`deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable`,
14
+ // app/delivery.ts) — kept in lockstep by hand-written parity tests (drift surface #2, ADR-0065).
15
+ //
16
+ // This module closes surface #2 STRUCTURALLY: every derivation is expressed ONCE in Urban's closed
17
+ // expression DSL, and Urban compiles it to BOTH the SQLite VIEW select-list (`sqlSelectFor`, emitted
18
+ // verbatim into the superseding migration, drift-guarded) AND the runtime TS function (`fnFor`, the
19
+ // sole engine behind the `deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable` adapters in
20
+ // app/delivery.ts). `assertReadModelParity` (app/planReadModel.test.ts) is now the framework-owned
21
+ // regression guard that the two lowerings agree.
22
+ //
23
+ // SCOPE. The GROUP-BY aggregates are single-sourced in app/planRollups.ts (`defineRollup`); this model
24
+ // CONSUMES their columns via key-correlated `LEFT JOIN <rollup> ON plan_key` lookups (D1's per-row
25
+ // half). The pre-formatted display strings (`delivery_label`, `wave_label`, the wave `bar`) stay
26
+ // hand-authored *display* columns over these derived structured columns (D3 — display formatting is
27
+ // out of the framework AST); they carry no TS twin, so no surface-#2 obligation. The epic's
28
+ // write-time domain phase (`epic_phase`, app/epicPhase.ts) is a PROVENANCE projection (each spine
29
+ // worker stamps its own BPMN element's phase) with no SQL twin — it is not a per-row function of the
30
+ // plan row, so it stays hand-authored and out of this declaration.
31
+
32
+ import { and, caseWhen, col, defineReadModel, type Expr, eq, gt, isNull, lit, not, or, type ReadModel, rcol, when } from "@nanobpm/urban";
33
+ import { planDeliveryCounts, planWaveProgress } from "./planRollups.ts";
34
+
35
+ /** The base table the read model reads: the auto-provisioned `plans__tracking` derived VIEW (ADR-0065,
36
+ * urban 0.81.0), NOT the raw `plans` table. It re-exports `plans.*` plus a terminal-folded
37
+ * `derived_status` (`abandoned` on an out-of-band-terminated instance, else the base `plans.status`),
38
+ * so the Active/History bucket derivations below classify on ENGINE TRUTH and a cancelled epic drops
39
+ * out of Active with no worker write (issue #503) — exactly as `feature_read_model` reads
40
+ * `feature_runs__tracking`. */
41
+ export const PLAN_READ_MODEL_BASE_TABLE = "plans__tracking";
42
+
43
+ /** The base alias the managed VIEW gives `plans__tracking` — pinned so the emitted derived-column SQL
44
+ * (`pl."col"`) matches the superseding migration exactly (the drift guard compares against this alias). */
45
+ export const PLAN_READ_MODEL_BASE_ALIAS = "pl";
46
+
47
+ /** The rollup-lookup aliases `rcol(...)` reads under — the `LEFT JOIN <rollup> <alias> ON plan_key`
48
+ * targets. `dc` = `plan_delivery_counts` (slice-PR counts), `wp` = `plan_wave_progress` (wave
49
+ * frontier). Pinned so the emitted SQL and the migration's hand-authored JOIN aliases agree. */
50
+ export const DELIVERY_COUNTS_LOOKUP = "dc";
51
+ export const WAVE_PROGRESS_LOOKUP = "wp";
52
+
53
+ /** The effective (terminal-folded) status column the bucket/ack derivations classify on — the tracking
54
+ * VIEW's `derived_status`. Single source of truth for the name so the derivations can't drift from it. */
55
+ export const EFFECTIVE_STATUS_COLUMN = "derived_status";
56
+
57
+ /** The BASE `plans.status` column the delivery classification reads. `delivery` is only ever non-null
58
+ * for a `done` epic, and `done` is already terminal (no reconciler derive edge re-writes it), so base
59
+ * and effective status agree on the `= 'done'` gate; reading base `status` here keeps the `delivery`
60
+ * column byte-identical to the retired `plan_delivery` VIEW (061) and to `deriveDelivery(plan.status,
61
+ * …)`'s call sites, which pass the base status. */
62
+ const BASE_STATUS_COLUMN = "status";
63
+
64
+ const ds = col(EFFECTIVE_STATUS_COLUMN);
65
+ const bs = col(BASE_STATUS_COLUMN);
66
+
67
+ /** The derived epic `delivery` signal — the byte-for-byte twin of the retired `plan_delivery` VIEW's
68
+ * CASE (061) and of `deriveDelivery` (app/delivery.ts), now a per-row classification over the
69
+ * `plan_delivery_counts` rollup lookup:
70
+ * - NULL when the plan is not `done` OR opened no PRs (no positive signal yet), OR every PR is
71
+ * terminal but not all merged (resolved-not-landed — the `ELSE` arm).
72
+ * - 'converging' when ≥1 opened slice PR is still in flight (`prs_in_flight > 0`).
73
+ * - 'landed' when every opened slice PR merged (`prs_merged = prs_opened`, `prs_in_flight = 0`).
74
+ * The lookup's `prs_*` default to 0 on a LEFT-JOIN miss, so a plan with no `plan_delivery_counts` row
75
+ * reads `prs_opened = 0` ⇒ NULL, exactly `COALESCE(c.prs_opened, 0) = 0`. */
76
+ const delivery: Expr = caseWhen(
77
+ [
78
+ when(or(not(eq(bs, lit("done"))), eq(rcol(DELIVERY_COUNTS_LOOKUP, "prs_opened"), lit(0))), lit(null)),
79
+ when(gt(rcol(DELIVERY_COUNTS_LOOKUP, "prs_in_flight"), lit(0)), lit("converging")),
80
+ when(eq(rcol(DELIVERY_COUNTS_LOOKUP, "prs_merged"), rcol(DELIVERY_COUNTS_LOOKUP, "prs_opened")), lit("landed")),
81
+ ],
82
+ lit(null),
83
+ );
84
+
85
+ /** The Active/History partition (`deriveEpicBucket`, app/delivery.ts) — the byte-for-byte twin of the
86
+ * `plan_read_model` VIEW's bucket CASE (074/080). `active` while the epic is LIVE (planning/dispatched)
87
+ * OR `done`-but-still-`converging` (genuinely working) OR `done`-but-unacknowledged (stay actionable
88
+ * until dismissed); `history` once truly resolved (a `done` epic the operator acknowledged, or a
89
+ * terminal non-`done` status). Classifies the status arms on the terminal-folded `derived_status` so a
90
+ * cancelled epic falls to History; the `converging` arm reuses the {@link delivery} sub-expression
91
+ * (base-status-derived) so the two columns can't disagree. */
92
+ const listBucket: Expr = caseWhen(
93
+ [
94
+ when(or(eq(ds, lit("planning")), eq(ds, lit("dispatched"))), lit("active")),
95
+ when(and(eq(ds, lit("done")), eq(delivery, lit("converging"))), lit("active")),
96
+ when(and(eq(ds, lit("done")), isNull(col("acknowledged_at"))), lit("active")),
97
+ when(eq(ds, lit("done")), lit("history")),
98
+ ],
99
+ lit("history"),
100
+ );
101
+
102
+ /** The operator "Dismiss" (acknowledge) affordance flag (`epicIsAcknowledgeable` ∧ unacknowledged) —
103
+ * the byte-for-byte twin of the `plan_read_model` VIEW's `ack_open` CASE (074/080): `1` iff the epic is
104
+ * `done`, its fan-out has RESOLVED (`delivery` is not `converging`), and it is not yet acknowledged;
105
+ * else `0`. `not(eq(delivery, 'converging'))` matches the VIEW's null-safe `d.delivery IS NOT
106
+ * 'converging'` (a NULL delivery ⇒ resolved ⇒ acknowledgeable) under the shared "NULL → false" rule. */
107
+ const ackOpen: Expr = caseWhen(
108
+ [when(and(eq(ds, lit("done")), not(eq(delivery, lit("converging"))), isNull(col("acknowledged_at"))), lit(1))],
109
+ lit(0),
110
+ );
111
+
112
+ /** The wave frontier columns — bare pass-throughs of the `plan_wave_progress` rollup lookup (a
113
+ * taskless plan has no rollup row, so the LEFT-JOIN miss reads NULL, matching the workers' behaviour).
114
+ * They are structured columns of THIS model so the thin display `wave_label` (migration) can format
115
+ * `(current_wave + 1)/wave_count` over them without a TS twin. */
116
+ const waveCount: Expr = rcol(WAVE_PROGRESS_LOOKUP, "wave_count");
117
+ const currentWave: Expr = rcol(WAVE_PROGRESS_LOOKUP, "current_wave");
118
+
119
+ /** The keys of {@link planReadModel}'s DERIVED columns, in the order the superseding migration emits
120
+ * them. Base columns are identity pass-throughs (not derivations) and are listed in the migration
121
+ * directly; `delivery_label`/`wave_label` are hand-authored display columns (no TS twin). */
122
+ export const PLAN_READ_MODEL_DERIVED = ["delivery", "wave_count", "current_wave", "list_bucket", "ack_open"] as const;
123
+ export type PlanReadModelDerivedColumn = (typeof PLAN_READ_MODEL_DERIVED)[number];
124
+
125
+ /**
126
+ * The declare-once `plan_read_model` derived columns. `selectBaseColumns: false` because the base
127
+ * columns are plain identity pass-throughs enumerated in the migration (so the static pages↔schema
128
+ * contract guard, which reads a VIEW's columns off an aliased select-list, sees them) — and because
129
+ * the `plans` base row still carries the vestigial `list_bucket`/`ack_open` columns (#439), which a
130
+ * `base.*` splat would collide with these derivations. This model owns only the five real DERIVATIONS;
131
+ * both the migration VIEW (`sqlSelectFor`, drift-guarded) and the runtime TS oracle (`fnFor`, behind
132
+ * app/delivery.ts) are generated from THIS single declaration, with the two rollup lookups supplying
133
+ * the aggregate columns the per-row CASEs consume.
134
+ */
135
+ export const planReadModel: ReadModel = defineReadModel({
136
+ name: "plan_read_model",
137
+ baseTable: PLAN_READ_MODEL_BASE_TABLE,
138
+ selectBaseColumns: false,
139
+ lookups: [
140
+ {
141
+ as: DELIVERY_COUNTS_LOOKUP,
142
+ rollup: planDeliveryCounts,
143
+ on: [{ base: "plan_key", rollup: "plan_key" }],
144
+ defaults: { prs_opened: 0, prs_merged: 0, prs_in_flight: 0 },
145
+ },
146
+ {
147
+ as: WAVE_PROGRESS_LOOKUP,
148
+ rollup: planWaveProgress,
149
+ on: [{ base: "plan_key", rollup: "plan_key" }],
150
+ },
151
+ ],
152
+ derive: {
153
+ delivery,
154
+ wave_count: waveCount,
155
+ current_wave: currentWave,
156
+ list_bucket: listBucket,
157
+ ack_open: ackOpen,
158
+ },
159
+ });