@nanobpm/nano-workforce 0.124.0 → 0.125.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.125.0](https://github.com/nanobpm/nano-workforce/compare/v0.124.0...v0.125.0) (2026-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **read-model:** declare feature_read_model once via Urban defineReadModel (ADR-0065 step 2) ([#472](https://github.com/nanobpm/nano-workforce/issues/472)) ([6424efb](https://github.com/nanobpm/nano-workforce/commit/6424efbeabec60366567e218f12edd996bb96780)), closes [#422](https://github.com/nanobpm/nano-workforce/issues/422)
7
+
1
8
  # [0.124.0](https://github.com/nanobpm/nano-workforce/compare/v0.123.2...v0.124.0) (2026-08-23)
2
9
 
3
10
 
@@ -1,39 +1,41 @@
1
- // Read-model VIEW coverage for the feature-run pipeline projection (issue #439 the status-driven
2
- // follow-up to epic #412, "Retire worker-maintained denormalized projections in favour of SQL
3
- // VIEWs").
1
+ // Read-model coverage for the feature-run pipeline projection (issues #412 → #439 #422), now
2
+ // authored via Urban's ADR-0065 declare-once primitive (`defineReadModel`, app/featureReadModel.ts).
4
3
  //
5
- // 039_feature_pipeline_stage.sql denormalised the pipeline projection
6
- // (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
7
- // written by the `featureRuns()` gateway (app/feature.ts) at WRITE TIME from the pure `deriveStage` /
8
- // `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant did NOT hold
9
- // for the framework `instanceTracking` reconciler, which writes `feature_runs.status` through the RAW
10
- // datasource on a terminated instance bypassing the gateway and freezing the display columns.
11
- // 073_feature_read_model.sql retires the write-time projection: the derived columns are now a VIEW
12
- // over each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`, so there is no
13
- // stored column and no write-path for any writer to leave stale. 075_feature_read_model_attention_
14
- // from_user_tasks.sql then moves `attention` off the drift-prone `status` variable onto ENGINE TRUTH
15
- // — an OPEN `feature-blocked`/`feature-escalation` row in the `user_tasks` inbox (issue #422).
4
+ // 039_feature_pipeline_stage.sql denormalised the projection (`stage`/`stage_state`/`stage_skipped`/
5
+ // `attention`/`list_bucket`) onto the `feature_runs` row at WRITE TIME; 073 retired that into a VIEW
6
+ // over each row's own columns; 075 moved `attention` onto ENGINE TRUTH (an OPEN `user_tasks` row,
7
+ // issue #422). Each of those hand-wired the derived columns TWICE the SQL CASE/EXISTS AND the TS
8
+ // oracle (`deriveStage`/`deriveListBucket`) kept in lockstep by a bespoke parity test (drift
9
+ // surface #2). 076_feature_read_model_declare_once.sql supersedes them: every derived column is now
10
+ // emitted from the ONE `featureReadModel` declaration, which ALSO drives the TS via `fnFor`. This
11
+ // suite therefore guards THREE things:
16
12
  //
17
- // This exercises the REAL SQLite view (073+075 applied to an in-memory DB, mirroring
18
- // app/plansReadModel.test.ts / app/mergesPerDayView.test.ts) and pins that its CASE expressions
19
- // reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status × open-task matrix — the
20
- // SAME pure helpers the acknowledge operations guard on plus RED/GREEN guards reproducing the
21
- // reconciler bypass (a RAW-datasource `status` write must leave the projection correct) and the #422
22
- // answered-escalation drift (a sticky `status='escalated'` with no open task must show no ).
13
+ // 1. DRIFT GUARD migration 076 embeds each derived column's SQL VERBATIM from
14
+ // `featureReadModel.sqlSelectFor(...)`, so the checked-in VIEW cannot drift from the declaration.
15
+ // 2. FRAMEWORK PARITY GUARD `assertReadModelParity` proves the SQL and TS lowerings the ONE
16
+ // declaration compiles to agree (the role the old hand-written lockstep test played, now
17
+ // framework-owned).
18
+ // 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (076 applied to an in-memory DB): the full
19
+ // status × open-task matrix vs the model-derived oracle, the stale-stored-column ignore, the
20
+ // reconciler `status`-bypass, the #422 answered-escalation drift, and the page binding.
23
21
  import { readFileSync } from "node:fs";
24
22
  import { DatabaseSync } from "node:sqlite";
25
23
  import { test } from "node:test";
26
24
  import { fileURLToPath } from "node:url";
25
+ import { assertReadModelParity, type ParityDb, type ParitySample } from "@nanobpm/urban";
27
26
  import { assert, assertEquals } from "#test-assert";
28
27
  import { FEATURE_RUN_STATUSES } from "./feature.ts";
28
+ import { FEATURE_READ_MODEL_BASE_ALIAS, FEATURE_READ_MODEL_DERIVED, featureReadModel } from "./featureReadModel.ts";
29
29
  import { deriveListBucket, deriveStage } from "./stage.ts";
30
30
 
31
31
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
32
32
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
33
33
 
34
- // The base `feature_runs` shape the view reads (028 + 030's delivery_label + 035's title + 039's
35
- // acknowledged_at & the now-vestigial stored stage/… columns), plus migration 073. The stored derived
36
- // columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them.
34
+ const MIGRATION_076 = "076_feature_read_model_declare_once.sql";
35
+
36
+ // The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
37
+ // derivation `EXISTS`-reads. The stored derived columns are present precisely so the tests can seed
38
+ // STALE values and prove the VIEW ignores them.
37
39
  function viewDb(): DatabaseSync {
38
40
  const db = new DatabaseSync(":memory:");
39
41
  db.exec(
@@ -43,16 +45,12 @@ function viewDb(): DatabaseSync {
43
45
  outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
44
46
  stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
45
47
  );
46
- // The `user_tasks` inbox (034_user_tasks_inbox.sql) — the engine-truth source the 075 VIEW derives
47
- // `attention` from (a row IFF an escalation user task is OPEN). Minimal shape: the three columns the
48
- // correlated EXISTS lookups read, plus its PK.
49
48
  db.exec(
50
49
  `CREATE TABLE user_tasks (
51
50
  user_task_key TEXT PRIMARY KEY, element_id TEXT NOT NULL, subject_type TEXT NOT NULL,
52
51
  subject_key TEXT NOT NULL);`,
53
52
  );
54
- db.exec(MIG("073_feature_read_model.sql"));
55
- db.exec(MIG("075_feature_read_model_attention_from_user_tasks.sql"));
53
+ db.exec(MIG(MIGRATION_076));
56
54
  return db;
57
55
  }
58
56
 
@@ -71,7 +69,7 @@ interface SampleRun {
71
69
  auto_merge?: number;
72
70
  acknowledged_at?: string | null;
73
71
  // Deliberately-stale STORED projection columns (simulating a row the gateway last projected while
74
- // in a different status). The VIEW must ignore these and re-derive from `status`.
72
+ // in a different status). The VIEW must ignore these and re-derive.
75
73
  stored?: Partial<Record<"stage" | "stage_state" | "stage_skipped" | "attention" | "list_bucket", string>>;
76
74
  }
77
75
 
@@ -114,7 +112,67 @@ function projection(db: DatabaseSync, feature_key: string): Record<string, unkno
114
112
  return { ...r };
115
113
  }
116
114
 
117
- test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
115
+ // A `ParityDb` over node:sqlite's `DatabaseSync` for `assertReadModelParity` (which needs positional
116
+ // `exec`/`all`/`run`, whereas `DatabaseSync` exposes query methods on prepared statements).
117
+ function parityDb(db: DatabaseSync): ParityDb {
118
+ return {
119
+ exec: (sql) => db.exec(sql),
120
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
121
+ db.prepare(sql).all(...(params as never[])) as T[],
122
+ run: (sql, params: unknown[] = []) => {
123
+ const r = db.prepare(sql).run(...(params as never[]));
124
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
125
+ },
126
+ };
127
+ }
128
+
129
+ test("DRIFT GUARD: migration 076 embeds each derived column VERBATIM from featureReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
130
+ const sql = MIG(MIGRATION_076);
131
+ for (const col of FEATURE_READ_MODEL_DERIVED) {
132
+ const emitted = featureReadModel.sqlSelectFor(col, { baseAlias: FEATURE_READ_MODEL_BASE_ALIAS });
133
+ assert(
134
+ sql.includes(`${emitted} AS ${col}`),
135
+ `migration ${MIGRATION_076} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
136
+ `from featureReadModel (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
137
+ );
138
+ }
139
+ // The VIEW is a DROP+CREATE that supersedes 073/075, and keeps every base column as an aliased
140
+ // pass-through so the static pages↔schema contract guard still sees them.
141
+ assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "076 must DROP the superseded VIEW first");
142
+ assert(/CREATE VIEW feature_read_model AS/.test(sql), "076 must (re)create feature_read_model");
143
+ for (const base of ["feature_key", "status", "pr_key", "converge", "auto_merge", "acknowledged_at", "title", "repo"]) {
144
+ assert(sql.includes(`fr.${base} AS ${base}`), `076 must pass base column "${base}" through the VIEW`);
145
+ }
146
+ });
147
+
148
+ test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over the full status × open-task matrix (assertReadModelParity)", () => {
149
+ const samples: ParitySample[] = [];
150
+ for (const status of FEATURE_RUN_STATUSES) {
151
+ for (const converge of [0, 1]) {
152
+ for (const auto_merge of [0, 1]) {
153
+ for (const pr_key of [null, "o/r#pr"]) {
154
+ for (const acknowledged_at of [null, "2026-02-02T00:00:00Z"]) {
155
+ const el = status === "escalated" ? "feature-escalation" : status === "awaiting_operator" ? "feature-blocked" : null;
156
+ for (const openTask of el !== null ? [false, true] : [false]) {
157
+ const userTasks =
158
+ openTask && el !== null ? [{ subject_type: "feature", subject_key: "self", element_id: el }] : [];
159
+ samples.push({
160
+ baseRow: { feature_key: "self", status, pr_key, converge, auto_merge, acknowledged_at },
161
+ projections: { user_tasks: userTasks },
162
+ });
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ // A bare in-memory handle: the guard builds/drops its OWN TEMP fixtures, so it needs no schema.
170
+ const db = new DatabaseSync(":memory:");
171
+ assertReadModelParity(featureReadModel, parityDb(db), samples);
172
+ db.close();
173
+ });
174
+
175
+ test("the migration 076 VIEW derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
118
176
  const db = viewDb();
119
177
  const cases: Array<{ key: string; run: SampleRun; hasOpenBlockedTask: boolean; hasOpenEscalationTask: boolean }> = [];
120
178
  let i = 0;
@@ -123,10 +181,7 @@ test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACT
123
181
  for (const auto_merge of [0, 1]) {
124
182
  for (const pr_key of [null, `o/r#pr${i}`]) {
125
183
  // The open-task dimension only matters for the two human-wait statuses (escalated/
126
- // awaiting_operator), whose derivation reads open tasks for them exercise BOTH
127
- // task-present and task-absent (the #422 drift case = the task already gone). Every other
128
- // status ignores open tasks (`el` is null, so no task is ever created), so iterating the
129
- // dimension there would only duplicate identical cases; iterate [false] alone.
184
+ // awaiting_operator); every other status ignores open tasks, so iterate [false] alone there.
130
185
  const el = status === "escalated" ? "feature-escalation" : status === "awaiting_operator" ? "feature-blocked" : null;
131
186
  for (const openTask of el !== null ? [false, true] : [false]) {
132
187
  const key = `o/r#${i++}`;
@@ -162,7 +217,7 @@ test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACT
162
217
  }
163
218
  });
164
219
 
165
- test("feature_read_model derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
220
+ test("the migration 076 VIEW derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
166
221
  const db = viewDb();
167
222
  let i = 0;
168
223
  const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
@@ -182,7 +237,7 @@ test("feature_read_model derives list_bucket EXACTLY like deriveListBucket (hist
182
237
  }
183
238
  });
184
239
 
185
- test("feature_read_model IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
240
+ test("the migration 076 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
186
241
  const db = viewDb();
187
242
  // A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
188
243
  addRun(db, "o/r#stale", {
@@ -202,10 +257,10 @@ test("feature_read_model IGNORES any stale STORED projection columns — it read
202
257
  test("RED/GREEN GUARD #422: an ANSWERED escalation (status sticky 'escalated', no open user task) shows NO ⚠; the badge tracks the OPEN task, not status", () => {
203
258
  // The `feature` process answer-loop returns the token to `implement-task` without resetting the
204
259
  // `status` variable, so a run whose escalation was already answered still reads `status="escalated"`
205
- // until its next agent job completes (observed live on merlin: feature instance 31779). The OLD VIEW
206
- // derived `attention` from that value and rendered a stale on Overview. The badge now derives from
207
- // engine truth the presence of an OPEN `feature-escalation` user task (`pollUserTasks` deletes the
208
- // row the moment it is answered) — so it clears immediately regardless of the stale status.
260
+ // until its next agent job completes (observed live on merlin: feature instance 31779). The badge
261
+ // now derives from engine truth the presence of an OPEN `feature-escalation` user task
262
+ // (`pollUserTasks` deletes the row the moment it is answered) so it clears immediately regardless
263
+ // of the stale status.
209
264
  const db = viewDb();
210
265
 
211
266
  // Answered escalation: status STILL 'escalated' (stale) + a stored ⚠ that lied, but NO open task.
@@ -233,44 +288,32 @@ test("RED/GREEN GUARD #422: an ANSWERED escalation (status sticky 'escalated', n
233
288
  test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceTracking reconciler bypass) leaves the projection CORRECT (stage=Done, terminal stage_state, attention=null, Dismiss renderable, still Active)", () => {
234
289
  // Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated (cancelled)
235
290
  // process instance it writes `{status:"abandoned"}` to `feature_runs` through the RAW datasource,
236
- // bypassing the (now retired) projecting `featureRuns` gateway. Under the OLD write-time projection
237
- // the stored `stage`/`stage_state`/`attention`/`list_bucket` would FREEZE at their pre-terminal
238
- // values — the merlin symptom: a cancelled run wedged in Active as a live-looking `Implementing ⚠`,
239
- // its Dismiss gated shut on a NULL stage_state. Because the projection is now a VIEW over `status`,
240
- // the read model stays correct with no write-path for any writer to leave it stale.
291
+ // bypassing the (now retired) projecting `featureRuns` gateway. Because the projection is a VIEW over
292
+ // `status`, the read model stays correct with no write-path for any writer to leave it stale.
241
293
  const db = viewDb();
242
- // A live run mid-flight — its (soon-stale) stored projection says Implementing / ⚠ / active.
243
294
  addRun(db, "o/r#kill", {
244
295
  status: "running",
245
296
  stored: { stage: "Implementing", stage_state: undefined, attention: undefined, list_bucket: "active" },
246
297
  });
247
298
  assertEquals(projection(db, "o/r#kill").stage, "Implementing", "precondition: live run renders Implementing");
248
299
 
249
- // The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
250
- // UPDATE, exactly what the raw datasource emits.) It touches none of the display columns.
251
300
  db.prepare("UPDATE feature_runs SET status = 'abandoned' WHERE feature_key = ?").run("o/r#kill");
252
301
 
253
302
  const row = projection(db, "o/r#kill");
254
303
  const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
255
- // The projection tracks `status` through the VIEW — the merlin drift can no longer happen.
256
304
  assertEquals(row.stage, "Done", "an abandoned run is Done, not wedged at Implementing");
257
305
  assertEquals(row.stage, oracle.stage);
258
306
  assertEquals(row.stage_state, "failed", "abandoned renders a terminal FAILED state (was frozen NULL)");
259
307
  assertEquals(row.stage_state, oracle.state);
260
308
  assertEquals(row.attention, null, "the stale ⚠ badge is gone");
261
309
  assertEquals(row.attention, oracle.attention);
262
- // Dismiss's `showWhenField` is `stage_state`: a non-null terminal state makes it renderable.
263
310
  assert(row.stage_state != null, "Dismiss is renderable (stage_state is non-null) so the run can be ticked off");
264
- // Unacknowledged terminal → still Active (History only after the operator dismisses it).
265
311
  assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
266
312
  assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
267
313
  });
268
314
 
269
315
  test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
270
316
  // `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
271
- // `feature_runs` remains a valid schema table, so reverting this binding would leave every SQL-view
272
- // test green while the display silently resumed reading the stale stored columns; pin it here
273
- // (suppressed advisory feature.page.json — issue #439).
274
317
  const page = PAGE("feature.page.json");
275
318
  const runs = (page.nodes ?? []).find((n: { id: string }) => n.id === "feature-runs");
276
319
  assert(runs, "feature page must keep the Feature runs grid");
@@ -0,0 +1,152 @@
1
+ // The `feature_read_model` derived read model — DECLARED ONCE and compiled to BOTH backends via
2
+ // Urban's ADR-0065 reconciling-read-model primitive (`defineReadModel`, `@nanobpm/urban`).
3
+ //
4
+ // Background (issues #412 → #439 → #422). The Feature Runs surface renders DERIVED state — a run's
5
+ // pipeline `stage`, its `stage_state`/`stage_skipped`, its `attention` badge, its Active/History
6
+ // `list_bucket`. None of these are ground truth: each is a pure function of the row's own
7
+ // `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at` plus, for `attention`, the engine's
8
+ // open-user-task set. #412/#439 retired the WRITE-TIME denormalised projection into a SQLite VIEW
9
+ // (`feature_read_model`) so no writer can freeze a stored column; #458 (migration 075) then re-pointed
10
+ // `attention` off the drift-prone `status` variable onto ENGINE TRUTH — an OPEN `user_tasks` row.
11
+ //
12
+ // Two drift surfaces survived that hand-wired VIEW (ADR-0065, nano-ide#452):
13
+ // * #2 — every derived column was authored TWICE: the SQL `CASE`/`EXISTS` inside the migration VIEW
14
+ // AND the TypeScript oracle (`deriveStage`/`deriveListBucket`, app/stage.ts), kept in lockstep by
15
+ // a hand-written parity test. The lockstep test does not remove the duplication; it only alarms
16
+ // when the two copies diverge.
17
+ // * #3 — the migration, the VIEW DDL and the parity test were all hand-wired per projection.
18
+ //
19
+ // This module closes surface #2 STRUCTURALLY (ADR-0065 rollout step 2 — "declare once → compile to
20
+ // both", no engine-truth change): each derivation is expressed ONCE in Urban's closed expression DSL,
21
+ // and Urban compiles it to BOTH the SQLite VIEW select-list (`sqlSelectFor` — emitted verbatim into
22
+ // migration 076, drift-guarded) AND the runtime TS function (`fnFor` — the sole engine behind the
23
+ // `deriveStage`/`deriveListBucket` adapters in app/stage.ts). There is nothing left to keep in
24
+ // lockstep: the two lowerings fall out of the SAME AST, and `assertReadModelParity` (app/
25
+ // featureReadModel.test.ts) is now a framework-owned regression guard that the two lowerings agree,
26
+ // not an app-authored mirror of two hand-maintained copies.
27
+ //
28
+ // SCOPE (step 2). The `attention` derivation reads the app's own `user_tasks` inbox by name (the same
29
+ // engine-truth source migration 075 used) via the DSL's `exists(...)`; promoting that to the framework
30
+ // canonical `urban_open_user_tasks` projection and inverting `instanceTracking` writer→source are the
31
+ // LATER ADR-0065 rollout steps (3/4), deliberately out of scope here.
32
+
33
+ import { and, caseWhen, col, defineReadModel, type Expr, eq, exists, lit, neq, not, or, pcol, type ReadModel, when } from "@nanobpm/urban";
34
+
35
+ /** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
36
+ * terminal tier of BOTH the pipeline `stage`/`stage_state` derivations and the `list_bucket` history
37
+ * partition. Distinct from `FEATURE_TERMINAL_STATUSES` (app/feature.ts), the redispatch-settled set,
38
+ * which also counts `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/
39
+ * `Converging`), NOT `Done`, so the two lists must stay separate. Re-exported from app/stage.ts for
40
+ * back-compat with its existing importers. */
41
+ export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "blocked", "failed", "skipped", "abandoned"];
42
+
43
+ /** The DSL projection name the `attention` derivation `exists(...)`-reads. Unregistered in the
44
+ * `projectionRegistry`, so it resolves to the same-named physical table — the app's own `user_tasks`
45
+ * inbox (034_user_tasks_inbox.sql), reconciled by `pollUserTasks` (app/service.ts): a row exists IFF a
46
+ * native operator user task is currently OPEN, deleted the moment it closes. (ADR-0065 step 3 will
47
+ * promote this to the framework's canonical `urban_open_user_tasks` projection.) */
48
+ export const USER_TASKS_PROJECTION = "user_tasks";
49
+
50
+ /** `status IN (…)` as a closed-DSL predicate: an OR of equalities over the base row's `status`. */
51
+ const statusIn = (...statuses: readonly string[]): Expr => or(...statuses.map((s) => eq(col("status"), lit(s))));
52
+
53
+ /** The terminal tier — the row's `status` is one of the 6 `Done` statuses. */
54
+ const isDone: Expr = statusIn(...STAGE_DONE_STATUSES);
55
+
56
+ /** The canonical pipeline `stage`. TOTAL over all 11 statuses. Terminal → `Done`; else `converging`
57
+ * → `Converging`; else a raised PR (`pr_key` set, mirroring `(pr_key ?? "") !== ""`) or `opened` →
58
+ * `PR open`; else a live/parked implementation status → `Implementing`; else `Requested`. */
59
+ const stage: Expr = caseWhen(
60
+ [
61
+ when(isDone, lit("Done")),
62
+ when(eq(col("status"), lit("converging")), lit("Converging")),
63
+ when(or(neq(col("pr_key"), lit("")), eq(col("status"), lit("opened"))), lit("PR open")),
64
+ when(statusIn("running", "escalated", "awaiting_operator"), lit("Implementing")),
65
+ ],
66
+ lit("Requested"),
67
+ );
68
+
69
+ /** The active stage's render state in the `kind:"pipeline"` column's vocabulary: `ok` (merged/
70
+ * converged), `blocked` (terminal blocked), `failed` (failed/skipped/abandoned), else NULL (in
71
+ * progress). A pure function of `status`, so it is correct even for a parked/live status (NULL). */
72
+ const stageState: Expr = caseWhen(
73
+ [
74
+ when(statusIn("merged", "converged"), lit("ok")),
75
+ when(eq(col("status"), lit("blocked")), lit("blocked")),
76
+ when(statusIn("failed", "skipped", "abandoned"), lit("failed")),
77
+ ],
78
+ lit(null),
79
+ );
80
+
81
+ /** The space-separated set of pipeline stages NOT in this row's path, purely from `converge`/
82
+ * `auto_merge`: no converge ⇒ both `Converging` and `Merging` are skipped; converge but no auto-merge
83
+ * ⇒ only `Merging`; else none. (`not(col(...))` mirrors the TS `!truthy(...)` under the shared
84
+ * "NULL → false" rule.) */
85
+ const stageSkipped: Expr = caseWhen(
86
+ [
87
+ when(not(col("converge")), lit("Converging Merging")),
88
+ when(not(col("auto_merge")), lit("Merging")),
89
+ ],
90
+ lit(""),
91
+ );
92
+
93
+ /** The correlation predicate for an OPEN operator user task of `elementId` on this feature run: a
94
+ * `user_tasks` row keyed `subject_type='feature'`, `subject_key=<this row's feature_key>` (how
95
+ * `pollUserTasks` keys them — app/service.ts `DEFAULT_SUBJECT_TYPE`/`contextFor`). */
96
+ const openTaskWhere = (elementId: string): Expr =>
97
+ and(eq(pcol("subject_type"), lit("feature")), eq(pcol("subject_key"), col("feature_key")), eq(pcol("element_id"), lit(elementId)));
98
+
99
+ /** The `attention` badge, derived from ENGINE TRUTH — the presence of an OPEN native user task — NOT
100
+ * from the drift-prone `status` variable (issue #422). `blocked` glyph IFF an open `feature-blocked`
101
+ * task exists; `⚠` IFF an open `feature-escalation` task exists; else no badge. Once a task is
102
+ * answered its `user_tasks` row is gone, so the badge clears immediately even while `status` still
103
+ * reads a stale `"escalated"` on the answer-loop back into `implement-task`. */
104
+ const attention: Expr = caseWhen(
105
+ [
106
+ when(exists(USER_TASKS_PROJECTION, openTaskWhere("feature-blocked")), lit("blocked")),
107
+ when(exists(USER_TASKS_PROJECTION, openTaskWhere("feature-escalation")), lit("⚠")),
108
+ ],
109
+ lit(null),
110
+ );
111
+
112
+ /** `<col> IS NOT NULL` in the closed DSL, which has no dedicated null-test operator: a SELF-equality.
113
+ * `eq` collapses a nullish operand to false in BOTH backends (`COALESCE(x = x, 0)` in SQL, the nullish
114
+ * guard in `compareValues` for TS), and any NON-null value equals itself, so this is true IFF the column
115
+ * is non-NULL — faithful to 073/075's `acknowledged_at IS NOT NULL` and free of the SQLite string→number
116
+ * truthiness coercion a bare `col(...)` boolean predicate would otherwise rely on (e.g. `''`/`'abc'`). */
117
+ const isNotNull = (name: string): Expr => eq(col(name), col(name));
118
+
119
+ /** The Active/History partition: `history` IFF the row is in a truly-terminal status AND has been
120
+ * acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). `acknowledged_at IS
121
+ * NOT NULL` is expressed via {@link isNotNull} so it stays byte-equivalent to 073/075's VIEW and does
122
+ * not depend on string→number coercion in either backend. */
123
+ const listBucket: Expr = caseWhen([when(and(isDone, isNotNull("acknowledged_at")), lit("history"))], lit("active"));
124
+
125
+ /** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 076 emits them.
126
+ * Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
127
+ export const FEATURE_READ_MODEL_DERIVED = ["stage", "stage_state", "stage_skipped", "attention", "list_bucket"] as const;
128
+ export type FeatureReadModelDerivedColumn = (typeof FEATURE_READ_MODEL_DERIVED)[number];
129
+
130
+ /**
131
+ * The declare-once `feature_read_model` derived columns. `selectBaseColumns: false` because the base
132
+ * columns are plain identity pass-throughs enumerated in migration 076 (so the static pages↔schema
133
+ * contract guard, which reads a VIEW's columns off an aliased select-list, sees them); this model owns
134
+ * only the five real DERIVATIONS. Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the
135
+ * runtime TS oracle (`fnFor`, behind app/stage.ts) are generated from THIS single declaration.
136
+ */
137
+ export const featureReadModel: ReadModel = defineReadModel({
138
+ name: "feature_read_model",
139
+ baseTable: "feature_runs",
140
+ selectBaseColumns: false,
141
+ derive: {
142
+ stage,
143
+ stage_state: stageState,
144
+ stage_skipped: stageSkipped,
145
+ attention,
146
+ list_bucket: listBucket,
147
+ },
148
+ });
149
+
150
+ /** The base alias the managed VIEW gives `feature_runs` — pinned so the emitted derived-column SQL
151
+ * (`fr."col"`) matches migration 076 exactly (the drift guard compares against this alias). */
152
+ export const FEATURE_READ_MODEL_BASE_ALIAS = "fr";
package/app/stage.ts CHANGED
@@ -1,41 +1,36 @@
1
- // Canonical feature-run pipeline stage model (issue #254 §1) — the ONE source of truth for the
2
- // derived pipeline surface the Feature view renders. Mirrors the single-source-of-truth style of
3
- // `deriveDelivery` (app/delivery.ts) and `classifyEscalation` (app/escalationTaxonomy.ts): a PURE,
4
- // read-only function with no data access. The feature_runs gateway (app/feature.ts) projects its
5
- // output onto the stored `stage`/`stage_state`/`stage_skipped`/`attention` columns at write time,
6
- // exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
7
- // columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
1
+ // Canonical feature-run pipeline stage model (issue #254 §1) — the ergonomic TS façade over the ONE
2
+ // derived-read-model declaration in app/featureReadModel.ts. The pipeline surface the Feature view
3
+ // renders (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) is DERIVED, never ground
4
+ // truth: each column is a pure function of a feature run's own `status`/`pr_key`/`converge`/
5
+ // `auto_merge`/`acknowledged_at` plus, for `attention`, the engine's OPEN-user-task set.
8
6
  //
9
- // The mapping is TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES, computed from ONLY the
10
- // fields stored on the row — never a "previous"/"underlying" stage, because a FeatureRun stores only
11
- // its CURRENT status (any prior stage was overwritten on transition). Do NOT duplicate this mapping
12
- // anywhere (not in SQL, not in the page, not in each poller/worker): every writer flows through the
13
- // gateway, which is the single caller.
7
+ // SINGLE SOURCE OF TRUTH (ADR-0065, nano-ide#452). These derivations are declared ONCE, in Urban's
8
+ // closed expression DSL (`featureReadModel`, app/featureReadModel.ts), and compiled to BOTH the SQLite
9
+ // VIEW (migration 076) and the runtime TS functions used here. `deriveStage`/`deriveListBucket` are
10
+ // now thin ADAPTERS that evaluate that declaration's compiled functions (`fnFor`) they do NOT
11
+ // re-express the CASE/EXISTS logic in TypeScript, so the SQL and TS lowerings cannot drift (the former
12
+ // hand-written parity test becomes the framework-owned `assertReadModelParity` regression guard). The
13
+ // mapping remains TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES. Do NOT duplicate it
14
+ // anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
15
+ // VIEW or these adapters, both sourced from the one declaration.
16
+
17
+ import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
18
+
19
+ // Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
20
+ // home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
21
+ export { STAGE_DONE_STATUSES };
14
22
 
15
23
  /** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
16
24
  * fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
17
25
  export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
18
26
  export type StageKey = (typeof STAGE_KEYS)[number];
19
27
 
20
- /** The active stage's render state, in the urban 0.53.0 `kind:"pipeline"` column's EXACT vocabulary:
28
+ /** The active stage's render state, in the urban `kind:"pipeline"` column's EXACT vocabulary:
21
29
  * `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
22
30
  * → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
23
31
  * renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
24
32
  export type StageState = "ok" | "failed" | "blocked" | null;
25
33
 
26
- /** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
27
- * `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
28
- * `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
29
- * Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
30
- export const STAGE_DONE_STATUSES: readonly string[] = [
31
- "merged",
32
- "converged",
33
- "blocked",
34
- "failed",
35
- "skipped",
36
- "abandoned",
37
- ];
38
-
39
34
  /** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
40
35
  * this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
41
36
  export interface StageInput {
@@ -48,7 +43,8 @@ export interface StageInput {
48
43
  * authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
49
44
  * drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
50
45
  * the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
51
- * ⇒ no open task ⇒ no badge. `feature_read_model` (075) mirrors this with correlated EXISTS lookups. */
46
+ * ⇒ no open task ⇒ no badge. These booleans are lowered into synthetic `user_tasks` projection rows
47
+ * so this façade evaluates the SAME `exists(...)` derivation the VIEW compiles. */
52
48
  hasOpenBlockedTask?: boolean | null;
53
49
  hasOpenEscalationTask?: boolean | null;
54
50
  }
@@ -62,60 +58,58 @@ export interface DerivedStage {
62
58
  attention: string | null;
63
59
  }
64
60
 
65
- const truthy = (v: number | boolean | null | undefined): boolean => v === true || (typeof v === "number" && v !== 0);
66
-
67
- /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
68
- * for one feature run. Pure and read-only TOTAL over all 11 statuses (never returns undefined). */
69
- export function deriveStage(run: StageInput): DerivedStage {
70
- const { status } = run;
61
+ /** The synthetic correlation key threaded through the `attention` derivation: the base row's
62
+ * `feature_key` and each synthesised open-task row's `subject_key` share this value so the compiled
63
+ * `exists(... WHERE subject_key = feature_key ...)` predicate matches. Its concrete value is
64
+ * immaterial (it never leaves this function); it only has to be consistent between the two. */
65
+ const SELF_KEY = "self";
71
66
 
72
- // TERMINAL tier the 6 truly-terminal statuses collapse to Done. Unconditional: terminal `blocked`
73
- // is the issue §1 'Done ✕' row (state `blocked`), NOT Implementing.
74
- let stage: StageKey;
75
- let state: StageState;
76
- if (STAGE_DONE_STATUSES.includes(status)) {
77
- stage = "Done";
78
- state =
79
- status === "merged" || status === "converged"
80
- ? "ok"
81
- : status === "blocked"
82
- ? "blocked"
83
- : "failed"; // failed / skipped / abandoned
84
- } else {
85
- // LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
86
- // are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
87
- // through the same rule as a live run; their attention comes from the badge (below), not the stage.
88
- if (status === "converging") stage = "Converging";
89
- else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
90
- else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
91
- else stage = "Requested";
92
- state = null;
93
- }
94
-
95
- // `skipped`: stages not in this row's path, purely from converge/auto_merge.
96
- const converge = truthy(run.converge);
97
- const autoMerge = truthy(run.auto_merge);
98
- const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
67
+ /** The `user_tasks` projection rows a `StageInput`'s open-task booleans stand for one row per OPEN
68
+ * operator user task, keyed exactly as `pollUserTasks` records them, so the compiled `attention`
69
+ * derivation sees the same engine-truth shape at runtime and here. */
70
+ function openTaskRows(run: StageInput): Array<Record<string, unknown>> {
71
+ const rows: Array<Record<string, unknown>> = [];
72
+ if (run.hasOpenBlockedTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-blocked" });
73
+ if (run.hasOpenEscalationTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-escalation" });
74
+ return rows;
75
+ }
99
76
 
100
- // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
101
- // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
102
- // Derived from ENGINE TRUTH the presence of an OPEN native user task (issue #422), NOT from the
103
- // `status` variable. `status` is worker-written imperatively and goes stale on the answer-loop back
104
- // into `implement-task` (the process does not reset it), so a run whose escalation was already
105
- // ANSWERED still reads `status="escalated"` until its next job completes; sourcing the badge from
106
- // that value made the read model lie (a resolved run flagged ⚠ on Overview). The authoritative
107
- // "who is waiting on a human" set is the `user_tasks` inbox (`pollUserTasks`), which holds a row
108
- // IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
109
- // IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
110
- // Once answered, the row is gone and the badge clears regardless of the stale `status`.
111
- const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
77
+ /** Evaluate one derived column from the ONE `featureReadModel` declaration for a base row (plus any
78
+ * projection rows the derivation correlates against). This is the single seam where the framework's
79
+ * compiled-function boundary is crossed: `fnFor(column)` returns `unknown` (a derivation can compile to
80
+ * any DSL value), while the caller statically knows the declared `lit(...)` shape of each column. */
81
+ function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<string, unknown>, projections?: Record<string, Array<Record<string, unknown>>>): T {
82
+ // biome-ignore lint/plugin: runtime/framework contract boundary — `fnFor` returns `unknown`; each derived column yields one of its declared DSL `lit(...)` values, whose type the caller knows.
83
+ return featureReadModel.fnFor(column)(baseRow, projections) as T;
84
+ }
112
85
 
113
- return { stage, state, skipped: skippedKeys.join(" "), attention };
86
+ /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
87
+ * for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). The
88
+ * four columns are evaluated from the ONE `featureReadModel` declaration (app/featureReadModel.ts), so
89
+ * this façade and the SQLite VIEW compute byte-identical values by construction. */
90
+ export function deriveStage(run: StageInput): DerivedStage {
91
+ const baseRow = {
92
+ feature_key: SELF_KEY,
93
+ status: run.status,
94
+ pr_key: run.pr_key ?? null,
95
+ converge: run.converge ?? null,
96
+ auto_merge: run.auto_merge ?? null,
97
+ };
98
+ const projections = { [USER_TASKS_PROJECTION]: openTaskRows(run) };
99
+ return {
100
+ stage: evalDerived<StageKey>("stage", baseRow, projections),
101
+ state: evalDerived<StageState>("stage_state", baseRow, projections),
102
+ skipped: evalDerived<string>("stage_skipped", baseRow, projections),
103
+ attention: evalDerived<string | null>("attention", baseRow, projections),
104
+ };
114
105
  }
115
106
 
116
- /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
117
- * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
118
- * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
107
+ /** The Active/History partition label (§5): `history` iff the row is in a truly-terminal status AND
108
+ * acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). DERIVED on read from
109
+ * the ONE `featureReadModel` `list_bucket` declaration the same AST the VIEW compiles (migration 076),
110
+ * NOT a write-time projection: the page's tabs filter the VIEW's derived `list_bucket`, and the stored
111
+ * `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
112
+ * adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
119
113
  export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
120
- return STAGE_DONE_STATUSES.includes(status) && acknowledgedAt != null ? "history" : "active";
114
+ return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
121
115
  }
@@ -0,0 +1,53 @@
1
+ -- Feature-run read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452).
2
+ --
3
+ -- This migration SUPERSEDES the hand-wired `feature_read_model` VIEW of 073_feature_read_model.sql and
4
+ -- 075_feature_read_model_attention_from_user_tasks.sql. Those authored each derived column TWICE — the
5
+ -- SQL CASE/EXISTS here AND the TypeScript oracle (deriveStage/deriveListBucket, app/stage.ts) — kept in
6
+ -- lockstep by a hand-written parity test (drift surface #2, ADR-0065). This VIEW is no longer
7
+ -- hand-written: every DERIVED column body below is emitted VERBATIM from the ONE declaration in
8
+ -- app/featureReadModel.ts (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the
9
+ -- runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge.
10
+ -- A drift guard (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
11
+ -- `assertReadModelParity` proves the SQL and TS lowerings agree.
12
+ --
13
+ -- SEMANTICS ARE UNCHANGED from 075: `attention` still derives from ENGINE TRUTH — an OPEN native user
14
+ -- task in the `user_tasks` inbox (a `feature-blocked` row => blocked glyph, a `feature-escalation` row
15
+ -- => `⚠`), NOT the drift-prone `status` variable (issue #422); `stage`/`stage_state`/`stage_skipped`/
16
+ -- `list_bucket` are the same pure functions of the row. Only the AUTHORING moves to the framework
17
+ -- primitive; the projected values are identical.
18
+ --
19
+ -- Forward-only VIEW redefinition (DROP then CREATE). 073/075 are MERGED, IMMUTABLE migrations — never
20
+ -- edited; this is a NEW migration superseding their VIEW body. `feature_runs` (028+) and `user_tasks`
21
+ -- (034) already exist earlier in the chain, and 075 already created the supporting
22
+ -- `idx_user_tasks_subject_element` index the correlated attention EXISTS lookups seek, so it is not
23
+ -- re-created here. Base columns are plain identity pass-throughs (aliased so the static pages<->schema
24
+ -- contract guard sees the VIEW columns); `feature_runs fr` stays the sole top-level FROM (the user_tasks
25
+ -- lookups are nested EXISTS subqueries at paren depth >= 1). The runner wraps each file in its own
26
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
27
+
28
+ DROP VIEW IF EXISTS feature_read_model;
29
+
30
+ CREATE VIEW feature_read_model AS
31
+ SELECT
32
+ fr.feature_key AS feature_key,
33
+ fr.repo AS repo,
34
+ fr.issue_number AS issue_number,
35
+ fr.issue_url AS issue_url,
36
+ fr.title AS title,
37
+ fr.base_branch AS base_branch,
38
+ fr.status AS status,
39
+ fr.process_key AS process_key,
40
+ fr.pr_key AS pr_key,
41
+ fr.converge AS converge,
42
+ fr.auto_merge AS auto_merge,
43
+ fr.outcome AS outcome,
44
+ fr.delivery_label AS delivery_label,
45
+ fr.acknowledged_at AS acknowledged_at,
46
+ fr.created_at AS created_at,
47
+ fr.updated_at AS updated_at,
48
+ CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."status" = 'running'), 0) OR COALESCE(("fr"."status" = 'escalated'), 0) OR COALESCE(("fr"."status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
49
+ CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
50
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
51
+ CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
52
+ CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
53
+ FROM feature_runs fr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.124.0",
3
+ "version": "0.125.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.1.0",
62
- "@nanobpm/urban": "^0.77.1",
62
+ "@nanobpm/urban": "^0.80.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
@@ -103,3 +103,25 @@ test("collision detector flags a non-NNN shape and grandfathers historical dupes
103
103
  "grandfathered prefix 075 is not a new violation",
104
104
  );
105
105
  });
106
+
107
+ // The grandfather is keyed by the EXACT historical FILENAME set, not just the prefix (Copilot review,
108
+ // #472): pardoning a prefix wholesale would let any future `075_*.sql` slip in unnoticed, defeating the
109
+ // gate for that slot forever. A THIRD file taking a grandfathered prefix is a genuinely NEW collision
110
+ // and must still fail — only the specific already-merged files are exempt.
111
+ test("a NEW file joining a grandfathered prefix is still caught (exact-filename exemption)", () => {
112
+ // The two real, pardoned 075 files alone: exempt.
113
+ assertEquals(
114
+ collisionErrorsFromFiles(["075_delivery_graph_proposals.sql", "075_feature_read_model_attention_from_user_tasks.sql"]),
115
+ [],
116
+ "the exact historical 075 pair stays exempt",
117
+ );
118
+ // A third file at the same grandfathered prefix is NEW → must fail, naming the offender only.
119
+ const errors = collisionErrorsFromFiles([
120
+ "075_delivery_graph_proposals.sql",
121
+ "075_feature_read_model_attention_from_user_tasks.sql",
122
+ "075_someone_elses_next_free_prefix.sql",
123
+ ]);
124
+ assertEquals(errors.length, 1, "a new file on a grandfathered prefix is one violation");
125
+ assert(/prefix 075/.test(errors[0]));
126
+ assert(/075_someone_elses_next_free_prefix.sql is NEW/.test(errors[0]), "the NEW file is named as the offender");
127
+ });
@@ -52,6 +52,7 @@ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
52
52
  // boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
53
53
  // apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
54
54
  //
55
+ //
55
56
  // 075 is the same merge-skew story across two PRs that never saw each other (issue #470): #458 landed
56
57
  // `075_feature_read_model_attention_from_user_tasks` and #460/#463 landed `075_delivery_graph_proposals`,
57
58
  // each the branch-local "next" prefix, colliding silently only once both were on main (releases then
@@ -59,9 +60,23 @@ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
59
60
  // a merged migration would re-run it and abort boot, and the immutability check would itself flag the
60
61
  // rename. They create DISJOINT objects (`075_delivery_graph_proposals` adds the `delivery_graph_proposals`
61
62
  // table + its indexes; `075_feature_read_model_…` redefines the `feature_read_model` VIEW and adds one
62
- // `user_tasks` index), so their relative apply order is irrelevant. Grandfather 075; any NEW duplicate
63
- // prefix still fails the build (the next migration is 076).
64
- const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052", "075"]);
63
+ // `user_tasks` index), so their relative apply order is irrelevant. Grandfather 075 (the next migration
64
+ // is 076, which supersedes 075's `feature_read_model` VIEW body a NEW, unique prefix).
65
+ //
66
+ // The exemption is keyed by the EXACT set of colliding FILENAMES per prefix, not merely the prefix, so
67
+ // it only pardons the specific historical files that already merged — a THIRD file taking a
68
+ // grandfathered prefix (a fresh 075_*.sql, say) is NOT in the set and still fails the gate. This keeps
69
+ // "any NEW duplicate prefix still fails" literally true even for prefixes that already carry a pardoned
70
+ // collision (Copilot review, #472).
71
+ const GRANDFATHERED_DUPES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
72
+ ["004", new Set(["004_merge.sql", "004_planning.sql"])],
73
+ ["005", new Set(["005_job_activation.sql", "005_plan_deps.sql"])],
74
+ ["006", new Set(["006_plan_review.sql", "006_task_escalation.sql"])],
75
+ ["007", new Set(["007_plan_review_job_key.sql", "007_wave_gate.sql"])],
76
+ ["049", new Set(["049_drop_feature_escalation_surface.sql", "049_plan_task_needs.sql", "049_world_checkpoint.sql"])],
77
+ ["052", new Set(["052_plan_conformance.sql", "052_worker_durable_resume.sql"])],
78
+ ["075", new Set(["075_delivery_graph_proposals.sql", "075_feature_read_model_attention_from_user_tasks.sql"])],
79
+ ]);
65
80
 
66
81
  const PREFIX = /^(\d{3})_[^/]*\.sql$/;
67
82
 
@@ -185,13 +200,22 @@ export function collisionErrorsFromFiles(files: readonly string[]): string[] {
185
200
  }
186
201
 
187
202
  for (const [prefix, group] of byPrefix) {
188
- if (group.length > 1 && !GRANDFATHERED_DUPES.has(prefix)) {
189
- errors.push(
190
- ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} ` +
203
+ if (group.length <= 1) continue;
204
+ // Keyed by the EXACT historical filename set: a grandfathered prefix pardons ONLY those specific
205
+ // already-merged files. Any file in the group NOT in that set is a NEW collision and still fails.
206
+ const exempt = GRANDFATHERED_DUPES.get(prefix);
207
+ const unexpected = exempt ? group.filter((f) => !exempt.has(f)) : group;
208
+ if (unexpected.length === 0) continue;
209
+ errors.push(
210
+ exempt
211
+ ? ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — only the ` +
212
+ `grandfathered historical set (${[...exempt].sort().join(", ")}) may share this prefix; ` +
213
+ `${[...unexpected].sort().join(", ")} is NEW. Renumber it to the next free prefix ` +
214
+ `(check origin/main, not your branch point).`
215
+ : ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — ` +
191
216
  `two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
192
217
  `free prefix (check origin/main, not your branch point).`,
193
- );
194
- }
218
+ );
195
219
  }
196
220
 
197
221
  return errors;