@nanobpm/nano-workforce 0.107.0 → 0.107.2

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.
@@ -5,6 +5,12 @@ on:
5
5
  branches: [main]
6
6
  push:
7
7
  branches: [main]
8
+ # Merge-skew guard (issue #366): also run the required gates against the merge queue's
9
+ # PROSPECTIVE merged commit (GitHub Actions `merge_group` event). Several gates assert a whole-repo invariant (migration prefixes,
10
+ # BPMN DI freshness, committed generated artifacts) that two PRs can each satisfy in isolation yet
11
+ # violate once BOTH land on `main`. Validating the speculative merge commit the queue builds — not
12
+ # the stale PR head — blocks such a merge categorically instead of letting it poison the next PR.
13
+ merge_group:
8
14
 
9
15
  permissions:
10
16
  contents: read
@@ -77,3 +83,30 @@ jobs:
77
83
  - name: E2E (urban-testkit)
78
84
  run: npm run e2e
79
85
 
86
+ # FINAL regression guard for epic nano-ide#314 (S6, #321): the compounding oracle that keeps the
87
+ # code-first (`defineFlow`) and model-first (`.bpmn`) representations of the nano-workforce corpus
88
+ # in lockstep. For every model it DERIVES the BPMN from its defineFlow port, structurally DIFFS it
89
+ # against the checked-in golden via the S0 harness (@nanobpm/workflow/test-support), and DEPLOYS
90
+ # the derived model to the in-process @nanobpm/engine-wasm engine — failing on any structural drift
91
+ # OR deploy rejection. Parked models (awaiting an upstream construct) must each carry a documented
92
+ # blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
93
+ # never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
94
+ # sockets), so it runs on every PR/push as its own job.
95
+ derivation-parity:
96
+ name: derivation parity (nwf corpus regression guard)
97
+ runs-on: ubuntu-latest
98
+ steps:
99
+ - name: Checkout
100
+ uses: actions/checkout@v4
101
+
102
+ - name: Setup Node.js
103
+ uses: actions/setup-node@v4
104
+ with:
105
+ node-version: "24"
106
+
107
+ - name: Install dependencies
108
+ run: npm ci
109
+
110
+ - name: Derive + diff + deploy the full nwf corpus
111
+ run: npm run check:derivation-parity
112
+
@@ -0,0 +1,72 @@
1
+ name: Whole-repo invariants (merge-skew guard)
2
+
3
+ # Closes the merge-skew failure class (issue #366).
4
+ #
5
+ # Several gates assert a GLOBAL invariant / checked-in DERIVED artifact — `derived == f(sources)` —
6
+ # but the main CI only ever verifies them against a PR's OWN head. Two PRs can each pass in isolation
7
+ # and still break the invariant once BOTH squash-merge, because `main`'s `derived` is then
8
+ # `f(sources_A ∪ sources_B)`, which neither branch's green CI ever saw (the 052 migration collision,
9
+ # #359; the stale `retro.bpmn` DI, #365). Nothing re-checked `main`, so the breakage landed silently
10
+ # and poisoned the next unrelated PR to touch the same job.
11
+ #
12
+ # This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
13
+ # - `merge_group` — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
14
+ # - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
15
+ # slipped through, instead of first surfacing on an unrelated open PR.
16
+ # - `schedule` — a daily catch-all for any skew introduced by a merge that bypassed the queue.
17
+ on:
18
+ merge_group:
19
+ push:
20
+ branches: [main]
21
+ schedule:
22
+ # 06:00 UTC daily — cheap catch-all backstop.
23
+ - cron: "0 6 * * *"
24
+ workflow_dispatch:
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ invariants:
31
+ name: whole-repo invariants
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - name: Checkout
35
+ uses: actions/checkout@v4
36
+ with:
37
+ # Full history: the migration immutability gate diffs against the merge-base with
38
+ # origin/main, and the layout gate needs the whole tree — neither works on a shallow clone.
39
+ fetch-depth: 0
40
+
41
+ - name: Setup Node.js
42
+ uses: actions/setup-node@v4
43
+ with:
44
+ node-version: "24"
45
+
46
+ - name: Install dependencies
47
+ run: npm ci
48
+
49
+ # Prefix-collision (+ immutability): two branches that each took "the next" free migration
50
+ # prefix collide once merged. Re-run on the merged/`main` tree so the collision can't hide.
51
+ - name: Check migration prefixes (no collisions)
52
+ run: npm run check:migrations
53
+
54
+ # BPMN DI freshness: a merged semantic model can carry flows whose DI was regenerated on neither
55
+ # branch. Regenerate the DI over the merged model and fail if any committed diagram is stale.
56
+ - name: Check BPMN diagram freshness (layout)
57
+ run: npm run layout:check
58
+
59
+ # Generated-artifact freshness (`urban gen --check`): landscape.gen.html, JSON Schemas / OpenAPI,
60
+ # the processos grammar (ir.gbnf) and friends are `derived == f(sources)` — re-derive over the
61
+ # merged sources and fail if a committed artifact drifted.
62
+ - name: Check generated artifacts (urban gen --check)
63
+ run: npm run gen:check
64
+
65
+ # Navigation index is another checked-in derived artifact; re-assert it on the merged tree too.
66
+ - name: Check navigation index freshness
67
+ run: npm run sync:nav:check
68
+
69
+ # Backstop: catch ANY other committed generated file that the merged sources render stale, even
70
+ # one without its own `--check` script above. A clean tree is the whole-repo invariant.
71
+ - name: No stale committed artifacts on the merged tree
72
+ run: git diff --exit-code
package/AGENTS.md CHANGED
@@ -272,7 +272,13 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
272
272
  Check `origin/main`, not your branch point — a fan-out epic branch forks at one
273
273
  prefix while `main` keeps advancing, so the branch-local "next" number collides
274
274
  on merge. Two files must never share a prefix; `npm run check:migrations`
275
- (a CI gate) enforces this and fails the build on any new duplicate.
275
+ (a CI gate) enforces this and fails the build on any new duplicate. Because a
276
+ prefix collision only exists in the *union* of two branches, this gate — like
277
+ `layout:check` and the generated-artifact `--check`s — is also re-run on the
278
+ merge queue's **prospective merged commit** and on **push to `main`** by
279
+ `.github/workflows/invariants.yml` (issue #366), so a merge-skew collision is
280
+ blocked at merge time or fails a `main`-scoped build within minutes rather than
281
+ first surfacing on an unrelated open PR.
276
282
  - **A merged migration is IMMUTABLE — never rename, delete, or edit it.** The
277
283
  runtime keys the `_urban_migrations` ledger by *filename*, so a renamed file is
278
284
  a *new* migration to the runner: it re-runs its DDL against an already-migrated
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.107.2](https://github.com/nanobpm/nano-workforce/compare/v0.107.1...v0.107.2) (2026-08-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **ci:** close the merge-skew failure class — re-check whole-repo invariants on the post-merge state ([#367](https://github.com/nanobpm/nano-workforce/issues/367)) ([62f5214](https://github.com/nanobpm/nano-workforce/commit/62f5214961cdb82a708d7cc08acecbf5eecf25ed)), closes [#359](https://github.com/nanobpm/nano-workforce/issues/359) [#365](https://github.com/nanobpm/nano-workforce/issues/365) [#366](https://github.com/nanobpm/nano-workforce/issues/366)
7
+
8
+ ## [0.107.1](https://github.com/nanobpm/nano-workforce/compare/v0.107.0...v0.107.1) (2026-08-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** converge PRs merged/closed out-of-band at every merge-stage wait ([#370](https://github.com/nanobpm/nano-workforce/issues/370)) ([c673c82](https://github.com/nanobpm/nano-workforce/commit/c673c823b6ed5dde5710c4cb677f7c6f01ae59de)), closes [nanobpm/nano-workforce#368](https://github.com/nanobpm/nano-workforce/issues/368)
14
+
1
15
  # [0.107.0](https://github.com/nanobpm/nano-workforce/compare/v0.106.3...v0.107.0) (2026-08-20)
2
16
 
3
17
 
@@ -0,0 +1,178 @@
1
+ // Class regression guard for the out-of-band terminal escape shared by EVERY merge-stage durable
2
+ // wait (issue #368).
3
+ //
4
+ // #368: a PR merged (or closed) OUT-OF-BAND — a maintainer clicks Merge, or a mergify queue lands
5
+ // it — while its merge-loop instance is parked at a durable GitHub wait can silently wedge forever.
6
+ // The `waiting_deps` branch of `pollMerges` only advanced a PR when its *declared dependencies*
7
+ // merged and had NO check on the PR itself already being merged: if those deps never cleared, the
8
+ // instance sat at `wait-deps` forever (ACTIVE, no incident, no timer boundary). `waiting_merge`
9
+ // already guarded this; the fix lifts that guard into ONE shared pre-check
10
+ // (`advanceIfTerminalOutOfBand`) run at the top of all four merge-stage waits — `waiting_deps`,
11
+ // `waiting_merge`, `waiting_lane`, `queued` — so no stage can strand on an out-of-band terminal
12
+ // transition. Each wait subscribes to a DIFFERENT catch, so the pre-check must publish the escape
13
+ // message THAT wait correlates to; this test asserts the class over every (status × merged/closed).
14
+ import { test } from "node:test";
15
+ import { assertEquals } from "#test-assert";
16
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
+ import { pollMerges } from "./service.ts";
18
+
19
+ // In-memory record gateway (get/find/insert/update/delete), matching app/promotionPoll.test.ts.
20
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
21
+ const stores: Record<string, any[]> = {};
22
+ function tbl(name: string, pk = "id") {
23
+ const rows = (stores[name] ??= [] as any[]);
24
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
+ return {
26
+ async all() {
27
+ return rows.slice();
28
+ },
29
+ async get(id: any) {
30
+ return rows.find((r) => r[pk] === id);
31
+ },
32
+ async find(where: any = {}) {
33
+ return rows.filter((r) => match(r, where));
34
+ },
35
+ async insert(row: any) {
36
+ rows.push({ ...row });
37
+ return row[pk];
38
+ },
39
+ async update(id: any, patch: any) {
40
+ const r = rows.find((row) => row[pk] === id);
41
+ if (r) Object.assign(r, patch);
42
+ },
43
+ async delete(id: any) {
44
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
45
+ },
46
+ };
47
+ }
48
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
49
+ return { data, stores };
50
+ }
51
+
52
+ // Records every message the poller publishes so a test can assert the exact escape.
53
+ function recordingEngine(): { engine: EngineClient; messages: any[] } {
54
+ const messages: any[] = [];
55
+ const engine = {
56
+ async publishMessage(msg: any) {
57
+ messages.push(msg);
58
+ },
59
+ } as any as EngineClient;
60
+ return { engine, messages };
61
+ }
62
+
63
+ // A token-transport GitHub stub. Serves `GET /repos/{repo}/pulls/{n}` from a per-number liveness
64
+ // map so `fetchPrState` (→ `classifyPrLiveness`) reads "merged" / "closed" / "open".
65
+ type Live = "merged" | "closed" | "open";
66
+ function githubFetch(states: Map<number, Live>) {
67
+ return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
68
+ const u = new URL(String(url));
69
+ const json = (obj: unknown, status = 200) =>
70
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
71
+ const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
72
+ if (m) {
73
+ const n = Number(m[1]);
74
+ const live = states.get(n) ?? "open";
75
+ return json({
76
+ merged: live === "merged",
77
+ merged_at: live === "merged" ? "2026-08-20T02:35:42Z" : null,
78
+ state: live === "open" ? "open" : "closed",
79
+ mergeable_state: live === "open" ? "clean" : "unknown",
80
+ draft: false,
81
+ head: { sha: "deadbeef" },
82
+ });
83
+ }
84
+ return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
85
+ };
86
+ }
87
+
88
+ async function withGithub<T>(states: Map<number, Live>, fn: () => Promise<T>): Promise<T> {
89
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
90
+ const prevFetch = globalThis.fetch;
91
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
92
+ globalThis.fetch = githubFetch(states) as typeof fetch;
93
+ try {
94
+ return await fn();
95
+ } finally {
96
+ globalThis.fetch = prevFetch;
97
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
98
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
99
+ }
100
+ }
101
+
102
+ function prRow(prKey: string, number: number, status: string) {
103
+ const ts = "2026-08-20T00:00:00Z";
104
+ return {
105
+ pr_key: prKey,
106
+ repo: "o/r",
107
+ number,
108
+ url: `https://github.com/o/r/pull/${number}`,
109
+ title: "t",
110
+ status,
111
+ current_round: 0,
112
+ process_key: null,
113
+ waiting_since: null,
114
+ last_review_id: null,
115
+ outcome: null,
116
+ created_at: ts,
117
+ updated_at: ts,
118
+ converged_at: null,
119
+ merged_at: null,
120
+ };
121
+ }
122
+
123
+ // Every merge-stage durable wait, with the escape message its parked catch subscribes to. The
124
+ // escape differs per wait because each subscribes to a different message — the whole point of the
125
+ // shared pre-check is to publish the RIGHT one so it correlates instead of being dropped.
126
+ const CASES: { status: string; merged: string; closed: string }[] = [
127
+ // wait-deps subscribes only `deps-cleared` (→ arm-merge → wait-mergeable, where block 2 converges).
128
+ { status: "waiting_deps", merged: "deps-cleared", closed: "deps-cleared" },
129
+ // wait-mergeable subscribes `merge-ready` (→ gw-mergeable → attempt-merge terminal short-circuits).
130
+ { status: "waiting_merge", merged: "merge-ready", closed: "merge-ready" },
131
+ // waiting_lane is an app hold that leaves the process on wait-mergeable → also `merge-ready`.
132
+ { status: "waiting_lane", merged: "merge-ready", closed: "merge-ready" },
133
+ // wait-landed subscribes `merge-landed` (→ mark-merged) and `merge-evicted` (→ arm-merge). A
134
+ // merged queue PR lands; a closed one can never land, so re-arm and let block 2 abandon it.
135
+ { status: "queued", merged: "merge-landed", closed: "merge-evicted" },
136
+ ];
137
+
138
+ for (const c of CASES) {
139
+ for (const live of ["merged", "closed"] as const) {
140
+ test(`out-of-band ${live} PR at ${c.status} converges via ${live === "merged" ? c.merged : c.closed}`, async () => {
141
+ const { data, stores } = memData();
142
+ const { engine, messages } = recordingEngine();
143
+ stores["pull_requests"] = [prRow("o/r#100", 100, c.status)];
144
+ // A declared dependency that has NOT merged — the exact condition that wedged `waiting_deps`:
145
+ // the deps loop would never clear, so ONLY the PR's own terminal state can converge it.
146
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
147
+
148
+ await withGithub(new Map<number, Live>([[100, live], [200, "open"]]), () =>
149
+ pollMerges(data, engine, "tok"),
150
+ );
151
+
152
+ assertEquals(messages.length, 1, `expected exactly one escape message for ${c.status}/${live}`);
153
+ const expected = live === "merged" ? c.merged : c.closed;
154
+ assertEquals(messages[0].name, expected);
155
+ assertEquals(messages[0].correlationKey, "o/r#100");
156
+ if (expected === "merge-ready") assertEquals(messages[0].variables.mergeState, "ready");
157
+ // Flipped onto the transient `merging` status so a slow pass can't double-signal.
158
+ assertEquals(stores["pull_requests"][0].status, "merging");
159
+ });
160
+ }
161
+ }
162
+
163
+ // Negative guard: a still-OPEN `waiting_deps` PR whose declared dep is unmerged must NOT be forced
164
+ // terminal by the pre-check — it stays parked (no escape published), proving the pre-check fires
165
+ // only on a real out-of-band terminal transition and never drops a live PR.
166
+ test("still-open waiting_deps PR with an unmerged dep publishes nothing and stays parked", async () => {
167
+ const { data, stores } = memData();
168
+ const { engine, messages } = recordingEngine();
169
+ stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
170
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
171
+
172
+ await withGithub(new Map<number, Live>([[100, "open"], [200, "open"]]), () =>
173
+ pollMerges(data, engine, "tok"),
174
+ );
175
+
176
+ assertEquals(messages.length, 0);
177
+ assertEquals(stores["pull_requests"][0].status, "waiting_deps");
178
+ });
package/app/service.ts CHANGED
@@ -1014,6 +1014,87 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
1014
1014
  }
1015
1015
  }
1016
1016
 
1017
+ /** The escape message a merge-stage durable wait must publish when its PR has gone terminal
1018
+ * (merged/closed) OUT-OF-BAND — i.e. someone landed or closed it on GitHub while the process was
1019
+ * parked, so the wait's own declared trigger (deps clearing, a mergeable verdict, a lane release, a
1020
+ * queue landing) may never fire. Each wait subscribes to a DIFFERENT message, so the escape MUST be
1021
+ * the one its parked catch actually correlates to (publishing any other name is dropped by the
1022
+ * engine and re-wedges the PR in the transient `merging` status). All roads lead to the same proven
1023
+ * terminal path — `attempt-merge`'s idempotent already-merged / closed short-circuits (#368):
1024
+ * • waiting_deps → parked at `wait-deps`, subscribes ONLY `deps-cleared`. Deps are moot once the
1025
+ * PR itself landed, so clear them regardless of liveness; `deps-cleared` → `arm-merge` →
1026
+ * `wait-mergeable`, where block 2 (below) reads the terminal state and drives merged→mark-merged
1027
+ * / closed→abandon. This is the gap the incident hit — `wait-deps` had NO self-merged escape.
1028
+ * • waiting_merge / waiting_lane → both parked at `wait-mergeable` (waiting_lane is an app-internal
1029
+ * hold that leaves the process on `wait-mergeable`), which subscribes `merge-ready`. Route a
1030
+ * `ready` verdict through `gw-mergeable → attempt-merge`, whose short-circuits complete/abandon.
1031
+ * • queued → parked at `wait-landed`, subscribes `merge-landed` (→ mark-merged) and `merge-evicted`
1032
+ * (→ arm-merge). A merged queue PR lands (`merge-landed`); a closed-unmerged one can NEVER land,
1033
+ * so publishing `merge-landed` would falsely mark it merged — re-arm via `merge-evicted` instead
1034
+ * and let block 2 abandon it on the next pass. */
1035
+ function outOfBandEscapeMessage(
1036
+ status: string,
1037
+ liveness: "merged" | "closed",
1038
+ prKey: string,
1039
+ ): Parameters<EngineClient["publishMessage"]>[0] {
1040
+ switch (status) {
1041
+ case "waiting_deps":
1042
+ return { name: "deps-cleared", correlationKey: prKey, variables: {} };
1043
+ case "waiting_merge":
1044
+ case "waiting_lane":
1045
+ return {
1046
+ name: "merge-ready",
1047
+ correlationKey: prKey,
1048
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1049
+ };
1050
+ case "queued":
1051
+ return liveness === "merged"
1052
+ ? { name: "merge-landed", correlationKey: prKey, variables: {} }
1053
+ : { name: "merge-evicted", correlationKey: prKey, variables: {} };
1054
+ default:
1055
+ // Unreachable: only the four merge-stage durable waits call this. Fail loud rather than
1056
+ // mis-route a message the parked catch can't correlate (which would silently re-wedge the PR).
1057
+ throw new Error(`outOfBandEscapeMessage: unexpected merge-stage status ${JSON.stringify(status)}`);
1058
+ }
1059
+ }
1060
+
1061
+ /** ONE shared out-of-band terminal pre-check for EVERY merge-stage durable wait (#368). A PR parked
1062
+ * at any durable GitHub wait can be merged or closed out-of-band; without a per-branch check on the
1063
+ * PR's OWN state, a wait that keys only off its declared trigger (e.g. `waiting_deps`' declared
1064
+ * deps) strands its instance forever — ACTIVE, no incident, no timer boundary. `waiting_merge`
1065
+ * already guarded this; centralising the check here closes the whole class so no merge stage can
1066
+ * silently wedge on an out-of-band terminal transition.
1067
+ *
1068
+ * Reads the PR's live state (reusing an already-fetched `st` when the caller has one, e.g. block 2)
1069
+ * and, if terminal, publishes the escape message its parked catch subscribes to via
1070
+ * {@link flipToMergingThenPublish} — flipping to the transient `merging` so a slow pass can't
1071
+ * double-signal, reverting on a failed publish. Returns `true` when it advanced the PR (the caller
1072
+ * must `continue`), `false` when the PR is still live / unreadable and the caller should run its
1073
+ * normal per-status logic. Conservative: an unreadable (`null`) or ambiguous (`unknown`/open) state
1074
+ * never resolves terminal, so a false negative only costs a retry while dropping a live PR is
1075
+ * impossible. */
1076
+ async function advanceIfTerminalOutOfBand(
1077
+ data: DataLayer,
1078
+ engine: EngineClient,
1079
+ pr: { repo: string; number: number | string; pr_key: string; status: string },
1080
+ token: string,
1081
+ st?: PrState | null,
1082
+ ): Promise<boolean> {
1083
+ const state = st !== undefined ? st : await fetchPrState(pr.repo, pr.number, token);
1084
+ const liveness = classifyPrLiveness(state);
1085
+ if (liveness !== "merged" && liveness !== "closed") return false;
1086
+ const fromStatus = pr.status; // capture before flip: flipToMergingThenPublish mutates it to `merging`
1087
+ await flipToMergingThenPublish(
1088
+ data,
1089
+ engine,
1090
+ pr.pr_key,
1091
+ fromStatus,
1092
+ outOfBandEscapeMessage(fromStatus, liveness, pr.pr_key),
1093
+ );
1094
+ console.log(`[poller] out-of-band ${liveness} (${fromStatus}) -> ${pr.pr_key}`);
1095
+ return true;
1096
+ }
1097
+
1017
1098
  /** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
1018
1099
  * advanced by correlating a message — mirroring the review-ready pattern so the process owns
1019
1100
  * the wait and this glue only signals when a GitHub condition is met:
@@ -1023,12 +1104,20 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
1023
1104
  * • queued → the queued PR landed → `merge-landed`; or it conflicts (DIRTY) → `merge-evicted`
1024
1105
  * On publish we flip status to the transient `merging` (which no branch scans) so a slow pass
1025
1106
  * can't double-signal, exactly as `pollReviews` flips to `converging`; `flipToMergingThenPublish`
1026
- * reverts the flip if the publish fails so a failed handoff can't wedge the PR. */
1027
- async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1107
+ * reverts the flip if the publish fails so a failed handoff can't wedge the PR.
1108
+ *
1109
+ * EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
1110
+ * terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
1111
+ * is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
1112
+ export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1028
1113
  // 1) Dependencies merged?
1029
1114
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
1030
1115
  const prKey = pr.pr_key;
1031
1116
  try {
1117
+ // Out-of-band terminal FIRST: a PR merged/closed outside the loop while parked at `wait-deps`
1118
+ // must converge even if its declared deps never clear (the #368 wedge). `wait-deps` subscribes
1119
+ // only `deps-cleared`, so the shared pre-check publishes exactly that.
1120
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
1032
1121
  const depRows = await deps(data).find({ pr_key: prKey });
1033
1122
  let allMerged = true;
1034
1123
  for (const d of depRows) {
@@ -1055,38 +1144,12 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1055
1144
  try {
1056
1145
  const st = await fetchPrState(repo, number, token);
1057
1146
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1058
- const liveness = classifyPrLiveness(st);
1059
- if (liveness === "merged") {
1060
- // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
1061
- // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` NOT
1062
- // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
1063
- // `merge-landed` here has no subscription to correlate to, so the engine drops it and the PR
1064
- // wedges forever in the transient `merging` status (which no poller branch re-scans).
1065
- // Publish `merge-ready` with a `ready` verdict instead: it routes through `gw-mergeable` to
1066
- // `attempt-merge`, whose idempotent already-merged check completes the loop (`mark-merged`).
1067
- await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
1068
- name: "merge-ready",
1069
- correlationKey: prKey,
1070
- variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1071
- });
1072
- console.log(`[poller] already merged -> ${prKey}`);
1073
- continue;
1074
- }
1075
- if (liveness === "closed") {
1076
- // Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
1077
- // land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
1078
- // process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
1079
- // `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
1080
- // terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
1081
- // One canonical abandon implementation lives in the worker — the poller only routes to it.
1082
- await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
1083
- name: "merge-ready",
1084
- correlationKey: prKey,
1085
- variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1086
- });
1087
- console.log(`[poller] closed without merging -> ${prKey}`);
1088
- continue;
1089
- }
1147
+ // Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
1148
+ // routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
1149
+ // the loop (`mark-merged`) and whose closed short-circuit abandons a PR closed without merging
1150
+ // (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
1151
+ // path the whole class (#368) now shares.
1152
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1090
1153
  const verdict = classifyMergeability(st);
1091
1154
  if (verdict === "waiting") {
1092
1155
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
@@ -1145,6 +1208,10 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1145
1208
  for (const pr of await prs(data).find({ status: "waiting_lane" })) {
1146
1209
  const prKey = pr.pr_key;
1147
1210
  try {
1211
+ // Out-of-band terminal FIRST: a lane-held PR merged/closed outside the loop must converge even
1212
+ // if its lane predecessor never releases the hold. waiting_lane leaves the process parked at
1213
+ // `wait-mergeable`, so the shared pre-check publishes `merge-ready {ready}` (→ attempt-merge).
1214
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
1148
1215
  const lane = await mergeLaneDecisionForPr(data, prKey);
1149
1216
  if (lane?.isHeld) continue;
1150
1217
  await prs(data).update(prKey, { status: "waiting_merge", updated_at: now() });
@@ -1172,15 +1239,16 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1172
1239
  try {
1173
1240
  const st = await fetchPrState(repo, number, token);
1174
1241
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1175
- const verdict = queuedVerdict(st);
1176
- if (verdict === "landed") {
1177
- await flipToMergingThenPublish(data, engine, prKey, "queued", {
1178
- name: "merge-landed",
1179
- correlationKey: prKey,
1180
- variables: {},
1181
- });
1182
- console.log(`[poller] queued PR landed -> ${prKey}`);
1183
- } else if (verdict === "evicted") {
1242
+ // Out-of-band terminal FIRST (reusing `st`): a queued PR merged out-of-band lands
1243
+ // (`merge-landed` mark-merged); one CLOSED out-of-band without merging can never land, so the
1244
+ // pre-check re-arms it (`merge-evicted` arm-merge) and block 2 abandons it — a closed queued
1245
+ // PR would otherwise wedge, since `queuedVerdict` calls a non-DIRTY closed PR merely "waiting"
1246
+ // (#368). The DIRTY-while-open eviction below still handles a live-but-conflicted queue drop.
1247
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1248
+ // Terminal states (merged/closed) are handled by the shared pre-check above; here the PR is
1249
+ // still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a real
1250
+ // merge CONFLICT (`DIRTY`). `queuedVerdict` stays the canonical classifier for that.
1251
+ if (queuedVerdict(st) === "evicted") {
1184
1252
  await flipToMergingThenPublish(data, engine, prKey, "queued", {
1185
1253
  name: "merge-evicted",
1186
1254
  correlationKey: prKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.107.0",
3
+ "version": "0.107.2",
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",
@@ -41,6 +41,8 @@
41
41
  "heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
42
42
  "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
43
43
  "reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
44
+ "precheck:derivation-parity": "urban gen",
45
+ "check:derivation-parity": "node --experimental-strip-types scripts/check-derivation-parity.ts",
44
46
  "gen": "urban gen",
45
47
  "gen:check": "urban gen --check",
46
48
  "layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
@@ -0,0 +1,213 @@
1
+ // check-derivation-parity — the FINAL regression guard for epic nanobpm/nano-ide#314 (S6, #321).
2
+ //
3
+ // This is the compounding oracle that keeps the code-first (`defineFlow`) and model-first (`.bpmn`)
4
+ // representations of the nano-workforce corpus in lockstep. For EVERY golden under
5
+ // `resources/processes/*.bpmn` it runs the full derive → diff → deploy loop:
6
+ //
7
+ // (a) DERIVE — take the golden's `defineFlow` port (test/derivation-parity/flows.ts) and derive
8
+ // its BPMN with `@nanobpm/workflow`.
9
+ // (b) DIFF — structurally compare the derived model against the checked-in golden using the S0
10
+ // parity harness (`@nanobpm/workflow/test-support`'s `normalize` / `assertDerivation-
11
+ // Parity`). The normalization/diff is NEVER reimplemented here — this gate only calls
12
+ // the shared harness, so the code-first check can't drift from the unit suite's.
13
+ // (c) DEPLOY — deploy the derived model to the in-process `@nanobpm/engine-wasm` engine (via
14
+ // `@nanobpm/urban-testkit`) and assert the engine ACCEPTS it.
15
+ //
16
+ // Any structural drift (b) OR deploy rejection (c) fails the build.
17
+ //
18
+ // PARKED MODELS ARE ACCOUNTED FOR, NOT IGNORED. The corpus is currently fully parked behind
19
+ // upstream `@nanobpm/workflow` constructs (see test/derivation-parity/flows.ts for the three blocker
20
+ // classes). Each parked model must carry a documented `blockedReason`; a model that is neither
21
+ // ported nor documented fails this gate, so the corpus can never silently lose coverage. As each
22
+ // parked model flips to a real `flow` upstream, it is automatically pulled into the full
23
+ // derive → diff → deploy loop here with NO change to this script.
24
+ //
25
+ // SELF-PROVING CANARY. Because the corpus is (today) all-parked, a gate that merely iterated it
26
+ // would be a vacuous green — it could rot without anyone noticing. So before touching the corpus we
27
+ // run a CANARY that proves the oracle's RED path genuinely fires: a faithful derived flow deploys
28
+ // green and diffs green, a drifted derivation is CAUGHT by the diff, and a corrupted model is
29
+ // REJECTED by the engine. If any red path fails to fire (the diff misses drift, or the engine
30
+ // accepts garbage), the gate fails — the oracle must be able to say no.
31
+
32
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { createWasmEngineClient } from "@nanobpm/urban-testkit";
37
+ import type { DeclarativeFlow } from "@nanobpm/workflow";
38
+ import { declarativeToBpmn, defineFlow, toDeployableBpmn } from "@nanobpm/workflow";
39
+ import { assertDerivationParity } from "@nanobpm/workflow/test-support";
40
+ import { PORTS } from "../test/derivation-parity/flows.ts";
41
+
42
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
43
+ const PROCESSES_DIR = join(REPO_ROOT, "resources", "processes");
44
+ const goldenPath = (model: string): string => join(PROCESSES_DIR, `${model}.bpmn`);
45
+
46
+ type WasmEngine = Awaited<ReturnType<typeof createWasmEngineClient>>;
47
+
48
+ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
49
+
50
+ /** Deploy a derived flow's *deployable* BPMN (semantic model + auto-layout DI) to the wasm engine
51
+ * and assert acceptance. Throws when the engine rejects the model or reports nothing deployed. */
52
+ async function deployDerived(engine: WasmEngine, id: string, flow: DeclarativeFlow): Promise<void> {
53
+ const xml = await toDeployableBpmn(flow);
54
+ const result = await engine.deployResources([{ name: `${id}.bpmn`, content: xml, contentType: "application/xml" }]);
55
+ if (!result || result.deployed < 1) {
56
+ throw new Error(`engine did not accept derived "${id}" (deployed=${result?.deployed ?? 0})`);
57
+ }
58
+ }
59
+
60
+ /** Prove the derive → diff → deploy oracle can actually say NO, so an all-parked corpus can't let
61
+ * this gate rot into a vacuous green. Pushes a line onto `errors` for any red path that fails to
62
+ * fire. */
63
+ async function proveOracleFiresRed(engine: WasmEngine, errors: string[]): Promise<void> {
64
+ const dir = mkdtempSync(join(tmpdir(), "nwf-parity-canary-"));
65
+ try {
66
+ const canary = defineFlow("parity-canary", (w) => {
67
+ w.task("step-a", { jobType: "senior:noop" });
68
+ });
69
+ const golden = join(dir, "parity-canary.bpmn");
70
+ writeFileSync(golden, declarativeToBpmn(canary), "utf8");
71
+
72
+ // DIFF, green: a faithful derivation matches its own golden.
73
+ try {
74
+ assertDerivationParity(canary, golden);
75
+ } catch (e) {
76
+ errors.push(` canary: a faithful derivation was reported as drift — the diff is broken (${errMsg(e)})`);
77
+ }
78
+
79
+ // DIFF, red: a structurally different derivation MUST be caught.
80
+ const drifted = defineFlow("parity-canary", (w) => {
81
+ w.task("step-a", { jobType: "senior:noop" });
82
+ w.task("step-b", { jobType: "senior:noop" });
83
+ });
84
+ let structuralFired = false;
85
+ try {
86
+ assertDerivationParity(drifted, golden);
87
+ } catch {
88
+ structuralFired = true;
89
+ }
90
+ if (!structuralFired) {
91
+ errors.push(" canary: the structural-drift oracle did NOT fire (an added node slipped past the diff)");
92
+ }
93
+
94
+ // DEPLOY, green: a valid derived model is accepted by the engine.
95
+ try {
96
+ await deployDerived(engine, "parity-canary", canary);
97
+ } catch (e) {
98
+ errors.push(` canary: the engine rejected a VALID derived model — the deploy oracle is broken (${errMsg(e)})`);
99
+ }
100
+
101
+ // DEPLOY, red: a corrupted model MUST be rejected.
102
+ let deployFired = false;
103
+ try {
104
+ await engine.deployResources([{ name: "corrupt.bpmn", content: "<not-bpmn/>", contentType: "application/xml" }]);
105
+ } catch {
106
+ deployFired = true;
107
+ }
108
+ if (!deployFired) {
109
+ errors.push(" canary: the deploy-rejection oracle did NOT fire (the engine accepted invalid BPMN)");
110
+ }
111
+ } finally {
112
+ rmSync(dir, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ /** Assert PORTS covers EXACTLY the checked-in goldens under
117
+ * `resources/processes/*.bpmn`. Without this the guard's "full corpus" claim is
118
+ * unproven: a newly-added golden with no PORTS entry would be silently skipped
119
+ * (eroding coverage while still reporting OK), and a stale PORTS entry for a
120
+ * deleted golden would iterate a model that no longer exists. Both directions
121
+ * fail the gate. */
122
+ function assertPortsCoverGoldens(errors: string[]): void {
123
+ const goldens = readdirSync(PROCESSES_DIR)
124
+ .filter((f) => f.endsWith(".bpmn"))
125
+ .map((f) => f.slice(0, -".bpmn".length));
126
+ const ported = PORTS.map((p) => p.model);
127
+
128
+ const missing = goldens.filter((g) => !ported.includes(g)).sort();
129
+ if (missing.length > 0) {
130
+ errors.push(
131
+ ` PORTS is missing ${missing.length} checked-in golden(s) under resources/processes ` +
132
+ `(${missing.join(", ")}) — add a derived flow or a documented blockedReason so the ` +
133
+ `"full corpus" guard can't silently skip them.`,
134
+ );
135
+ }
136
+
137
+ const orphaned = ported.filter((m) => !goldens.includes(m)).sort();
138
+ if (orphaned.length > 0) {
139
+ errors.push(
140
+ ` PORTS references ${orphaned.length} model(s) with no golden under resources/processes ` +
141
+ `(${orphaned.join(", ")}) — remove the stale entry or restore its golden.`,
142
+ );
143
+ }
144
+
145
+ const seen = new Set<string>();
146
+ const duplicated = new Set<string>();
147
+ for (const m of ported) {
148
+ if (seen.has(m)) duplicated.add(m);
149
+ seen.add(m);
150
+ }
151
+ if (duplicated.size > 0) {
152
+ errors.push(
153
+ ` PORTS has duplicate entries for ${duplicated.size} model(s) (${[...duplicated].sort().join(", ")}) — ` +
154
+ `each golden must appear exactly once.`,
155
+ );
156
+ }
157
+ }
158
+
159
+ async function main(): Promise<void> {
160
+ const errors: string[] = [];
161
+ const engine = await createWasmEngineClient();
162
+
163
+ let ported = 0;
164
+ let deployed = 0;
165
+ let parked = 0;
166
+
167
+ try {
168
+ assertPortsCoverGoldens(errors);
169
+ await proveOracleFiresRed(engine, errors);
170
+
171
+ for (const port of PORTS) {
172
+ if (port.flow) {
173
+ ported++;
174
+ const golden = goldenPath(port.model);
175
+ try {
176
+ assertDerivationParity(port.flow, golden);
177
+ } catch (e) {
178
+ errors.push(` ${port.model}: STRUCTURAL DRIFT vs golden — ${errMsg(e)}`);
179
+ continue; // a model that doesn't derive its golden can't be trusted to deploy meaningfully
180
+ }
181
+ try {
182
+ await deployDerived(engine, port.model, port.flow);
183
+ deployed++;
184
+ } catch (e) {
185
+ errors.push(` ${port.model}: DEPLOY REJECTED by wasm engine — ${errMsg(e)}`);
186
+ }
187
+ } else {
188
+ parked++;
189
+ if (!port.blockedReason || port.blockedReason.trim().length === 0) {
190
+ errors.push(
191
+ ` ${port.model}: neither ported (no flow) nor documented (no blockedReason) — every ` +
192
+ `corpus model must derive its golden or carry a precise blocker.`,
193
+ );
194
+ }
195
+ }
196
+ }
197
+ } finally {
198
+ // Release the underlying WASM engine resources so repeated runs (local / CI matrix) don't leak.
199
+ await engine.close();
200
+ }
201
+
202
+ if (errors.length > 0) {
203
+ console.error(`check-derivation-parity: the nano-workforce corpus failed its derivation-parity guard:\n${errors.join("\n")}`);
204
+ process.exit(1);
205
+ }
206
+
207
+ console.log(
208
+ `check-derivation-parity: OK (${PORTS.length} corpus models — ${ported} ported ` +
209
+ `[${deployed} deploy-accepted by the wasm engine], ${parked} documented-parked; oracle red paths verified).`,
210
+ );
211
+ }
212
+
213
+ if (import.meta.main) main();
@@ -6,7 +6,7 @@
6
6
  // with origin/main; here we drive its diff classifier directly with representative
7
7
  // `git diff --find-renames --name-status` output so each violation shape is pinned.
8
8
  import test from "node:test";
9
- import { immutabilityErrorsFromDiff } from "./check-migrations.ts";
9
+ import { collisionErrorsFromFiles, immutabilityErrorsFromDiff } from "./check-migrations.ts";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
 
12
12
  test("a rename of a merged migration is a violation", () => {
@@ -56,3 +56,41 @@ test("mixed changes report every violation but ignore the addition", () => {
56
56
  assert(errors.some((e) => /DELETED/.test(e)));
57
57
  assert(errors.some((e) => /RENAMED/.test(e)));
58
58
  });
59
+
60
+ // Merge-skew regression coverage (issue #366).
61
+ //
62
+ // The failure class this pins: two PRs each pass `check:migrations` on their OWN head, but their
63
+ // COMBINATION on `main` after both squash-merge violates the prefix-collision invariant — because
64
+ // neither branch's green CI ever saw the other's file. The 052 collision (#351 + #355, hotfixed in
65
+ // #359) was exactly this. Driving the pure collision detector with each branch's tree AND their union
66
+ // demonstrates that only the post-merge state trips the gate, which is why the gate must re-run on
67
+ // the prospective merged commit (merge_group) / on push to `main`, not just PR heads.
68
+ test("merge skew: two individually-clean branches whose union collides IS caught", () => {
69
+ const mainTree = ["050_capability_gates.sql", "051_merges_per_day.sql"];
70
+ // Each branch independently picks the same "next free" prefix (060) without seeing its sibling.
71
+ const branchA = [...mainTree, "060_plan_conformance.sql"];
72
+ const branchB = [...mainTree, "060_worker_durable_resume.sql"];
73
+
74
+ // On its own head, each branch is clean — this is why both PRs go green in isolation.
75
+ assertEquals(collisionErrorsFromFiles(branchA), [], "branch A alone has no colliding prefix");
76
+ assertEquals(collisionErrorsFromFiles(branchB), [], "branch B alone has no colliding prefix");
77
+
78
+ // The post-merge tree on `main` (git merges both files cleanly — the names don't textually
79
+ // conflict) now shares slot 060. The gate, re-run on that merged state, catches it.
80
+ const mergedOnMain = [...new Set([...branchA, ...branchB])].sort();
81
+ const errors = collisionErrorsFromFiles(mergedOnMain);
82
+ assertEquals(errors.length, 1, "the merged tree has exactly one colliding prefix");
83
+ assert(/prefix 060/.test(errors[0]));
84
+ assert(/060_plan_conformance.sql/.test(errors[0]));
85
+ assert(/060_worker_durable_resume.sql/.test(errors[0]));
86
+ });
87
+
88
+ test("collision detector flags a non-NNN shape and grandfathers historical dupes", () => {
89
+ assert(collisionErrorsFromFiles(["nope.sql"]).some((e) => /required NNN_name.sql shape/.test(e)));
90
+ // Grandfathered historical collisions (already applied forward-only) must stay exempt.
91
+ assertEquals(
92
+ collisionErrorsFromFiles(["052_plan_conformance.sql", "052_worker_durable_resume.sql"]),
93
+ [],
94
+ "grandfathered prefix 052 is not a new violation",
95
+ );
96
+ });
@@ -150,11 +150,13 @@ function checkImmutability(errors: string[]): boolean {
150
150
  return true;
151
151
  }
152
152
 
153
- function main(): void {
154
- const files = readdirSync(MIGRATIONS_DIR)
155
- .filter((f) => f.endsWith(".sql"))
156
- .sort();
157
-
153
+ /** Classify a migration file listing into shape + prefix-collision errors. Pure over the file list
154
+ * so the MERGE-SKEW scenario can be pinned in a test: two individually-clean branches each pass this
155
+ * over their OWN tree, but the gate must fail over the UNION that lands on `main` after both merge
156
+ * (issue #366). This is exactly why the gate has to re-run on the post-merge state — neither branch's
157
+ * green CI ever saw the other's prefix. Grandfathered historical collisions stay exempt. Exported
158
+ * for unit coverage. */
159
+ export function collisionErrorsFromFiles(files: readonly string[]): string[] {
158
160
  const errors: string[] = [];
159
161
  const byPrefix = new Map<string, string[]>();
160
162
 
@@ -175,13 +177,23 @@ function main(): void {
175
177
  for (const [prefix, group] of byPrefix) {
176
178
  if (group.length > 1 && !GRANDFATHERED_DUPES.has(prefix)) {
177
179
  errors.push(
178
- ` prefix ${prefix} is used by ${group.length} files: ${group.join(", ")} — ` +
180
+ ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — ` +
179
181
  `two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
180
182
  `free prefix (check origin/main, not your branch point).`,
181
183
  );
182
184
  }
183
185
  }
184
186
 
187
+ return errors;
188
+ }
189
+
190
+ function main(): void {
191
+ const files = readdirSync(MIGRATIONS_DIR)
192
+ .filter((f) => f.endsWith(".sql"))
193
+ .sort();
194
+
195
+ const errors: string[] = collisionErrorsFromFiles(files);
196
+
185
197
  const immutabilityChecked = checkImmutability(errors);
186
198
 
187
199
  if (errors.length > 0) {
@@ -121,3 +121,28 @@ Then, on a resumed run here:
121
121
 
122
122
  No golden `.bpmn` file may be edited to force a match — the derivation must
123
123
  reproduce the checked-in golden.
124
+
125
+ ## CI regression guard (S6, nano-ide#321)
126
+
127
+ The unit suite above (run under `npm test`) does the DERIVE + structural DIFF for
128
+ every ported model. The **final regression guard** adds the third leg — DEPLOY —
129
+ and wires the whole corpus into CI as its own job:
130
+
131
+ npm run check:derivation-parity # scripts/check-derivation-parity.ts
132
+
133
+ For every model it derives the BPMN from its `defineFlow` port, structurally
134
+ diffs it against the checked-in golden via the **same** S0 harness
135
+ (`normalize` / `assertDerivationParity` — never reimplemented), and **deploys the
136
+ derived model to the in-process `@nanobpm/engine-wasm` engine**
137
+ (`@nanobpm/urban-testkit`), asserting the engine accepts it. Any structural drift
138
+ **or** deploy rejection fails the build. Parked models must each carry a
139
+ documented `blockedReason`, so the corpus can never silently lose coverage; a
140
+ parked model flips into the full derive → diff → deploy loop automatically the
141
+ moment its `flow` lands — no change to the gate.
142
+
143
+ Because the corpus is (currently) fully parked, the gate runs a **self-proving
144
+ canary** first: it proves a faithful derivation deploys and diffs green, a
145
+ drifted derivation is caught by the diff, and a corrupted model is rejected by
146
+ the engine — so the oracle's red path can never rot into a vacuous green. The
147
+ guard runs on every PR/push as the `derivation-parity` job in
148
+ `.github/workflows/ci.yml`.