@nanobpm/nano-workforce 0.105.0 → 0.106.1

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.
@@ -209,6 +209,43 @@ test("pollUserTasks: projects a blocked feature run (feature-blocked) with the d
209
209
  assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
210
210
  });
211
211
 
212
+ test("pollUserTasks: projects a conformance-escalation ack (issue #216) keyed to the epic, question from summary", async () => {
213
+ // The advisory `retro` process parks on a native `conformance-escalation` user task when the
214
+ // spec-conformance audit found the epic did not cleanly meet its spec. retro is not a delivery
215
+ // aggregate, so its instance is tracked on `plan_conformance` (review_status = 'reviewing'); the
216
+ // poller scans those rows, reads the open ack task from the engine, and projects it under the epic
217
+ // (plan) subject with the audit `summary` as its question. A settled ('reviewed') row is skipped.
218
+ const { data, stores } = memData({
219
+ plan_conformance: [
220
+ {
221
+ plan_key: "o/r#70",
222
+ process_key: "cp-70",
223
+ review_status: "reviewing",
224
+ summary: "slice 2 reduced; auth cache never verified",
225
+ },
226
+ { plan_key: "o/r#71", process_key: "cp-71", review_status: "reviewed", summary: "all clean" },
227
+ ],
228
+ plans: [
229
+ { plan_key: "o/r#70", status: "done", issue_url: "https://github.com/o/r/issues/70", title: "Ship the cache" },
230
+ ],
231
+ });
232
+ const engine = fakeEngine({
233
+ "cp-70": [{ userTaskKey: "ut-conf", elementId: "conformance-escalation" }],
234
+ "cp-71": [{ userTaskKey: "ut-conf-settled", elementId: "conformance-escalation" }],
235
+ });
236
+
237
+ await pollUserTasks(data, engine);
238
+
239
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
240
+ assertEquals(Object.keys(byKey), ["ut-conf"]);
241
+ assertEquals(byKey["ut-conf"].element_id, "conformance-escalation");
242
+ assertEquals(byKey["ut-conf"].kind_label, "Conformance review");
243
+ assertEquals(byKey["ut-conf"].subject_type, "plan");
244
+ assertEquals(byKey["ut-conf"].subject_key, "o/r#70");
245
+ assertEquals(byKey["ut-conf"].subject_title, "Ship the cache");
246
+ assertEquals(byKey["ut-conf"].question, "slice 2 reduced; auth cache never verified");
247
+ });
248
+
212
249
  test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
213
250
  const { data, stores } = memData({
214
251
  user_tasks: [
package/app/service.ts CHANGED
@@ -21,6 +21,11 @@ import {
21
21
  renderResolvedDepsBrief,
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
+ import {
25
+ activeConformanceReviews,
26
+ CONFORMANCE_ESCALATION_ELEMENT,
27
+ conformanceEscalationQuestion,
28
+ } from "./conformance.ts";
24
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
25
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
26
31
  import { fleetSupportsDurableResume } from "./durableResume.ts";
@@ -2199,6 +2204,41 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
2199
2204
  }
2200
2205
  }
2201
2206
 
2207
+ // Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
2208
+ // human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
2209
+ // #216). retro is not one of the delivery aggregates above, so its instance is tracked on
2210
+ // `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
2211
+ // project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
2212
+ for (const review of await activeConformanceReviews(data)) {
2213
+ if (!review.process_key) continue;
2214
+ let tasks: { userTaskKey: string; elementId?: string }[];
2215
+ try {
2216
+ tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
2217
+ } catch (err) {
2218
+ console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
2219
+ continue;
2220
+ }
2221
+ const plan = await plans(data).get(review.plan_key);
2222
+ for (const t of tasks) {
2223
+ if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
2224
+ push(
2225
+ buildUserTaskRow(
2226
+ {
2227
+ userTaskKey: t.userTaskKey,
2228
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
2229
+ subjectType: "plan",
2230
+ subjectKey: review.plan_key,
2231
+ subjectTitle: plan?.title ?? null,
2232
+ subjectUrl: plan?.issue_url ?? null,
2233
+ question: conformanceEscalationQuestion(review),
2234
+ processKey: review.process_key,
2235
+ },
2236
+ at,
2237
+ ),
2238
+ );
2239
+ }
2240
+ }
2241
+
2202
2242
  const persisted = await userTasks(data).all();
2203
2243
  const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
2204
2244
  for (const row of inserts) await userTasks(data).insert(row);
@@ -5,6 +5,7 @@
5
5
  // These are the pure source of truth the poller projects.
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
8
9
  import type { PlanReview } from "./plan.ts";
9
10
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
10
11
  import {
@@ -63,6 +64,25 @@ test("buildUserTaskRow: a blank question / missing url normalises to null", () =
63
64
  assertEquals(row?.kind_label, "Trial merge");
64
65
  });
65
66
 
67
+ test("buildUserTaskRow: a conformance-escalation projects the 'Conformance review' label (issue #216)", () => {
68
+ const row = buildUserTaskRow(
69
+ {
70
+ userTaskKey: "ut-c",
71
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
72
+ subjectType: "plan",
73
+ subjectKey: "o/r#7",
74
+ subjectTitle: "Ship the cache",
75
+ question: "slice 2 reduced",
76
+ },
77
+ AT,
78
+ );
79
+ assert(row !== null);
80
+ assertEquals(row?.element_id, "conformance-escalation");
81
+ assertEquals(row?.kind_label, "Conformance review");
82
+ assertEquals(row?.subject_type, "plan");
83
+ assertEquals(row?.question, "slice 2 reduced");
84
+ });
85
+
66
86
  test("buildUserTaskRow: an unknown (non-escalation) element yields null — no arbitrary user task leaks", () => {
67
87
  const row = buildUserTaskRow(
68
88
  { userTaskKey: "ut-3", elementId: "some-internal-task", subjectType: "plan", subjectKey: "o/r#3" },
package/app/userTasks.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
21
22
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
22
23
  import type { PlanReview } from "./plan.ts";
23
24
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -74,6 +75,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
74
75
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
75
76
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
76
77
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
78
+ [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
77
79
  };
78
80
 
79
81
  /** The denormalised context the poller has resolved for an open escalation user task. */
@@ -0,0 +1,27 @@
1
+ -- Conformance review — escalation instance tracking (issue #216).
2
+ --
3
+ -- When conformance finds the epic did NOT cleanly meet its spec (a reduced / not-verified item, or a
4
+ -- deviation nobody raised), it escalates to the Tasks inbox as a NON-BLOCKING follow-up: the `retro`
5
+ -- process parks on a native `conformance-escalation` user task until an operator acknowledges it.
6
+ --
7
+ -- The unified inbox (`user_tasks`, 034) is reconciled by `pollUserTasks`, which scans the open user
8
+ -- tasks of each *instance-tracked* aggregate (feature_runs / plans / pull_requests). The retro process
9
+ -- had no such tracking, so its user task was invisible to the inbox. These two columns make
10
+ -- `plan_conformance` the retro run's tracking row: `process_key` is the retro process instance, and
11
+ -- `review_status` is its escalation lifecycle — `reviewing` while the ack task is open (the poller
12
+ -- scans these), `reviewed` once the run settles (set by `record-conformance` when there is nothing to
13
+ -- escalate, by the `pr.conformance-ack` worker (`acknowledgeConformance`) when an operator
14
+ -- acknowledges the escalation and the retro instance COMPLETES normally, and — as a crash/cancel
15
+ -- safety net — by `instanceTracking.onTerminated` when the retro instance is TERMINATED rather than
16
+ -- completing).
17
+ --
18
+ -- Forward-only, additive (expand): two nullable/defaulted columns on the table 052 just added; the
19
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
20
+ ALTER TABLE plan_conformance ADD COLUMN process_key TEXT;
21
+ ALTER TABLE plan_conformance ADD COLUMN review_status TEXT NOT NULL DEFAULT 'reviewed';
22
+
23
+ -- `pollUserTasks` scans open retro escalations by `review_status = 'reviewing'` every poll pass
24
+ -- (app/conformance.ts `activeConformanceReviews`). Index it so the common-case status scan stays a
25
+ -- cheap lookup instead of a full table scan as conformance rows accumulate — mirrors the
26
+ -- `idx_feature_runs_status` precedent (028) for the equivalent status-scanned aggregate.
27
+ CREATE INDEX IF NOT EXISTS idx_plan_conformance_review_status ON plan_conformance(review_status);
package/nano.app.json CHANGED
@@ -67,6 +67,20 @@
67
67
  }
68
68
  },
69
69
  "pollMs": 5000
70
+ },
71
+ {
72
+ "table": "plan_conformance",
73
+ "keyField": "process_key",
74
+ "statusField": "review_status",
75
+ "activeStatuses": [
76
+ "reviewing"
77
+ ],
78
+ "onTerminated": {
79
+ "set": {
80
+ "review_status": "reviewed"
81
+ }
82
+ },
83
+ "pollMs": 5000
70
84
  }
71
85
  ],
72
86
  "workers": [
@@ -166,6 +180,10 @@
166
180
  "taskType": "pr.conformance-record",
167
181
  "handler": "workers/conformance-record/worker.ts"
168
182
  },
183
+ {
184
+ "taskType": "pr.conformance-ack",
185
+ "handler": "workers/conformance-ack/worker.ts"
186
+ },
169
187
  {
170
188
  "taskType": "pr.progress-check",
171
189
  "handler": "workers/progress-check/worker.ts"
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.105.0",
3
+ "version": "0.106.1",
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",
7
7
  "imports": {
8
- "#test-assert": "./test/assert.ts"
8
+ "#test-assert": "./test/assert.ts",
9
+ "#test-migrations": "./test/migrations.ts"
9
10
  },
10
11
  "engines": {
11
12
  "node": ">=22.6"
@@ -37,6 +38,7 @@
37
38
  "pretypecheck": "urban gen",
38
39
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
39
40
  "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
41
+ "heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
40
42
  "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
41
43
  "reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
42
44
  "gen": "urban gen",
@@ -532,6 +532,90 @@
532
532
  },
533
533
  "refreshMs": 5000
534
534
  }
535
+ },
536
+ {
537
+ "type": "dataGrid",
538
+ "id": "conformance-reviews",
539
+ "props": {
540
+ "title": "Conformance reviews",
541
+ "data": {
542
+ "kind": "datasource",
543
+ "source": "app",
544
+ "table": "user_tasks",
545
+ "filter": [
546
+ {
547
+ "field": "element_id",
548
+ "in": [
549
+ "conformance-escalation"
550
+ ]
551
+ }
552
+ ],
553
+ "orderBy": {
554
+ "field": "updated_at",
555
+ "dir": "desc"
556
+ }
557
+ },
558
+ "columns": [
559
+ {
560
+ "field": "subject_title",
561
+ "template": "{{subject_title}}",
562
+ "header": "Epic",
563
+ "subtitleField": "subject_key",
564
+ "truncate": true,
565
+ "linkField": "subject_url"
566
+ },
567
+ {
568
+ "field": "question",
569
+ "header": "Findings"
570
+ },
571
+ {
572
+ "field": "subject_url",
573
+ "header": "Issue"
574
+ },
575
+ {
576
+ "field": "process_key",
577
+ "header": "Process",
578
+ "link": {
579
+ "kind": "processExplorer",
580
+ "keyField": "process_key"
581
+ }
582
+ },
583
+ {
584
+ "field": "updated_at",
585
+ "header": "Updated",
586
+ "format": "datetime"
587
+ }
588
+ ],
589
+ "rowKey": "user_task_key",
590
+ "detail": {
591
+ "linkField": "subject_url",
592
+ "fields": [
593
+ {
594
+ "field": "question",
595
+ "label": "Findings"
596
+ }
597
+ ],
598
+ "form": {
599
+ "showWhenField": "user_task_key",
600
+ "title": "Acknowledge conformance review",
601
+ "promptField": "question",
602
+ "inputKey": "note",
603
+ "inputLabel": "Disposition note (what you decided / follow-up)",
604
+ "submitLabel": "Acknowledge & close",
605
+ "action": {
606
+ "path": "/app/api/actions/complete-user-task",
607
+ "successLabel": "Acknowledged — the review will close",
608
+ "body": {
609
+ "userTaskKey": "{{row.user_task_key}}",
610
+ "variables": {
611
+ "note": "{{form.note}}"
612
+ }
613
+ }
614
+ }
615
+ }
616
+ },
617
+ "refreshMs": 5000
618
+ }
535
619
  }
536
620
  ]
537
621
  }
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "conformance-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "The spec-conformance review found this epic did **not** cleanly meet its spec — a slice was reduced or could not be verified against the code, or a deviation was made that nobody raised during implementation. The delivery already landed, so this is a **non-blocking** follow-up: review the report on the epic issue, decide any remediation, then acknowledge to close it.",
9
+ "label": "Conformance review"
10
+ },
11
+ {
12
+ "type": "textarea",
13
+ "key": "note",
14
+ "label": "Disposition note (what you decided / follow-up — recorded on the review)"
15
+ }
16
+ ]
17
+ }
@@ -61,7 +61,32 @@
61
61
  </zeebe:properties>
62
62
  </bpmn:extensionElements>
63
63
  <bpmn:incoming>f_toRecordConformance</bpmn:incoming>
64
- <bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
64
+ <bpmn:outgoing>f_toDeviationsGw</bpmn:outgoing>
65
+ </bpmn:serviceTask>
66
+ <bpmn:exclusiveGateway id="gw-deviations" name="deviations?" default="f_noDeviations">
67
+ <bpmn:incoming>f_toDeviationsGw</bpmn:incoming>
68
+ <bpmn:outgoing>f_deviations</bpmn:outgoing>
69
+ <bpmn:outgoing>f_noDeviations</bpmn:outgoing>
70
+ </bpmn:exclusiveGateway>
71
+ <bpmn:userTask id="conformance-escalation" name="Conformance review (human)">
72
+ <bpmn:extensionElements>
73
+ <zeebe:formDefinition formId="conformance-escalation" />
74
+ <zeebe:userTask />
75
+ <zeebe:assignmentDefinition candidateGroups="operators" />
76
+ </bpmn:extensionElements>
77
+ <bpmn:incoming>f_deviations</bpmn:incoming>
78
+ <bpmn:outgoing>f_toConformanceAck</bpmn:outgoing>
79
+ </bpmn:userTask>
80
+ <bpmn:serviceTask id="record-conformance-ack" name="Record acknowledgement">
81
+ <bpmn:extensionElements>
82
+ <zeebe:taskDefinition type="pr.conformance-ack" />
83
+ <zeebe:ioMapping>
84
+ <zeebe:input source="=planKey" target="planKey" />
85
+ <zeebe:input source="=if (is defined(note)) then note else null" target="note" />
86
+ </zeebe:ioMapping>
87
+ </bpmn:extensionElements>
88
+ <bpmn:incoming>f_toConformanceAck</bpmn:incoming>
89
+ <bpmn:outgoing>f_ackToSynthesize</bpmn:outgoing>
65
90
  </bpmn:serviceTask>
66
91
  <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
67
92
  <bpmn:extensionElements>
@@ -73,7 +98,8 @@
73
98
  <zeebe:input source="=retroDigest" target="appendPrompt" />
74
99
  </zeebe:ioMapping>
75
100
  </bpmn:extensionElements>
76
- <bpmn:incoming>f_toSynthesize</bpmn:incoming>
101
+ <bpmn:incoming>f_noDeviations</bpmn:incoming>
102
+ <bpmn:incoming>f_ackToSynthesize</bpmn:incoming>
77
103
  <bpmn:outgoing>f_toRecord</bpmn:outgoing>
78
104
  </bpmn:serviceTask>
79
105
  <bpmn:serviceTask id="record" name="Record retro">
@@ -92,7 +118,13 @@
92
118
  <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
93
119
  <bpmn:sequenceFlow id="f_toConformance" sourceRef="gather" targetRef="conformance" />
94
120
  <bpmn:sequenceFlow id="f_toRecordConformance" sourceRef="conformance" targetRef="record-conformance" />
95
- <bpmn:sequenceFlow id="f_toSynthesize" sourceRef="record-conformance" targetRef="synthesize" />
121
+ <bpmn:sequenceFlow id="f_toDeviationsGw" sourceRef="record-conformance" targetRef="gw-deviations" />
122
+ <bpmn:sequenceFlow id="f_deviations" name="deviations" sourceRef="gw-deviations" targetRef="conformance-escalation">
123
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=hasDeviations = true</bpmn:conditionExpression>
124
+ </bpmn:sequenceFlow>
125
+ <bpmn:sequenceFlow id="f_noDeviations" sourceRef="gw-deviations" targetRef="synthesize" />
126
+ <bpmn:sequenceFlow id="f_toConformanceAck" sourceRef="conformance-escalation" targetRef="record-conformance-ack" />
127
+ <bpmn:sequenceFlow id="f_ackToSynthesize" sourceRef="record-conformance-ack" targetRef="synthesize" />
96
128
  <bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
97
129
  <bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
98
130
  </bpmn:process>
@@ -113,16 +145,28 @@
113
145
  <bpmndi:BPMNShape id="BPMNShape_record-conformance" bpmnElement="record-conformance">
114
146
  <dc:Bounds x="616" y="80" width="100" height="80" />
115
147
  </bpmndi:BPMNShape>
148
+ <bpmndi:BPMNShape id="BPMNShape_gw-deviations" bpmnElement="gw-deviations" isMarkerVisible="true">
149
+ <dc:Bounds x="776" y="95" width="50" height="50" />
150
+ <bpmndi:BPMNLabel>
151
+ <dc:Bounds x="770" y="65" width="62" height="14" />
152
+ </bpmndi:BPMNLabel>
153
+ </bpmndi:BPMNShape>
154
+ <bpmndi:BPMNShape id="BPMNShape_conformance-escalation" bpmnElement="conformance-escalation">
155
+ <dc:Bounds x="751" y="240" width="100" height="80" />
156
+ </bpmndi:BPMNShape>
157
+ <bpmndi:BPMNShape id="BPMNShape_record-conformance-ack" bpmnElement="record-conformance-ack">
158
+ <dc:Bounds x="911" y="240" width="100" height="80" />
159
+ </bpmndi:BPMNShape>
116
160
  <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
117
- <dc:Bounds x="816" y="80" width="100" height="80" />
161
+ <dc:Bounds x="1056" y="80" width="100" height="80" />
118
162
  </bpmndi:BPMNShape>
119
163
  <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
120
- <dc:Bounds x="1016" y="80" width="100" height="80" />
164
+ <dc:Bounds x="1256" y="80" width="100" height="80" />
121
165
  </bpmndi:BPMNShape>
122
166
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
123
- <dc:Bounds x="1216" y="102" width="36" height="36" />
167
+ <dc:Bounds x="1456" y="102" width="36" height="36" />
124
168
  <bpmndi:BPMNLabel>
125
- <dc:Bounds x="1194" y="143" width="80" height="14" />
169
+ <dc:Bounds x="1434" y="143" width="80" height="14" />
126
170
  </bpmndi:BPMNLabel>
127
171
  </bpmndi:BPMNShape>
128
172
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -137,17 +181,34 @@
137
181
  <di:waypoint x="516" y="120" />
138
182
  <di:waypoint x="616" y="120" />
139
183
  </bpmndi:BPMNEdge>
140
- <bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
184
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toDeviationsGw" bpmnElement="f_toDeviationsGw">
141
185
  <di:waypoint x="716" y="120" />
142
- <di:waypoint x="816" y="120" />
186
+ <di:waypoint x="776" y="120" />
187
+ </bpmndi:BPMNEdge>
188
+ <bpmndi:BPMNEdge id="BPMNEdge_f_noDeviations" bpmnElement="f_noDeviations">
189
+ <di:waypoint x="826" y="120" />
190
+ <di:waypoint x="1056" y="120" />
191
+ </bpmndi:BPMNEdge>
192
+ <bpmndi:BPMNEdge id="BPMNEdge_f_deviations" bpmnElement="f_deviations">
193
+ <di:waypoint x="801" y="145" />
194
+ <di:waypoint x="801" y="240" />
195
+ </bpmndi:BPMNEdge>
196
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toConformanceAck" bpmnElement="f_toConformanceAck">
197
+ <di:waypoint x="851" y="280" />
198
+ <di:waypoint x="911" y="280" />
199
+ </bpmndi:BPMNEdge>
200
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ackToSynthesize" bpmnElement="f_ackToSynthesize">
201
+ <di:waypoint x="1011" y="280" />
202
+ <di:waypoint x="1106" y="280" />
203
+ <di:waypoint x="1106" y="160" />
143
204
  </bpmndi:BPMNEdge>
144
205
  <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
145
- <di:waypoint x="916" y="120" />
146
- <di:waypoint x="1016" y="120" />
206
+ <di:waypoint x="1156" y="120" />
207
+ <di:waypoint x="1256" y="120" />
147
208
  </bpmndi:BPMNEdge>
148
209
  <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
149
- <di:waypoint x="1116" y="120" />
150
- <di:waypoint x="1216" y="120" />
210
+ <di:waypoint x="1356" y="120" />
211
+ <di:waypoint x="1456" y="120" />
151
212
  </bpmndi:BPMNEdge>
152
213
  </bpmndi:BPMNPlane>
153
214
  </bpmndi:BPMNDiagram>
@@ -0,0 +1,58 @@
1
+ // Red/green coverage for the migration immutability gate (scripts/check-migrations.ts, issue #357).
2
+ //
3
+ // The runtime keys the migration ledger by FILENAME, so once a migration merges it must never be
4
+ // renamed (re-runs against a migrated DB and aborts boot), deleted (desyncs ledger from schema), or
5
+ // edited (silently no-ops on migrated installs). The gate diffs db/migrations against the merge-base
6
+ // with origin/main; here we drive its diff classifier directly with representative
7
+ // `git diff --find-renames --name-status` output so each violation shape is pinned.
8
+ import test from "node:test";
9
+ import { immutabilityErrorsFromDiff } from "./check-migrations.ts";
10
+ import { assert, assertEquals } from "#test-assert";
11
+
12
+ test("a rename of a merged migration is a violation", () => {
13
+ const errors = immutabilityErrorsFromDiff(
14
+ "R100\tdb/migrations/043_user_tasks_subject_title.sql\tdb/migrations/046_user_tasks_subject_title.sql",
15
+ );
16
+ assertEquals(errors.length, 1);
17
+ assert(/RENAMED/.test(errors[0]));
18
+ assert(/043_user_tasks_subject_title.sql -> 046_user_tasks_subject_title.sql/.test(errors[0]));
19
+ });
20
+
21
+ test("a delete of a merged migration is a violation", () => {
22
+ const errors = immutabilityErrorsFromDiff("D\tdb/migrations/046_user_tasks_subject_title.sql");
23
+ assertEquals(errors.length, 1);
24
+ assert(/DELETED/.test(errors[0]));
25
+ });
26
+
27
+ test("an edit of a merged migration is a violation", () => {
28
+ const errors = immutabilityErrorsFromDiff("M\tdb/migrations/046_user_tasks_subject_title.sql");
29
+ assertEquals(errors.length, 1);
30
+ assert(/EDITED/.test(errors[0]));
31
+ });
32
+
33
+ test("adding a new migration is allowed", () => {
34
+ assertEquals(
35
+ immutabilityErrorsFromDiff("A\tdb/migrations/061_brand_new.sql"),
36
+ [],
37
+ "an addition is not a violation",
38
+ );
39
+ });
40
+
41
+ test("a clean diff (no migration changes) yields no violations", () => {
42
+ assertEquals(immutabilityErrorsFromDiff(""), []);
43
+ });
44
+
45
+ test("mixed changes report every violation but ignore the addition", () => {
46
+ const errors = immutabilityErrorsFromDiff(
47
+ [
48
+ "A\tdb/migrations/061_new.sql",
49
+ "M\tdb/migrations/010_old.sql",
50
+ "D\tdb/migrations/011_gone.sql",
51
+ "R096\tdb/migrations/012_a.sql\tdb/migrations/013_a.sql",
52
+ ].join("\n"),
53
+ );
54
+ assertEquals(errors.length, 3, "the three mutations are flagged, the addition is not");
55
+ assert(errors.some((e) => /EDITED/.test(e)));
56
+ assert(errors.some((e) => /DELETED/.test(e)));
57
+ assert(errors.some((e) => /RENAMED/.test(e)));
58
+ });