@nanobpm/nano-workforce 0.104.0 → 0.106.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/app/abandon.ts +12 -3
  4. package/app/agentCompletion.test.ts +28 -0
  5. package/app/agentCompletion.ts +14 -2
  6. package/app/conformance.test.ts +313 -0
  7. package/app/conformance.ts +329 -0
  8. package/app/dbFence.ts +18 -0
  9. package/app/instance-tracking.test.ts +24 -0
  10. package/app/migration053.test.ts +84 -0
  11. package/app/plan.ts +6 -0
  12. package/app/pollUserTasks.test.ts +37 -0
  13. package/app/retro.test.ts +32 -2
  14. package/app/retro.ts +26 -10
  15. package/app/service.test.ts +191 -3
  16. package/app/service.ts +163 -2
  17. package/app/userTasks.test.ts +20 -0
  18. package/app/userTasks.ts +2 -0
  19. package/app/waves.test.ts +12 -0
  20. package/app/world/store.ts +6 -6
  21. package/db/migrations/004_planning.sql +1 -1
  22. package/db/migrations/052_plan_conformance.sql +28 -0
  23. package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
  24. package/db/migrations/054_conformance_review_tracking.sql +27 -0
  25. package/nano.app.json +24 -1
  26. package/package.json +1 -1
  27. package/pages/tasks.page.json +84 -0
  28. package/resources/forms/conformance-escalation.form +17 -0
  29. package/resources/processes/retro.bpmn +123 -11
  30. package/resources/prompts/conformance.md +105 -0
  31. package/resources/prompts/retro.md +5 -0
  32. package/workers/conformance-ack/worker.test.ts +50 -0
  33. package/workers/conformance-ack/worker.ts +31 -0
  34. package/workers/conformance-record/worker.test.ts +241 -0
  35. package/workers/conformance-record/worker.ts +127 -0
  36. package/workers/merge/worker.test.ts +4 -0
  37. package/workers/merge/worker.ts +13 -18
  38. package/workers/retro-gather/worker.test.ts +6 -0
  39. package/workers/retro-gather/worker.ts +17 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * escalate unmet/undisclosed conformance deviations to the Tasks inbox ([#356](https://github.com/nanobpm/nano-workforce/issues/356)) ([5ad24bc](https://github.com/nanobpm/nano-workforce/commit/5ad24bcadf833934804973a25fd96f4eb4d28a84)), closes [#354](https://github.com/nanobpm/nano-workforce/issues/354) [#354](https://github.com/nanobpm/nano-workforce/issues/354)
7
+
8
+ # [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * examine epic implementation against spec in retro (conformance) ([#355](https://github.com/nanobpm/nano-workforce/issues/355)) ([c52d059](https://github.com/nanobpm/nano-workforce/commit/c52d0594d767c551d31ed967d1ef79cb30e82ceb)), closes [#217](https://github.com/nanobpm/nano-workforce/issues/217) [#216](https://github.com/nanobpm/nano-workforce/issues/216) [#217](https://github.com/nanobpm/nano-workforce/issues/217)
14
+
1
15
  # [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
2
16
 
3
17
 
package/README.md CHANGED
@@ -153,6 +153,7 @@ capability):
153
153
  | `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
154
154
  | `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
155
155
  | `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
156
+ | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue, and escalate an unmet/undisclosed deviation to the Tasks inbox as a non-blocking ack |
156
157
 
157
158
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
158
159
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
package/app/abandon.ts CHANGED
@@ -20,11 +20,20 @@
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
21
  import { publicBaseUrl } from "./blackboard.ts";
22
22
 
23
- /** The one app-row status that means "this run was cancelled". Convergence/merge terminal states
24
- * `converged`/`merged` are NOT abandonment only an explicit cancel flips a live run here. */
23
+ /** The one app-row status meaning a PR is terminally abandoned — the run must not be worked on
24
+ * further. Two disjoint producers flip a row here, and both are non-completion terminals that must
25
+ * stop a servicing agent:
26
+ * 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive, via the
27
+ * `instanceTracking` `onTerminated.set` patch), and
28
+ * 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
29
+ * merging** (#352) — for both `pull_requests` and its `plan_tasks`.
30
+ * Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
31
+ * case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
32
+ * true` is correct. */
25
33
  export const ABANDONED_STATUS = "abandoned";
26
34
 
27
- /** True when a PR's app-row status means the run was cancelled and the agent must not act. */
35
+ /** True when a PR's app-row status is terminally abandoned (run cancelled, or PR closed-unmerged)
36
+ * and the agent must not act. */
28
37
  export function isAbandoned(status: string | null | undefined): boolean {
29
38
  return status === ABANDONED_STATUS;
30
39
  }
@@ -229,6 +229,34 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
229
229
  assertEquals(completed[0].variables, { note: "reassigned to a human" });
230
230
  });
231
231
 
232
+ test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
233
+ // The retro conformance ack mirrors feature-blocked: a HUMAN operator retires it via
234
+ // `completeEscalationAsHuman`, but it stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) —
235
+ // an agent must never acknowledge a conformance review on a human's behalf.
236
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
237
+ const data = memData(stores);
238
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-c", elementId: "conformance-escalation" }]);
239
+
240
+ const asAgent = await completeEscalationAsAgent(data, engine, {
241
+ userTaskKey: "ut-c",
242
+ agentId: "bot",
243
+ variables: { note: "n" },
244
+ });
245
+ assertEquals(asAgent.ok, false, "the agent completer refuses conformance-escalation");
246
+ assertEquals(asAgent.reason, "not a completable task");
247
+ assertEquals(completed.length, 0);
248
+
249
+ const asHuman = await completeEscalationAsHuman(data, engine, {
250
+ userTaskKey: "ut-c",
251
+ operatorId: "alice",
252
+ variables: { note: "filed follow-up" },
253
+ });
254
+ assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
255
+ assertEquals(asHuman.elementId, "conformance-escalation");
256
+ assertEquals(completed.length, 1);
257
+ assertEquals(completed[0].variables, { note: "filed follow-up" });
258
+ });
259
+
232
260
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
233
261
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
234
262
  const data = memData(stores);
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
25
26
 
26
27
  const now = () => new Date().toISOString();
27
28
 
@@ -79,13 +80,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
79
80
  * `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
80
81
  export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
81
82
 
83
+ /** The `conformance-escalation` operator user-task element id (retro.bpmn) — the native ack a retro
84
+ * run parks on when the spec-conformance audit finds the epic did NOT cleanly meet its spec (a
85
+ * reduced / not-verified slice, or an unraised deviation). Like `feature-blocked` it is a human-only
86
+ * acknowledgement (never agent-answerable), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only
87
+ * the HUMAN completer accepts it (issue #216). Re-exported from the canonical
88
+ * `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
89
+ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
90
+
82
91
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
83
92
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
84
- * acknowledgement. The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (no `feature-blocked`),
85
- * so widening the human surface never lets an agent retire a blocked run. */
93
+ * and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
94
+ * `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
95
+ * a blocked run or a conformance review. */
86
96
  export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
87
97
  ...ESCALATION_TASK_ELEMENTS,
88
98
  FEATURE_BLOCKED_TASK_ELEMENT,
99
+ CONFORMANCE_ESCALATION_TASK_ELEMENT,
89
100
  ]);
90
101
 
91
102
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -98,6 +109,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
98
109
  "wait-answer": "pr-escalation",
99
110
  "wait-merge-answer": "pr-escalation",
100
111
  "feature-blocked": "feature-blocked",
112
+ [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
101
113
  };
102
114
 
103
115
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -0,0 +1,313 @@
1
+ // Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
4
+ import type { DataLayer } from "@nanobpm/urban";
5
+ import { memBlackboardSource } from "../test/blackboardDb.ts";
6
+ import { appendEntry } from "./blackboard.ts";
7
+ import {
8
+ acknowledgeConformance,
9
+ activeConformanceReviews,
10
+ gatherConformance,
11
+ hasDeliveredImplementation,
12
+ hasDeliveredImplementationForPlan,
13
+ recordConformance,
14
+ renderConformanceBrief,
15
+ } from "./conformance.ts";
16
+
17
+ // In-memory record gateway matching the Table<T> subset conformance.ts uses.
18
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
19
+ const stores: Record<string, any[]> = {};
20
+ const seq: Record<string, number> = {};
21
+ function tbl(name: string, pk = "id") {
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
24
+ return {
25
+ async insert(row: any) {
26
+ if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
27
+ throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
28
+ }
29
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
30
+ rows.push(pk === "id" ? { id, ...row } : { ...row });
31
+ return pk === "id" ? id : row[pk];
32
+ },
33
+ async find(where: any = {}) {
34
+ return rows.filter((r) => match(r, where));
35
+ },
36
+ async findOne(where: any = {}) {
37
+ return rows.find((r) => match(r, where));
38
+ },
39
+ async get(id: any) {
40
+ return rows.find((row) => row[pk] === id);
41
+ },
42
+ async update(id: any, patch: any) {
43
+ const r = rows.find((row) => row[pk] === id);
44
+ if (r) Object.assign(r, patch);
45
+ },
46
+ };
47
+ }
48
+ const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
49
+ return { data, stores };
50
+ }
51
+
52
+ const PLAN = "acme/widgets#7";
53
+
54
+ function seedPlan(stores: Record<string, any[]>) {
55
+ stores["plans"] = [{
56
+ plan_key: PLAN,
57
+ repo: "acme/widgets",
58
+ issue_url: "https://github.com/acme/widgets/issues/7",
59
+ title: "Widgets epic",
60
+ status: "done",
61
+ }];
62
+ }
63
+ function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
64
+ (stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
65
+ }
66
+ function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
67
+ (stores["pull_requests"] ??= []).push({ pr_key, status });
68
+ }
69
+
70
+ test("gatherConformance: collects the spec, only LANDED PRs, and raised scope-changes", async () => {
71
+ const { data, stores } = memData();
72
+ seedPlan(stores);
73
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", pr_key: "acme/widgets#10" });
74
+ seedTask(stores, { id: 2, task_index: 1, task_id: "t2", title: "Rate limit", prompt: "add rate limiting", status: "opened", pr_key: "acme/widgets#11" });
75
+ seedTask(stores, { id: 3, task_index: 2, task_id: "t3", title: "Webhook", prompt: "retry webhooks", status: "opened", pr_key: "acme/widgets#12" });
76
+ seedTask(stores, { id: 4, task_index: 3, task_id: "t4", title: "Docs", prompt: "write docs", status: "skipped", pr_key: null });
77
+ seedPr(stores, "acme/widgets#10", "merged"); // landed
78
+ seedPr(stores, "acme/widgets#11", "converged"); // landed (review-only)
79
+ seedPr(stores, "acme/widgets#12", "abandoned"); // NOT landed
80
+ await appendEntry(data, PLAN, { author_task: "t2", kind: "scope-change", body: "narrowed rate limit to per-IP only" });
81
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "not a scope change" });
82
+
83
+ const d = await gatherConformance(data, PLAN);
84
+ assertEquals(d.repo, "acme/widgets");
85
+ assertEquals(d.issueUrl, "https://github.com/acme/widgets/issues/7");
86
+ assertEquals(d.slices.length, 4);
87
+ // Only merged/converged PRs are "delivered"; abandoned and task-less slices are excluded.
88
+ assertEquals(d.deliveredPrs, ["acme/widgets#10", "acme/widgets#11"]);
89
+ assertEquals(d.slices.find((s) => s.taskId === "t3")?.landed, false);
90
+ assertEquals(d.slices.find((s) => s.taskId === "t4")?.landed, false);
91
+ // Only scope-change entries surface as raised deviations — learnings are ignored.
92
+ assertEquals(d.scopeChanges.length, 1);
93
+ assertEquals(d.scopeChanges[0].author_task, "t2");
94
+ });
95
+
96
+ test("gatherConformance: sorts slices by task_index", async () => {
97
+ const { data, stores } = memData();
98
+ seedPlan(stores);
99
+ seedTask(stores, { id: 1, task_index: 2, task_id: "t3", status: "skipped", pr_key: null });
100
+ seedTask(stores, { id: 2, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
101
+ seedTask(stores, { id: 3, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
102
+ const d = await gatherConformance(data, PLAN);
103
+ assertEquals(d.slices.map((s) => s.taskId), ["t1", "t2", "t3"]);
104
+ });
105
+
106
+ test("gatherConformance: uses pre-fetched blackboard entries instead of re-scanning", async () => {
107
+ const { data, stores } = memData();
108
+ seedPlan(stores);
109
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
110
+ // A scope-change lives in the store, but the caller passes an EMPTY pre-fetched snapshot — the
111
+ // function must honour what it was handed and not re-read the store.
112
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "scope-change", body: "should be ignored" });
113
+ const d = await gatherConformance(data, PLAN, []);
114
+ assertEquals(d.scopeChanges.length, 0);
115
+ });
116
+
117
+ test("hasDeliveredImplementation: true iff at least one PR landed", async () => {
118
+ const { data, stores } = memData();
119
+ seedPlan(stores);
120
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
121
+ seedPr(stores, "acme/widgets#10", "abandoned");
122
+ assert(!hasDeliveredImplementation(await gatherConformance(data, PLAN)));
123
+ stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "merged" }];
124
+ assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
125
+ });
126
+
127
+ test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
128
+ const { data, stores } = memData();
129
+ seedPlan(stores);
130
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
131
+ seedTask(stores, { id: 2, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
132
+ seedPr(stores, "acme/widgets#10", "abandoned");
133
+ // No landed PR yet — agrees with the full-digest helper.
134
+ assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), false);
135
+ assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), false);
136
+ // A landed PR flips both to true.
137
+ stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converged" }];
138
+ assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
139
+ assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), true);
140
+ // The cheap check must not touch the blackboard.
141
+ const before = (stores["blackboard"] ?? []).length;
142
+ await hasDeliveredImplementationForPlan(data, PLAN);
143
+ assertEquals((stores["blackboard"] ?? []).length, before);
144
+ });
145
+
146
+ test("renderConformanceBrief: lists PRs to examine, the spec, and raised deviations", () => {
147
+ const brief = renderConformanceBrief({
148
+ planKey: PLAN,
149
+ repo: "acme/widgets",
150
+ issueUrl: "https://x/7",
151
+ title: "Epic",
152
+ slices: [
153
+ { taskId: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", prKey: "acme/widgets#10", landed: true },
154
+ { taskId: "t2", title: "Docs", prompt: null, status: "skipped", prKey: null, landed: false },
155
+ ],
156
+ deliveredPrs: ["acme/widgets#10"],
157
+ scopeChanges: [{ author_task: "t1", body: "narrowed to per-IP", created_at: "now" }],
158
+ });
159
+ assertStringIncludes(brief, "gh pr diff");
160
+ assertStringIncludes(brief, "acme/widgets#10");
161
+ assertStringIncludes(brief, "add JWT auth");
162
+ assertStringIncludes(brief, "narrowed to per-IP");
163
+ assertStringIncludes(brief, "RAISED during implementation");
164
+ });
165
+
166
+ test("renderConformanceBrief: states 'none' for no delivered PRs and no scope-changes", () => {
167
+ const brief = renderConformanceBrief({
168
+ planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
169
+ slices: [], deliveredPrs: [], scopeChanges: [],
170
+ });
171
+ assertStringIncludes(brief, "no implementation to verify");
172
+ assertStringIncludes(brief, "treat any deviation you find as UNRAISED");
173
+ });
174
+
175
+ test("recordConformance: inserts then updates the same plan_key row in place", async () => {
176
+ const { data, stores } = memData();
177
+ await recordConformance(data, PLAN, {
178
+ status: "filed",
179
+ commentUrl: "https://x/7#issuecomment-1",
180
+ slicesMet: 4,
181
+ slicesReduced: 1,
182
+ slicesNotVerified: 1,
183
+ deviationsRaised: 2,
184
+ deviationsUnraised: 1,
185
+ hasDeviations: true,
186
+ summary: "6 items, 4 met",
187
+ report: "full report",
188
+ });
189
+ assertEquals(stores["plan_conformance"].length, 1);
190
+ const row = stores["plan_conformance"][0];
191
+ assertEquals(row.status, "filed");
192
+ assertEquals(row.comment_url, "https://x/7#issuecomment-1");
193
+ assertEquals(row.slices_met, 4);
194
+ assertEquals(row.has_deviations, 1);
195
+
196
+ await recordConformance(data, PLAN, { status: "skipped", summary: "nothing shipped" });
197
+ assertEquals(stores["plan_conformance"].length, 1, "same plan_key must not duplicate");
198
+ assertEquals(stores["plan_conformance"][0].status, "skipped");
199
+ assertEquals(stores["plan_conformance"][0].has_deviations, 0);
200
+ });
201
+
202
+ test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
203
+ let updated = false;
204
+ const table = {
205
+ async insert() {
206
+ throw new Error("FOREIGN KEY constraint failed");
207
+ },
208
+ async update() {
209
+ updated = true;
210
+ },
211
+ };
212
+ const data = { table: () => table } as any as DataLayer;
213
+ let threw = false;
214
+ try {
215
+ await recordConformance(data, PLAN, { status: "filed" });
216
+ } catch (err) {
217
+ threw = true;
218
+ assertStringIncludes(String(err), "FOREIGN KEY");
219
+ }
220
+ assert(threw, "the FK error must propagate");
221
+ assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
222
+ });
223
+
224
+ test("recordConformance: persists the retro instance key and the escalation review_status (issue #216)", async () => {
225
+ const { data, stores } = memData();
226
+ await recordConformance(data, PLAN, {
227
+ status: "filed",
228
+ commentUrl: "https://x/7#c",
229
+ hasDeviations: true,
230
+ summary: "slice 2 reduced",
231
+ processKey: "retro-inst-7",
232
+ reviewStatus: "reviewing",
233
+ });
234
+ const row = stores["plan_conformance"][0];
235
+ assertEquals(row.process_key, "retro-inst-7");
236
+ assertEquals(row.review_status, "reviewing");
237
+
238
+ // Absent tracking fields default: no processKey, and a settled `reviewed`.
239
+ await recordConformance(data, "acme/widgets#8", { status: "skipped" });
240
+ const clean = stores["plan_conformance"].find((r) => r.plan_key === "acme/widgets#8");
241
+ assertEquals(clean.process_key, null);
242
+ assertEquals(clean.review_status, "reviewed");
243
+ });
244
+
245
+ test("recordConformance: rejects an untrackable `reviewing` row with a null processKey (invariant guard)", async () => {
246
+ const { data, stores } = memData();
247
+ // A `reviewing` row with no `process_key` is unreachable: `pollUserTasks` skips rows without a
248
+ // key and the `instanceTracking` binding keys off `process_key`, so it would strand the review
249
+ // forever. The API must refuse to persist that state, not just the worker call site.
250
+ await assertRejects(
251
+ () =>
252
+ recordConformance(data, PLAN, {
253
+ status: "filed",
254
+ hasDeviations: true,
255
+ reviewStatus: "reviewing",
256
+ }),
257
+ Error,
258
+ "untrackable",
259
+ );
260
+ assertEquals(stores["plan_conformance"] ?? [], []);
261
+ });
262
+
263
+ test("activeConformanceReviews: returns only the rows still `reviewing`", async () => {
264
+ const { data, stores } = memData();
265
+ stores["plan_conformance"] = [
266
+ { plan_key: "a/b#1", process_key: "p1", review_status: "reviewing", summary: "s1" },
267
+ { plan_key: "a/b#2", process_key: "p2", review_status: "reviewed", summary: "s2" },
268
+ ];
269
+ const active = await activeConformanceReviews(data);
270
+ assertEquals(active.map((r) => r.plan_key), ["a/b#1"]);
271
+ });
272
+
273
+ test("acknowledgeConformance: settles the row at reviewed and folds the note into the summary", async () => {
274
+ const { data, stores } = memData();
275
+ stores["plan_conformance"] = [
276
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
277
+ ];
278
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
279
+ const row = stores["plan_conformance"][0];
280
+ assertEquals(row.review_status, "reviewed");
281
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
282
+ });
283
+
284
+ test("acknowledgeConformance: fails loudly when the plan_conformance row is missing", async () => {
285
+ const { data } = memData();
286
+ await assertRejects(
287
+ () => acknowledgeConformance(data, PLAN, "filed follow-up #9"),
288
+ Error,
289
+ PLAN,
290
+ );
291
+ });
292
+
293
+ test("acknowledgeConformance: is idempotent — a retry after settling does not re-append the note", async () => {
294
+ const { data, stores } = memData();
295
+ stores["plan_conformance"] = [
296
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
297
+ ];
298
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
299
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
300
+ const row = stores["plan_conformance"][0];
301
+ assertEquals(row.review_status, "reviewed");
302
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
303
+ });
304
+
305
+ test("acknowledgeConformance: a blank note settles the row without changing the summary", async () => {
306
+ const { data, stores } = memData();
307
+ stores["plan_conformance"] = [
308
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "auth cache unverified" },
309
+ ];
310
+ await acknowledgeConformance(data, PLAN, " ");
311
+ assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
312
+ assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
313
+ });