@nanobpm/nano-workforce 0.201.4 → 0.202.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.202.0](https://github.com/nanobpm/nano-workforce/compare/v0.201.5...v0.202.0) (2026-10-12)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** authoritative claim resync on supervisor reconnect (Closes [#893](https://github.com/nanobpm/nano-workforce/issues/893)) ([#894](https://github.com/nanobpm/nano-workforce/issues/894)) ([2f39fcb](https://github.com/nanobpm/nano-workforce/commit/2f39fcb680ccafa22f633b409e2f66674ae85a7c))
6
+
7
+ ## [0.201.5](https://github.com/nanobpm/nano-workforce/compare/v0.201.4...v0.201.5) (2026-10-11)
8
+
9
+ ### Bug Fixes
10
+
11
+ * protect engine-owned process vars from agent-completion clobber (Closes [#887](https://github.com/nanobpm/nano-workforce/issues/887)) ([#888](https://github.com/nanobpm/nano-workforce/issues/888)) ([4245287](https://github.com/nanobpm/nano-workforce/commit/4245287e3d7553881f29bdda50cbd6d2b60b6268))
12
+
1
13
  ## [0.201.4](https://github.com/nanobpm/nano-workforce/compare/v0.201.3...v0.201.4) (2026-10-11)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -428,6 +428,79 @@ Notes:
428
428
  `answer`/`scopePending` (inputs the agent consumes) stay cleared on the
429
429
  review-round output.
430
430
 
431
+ - **Engine-owned process variables are restored around every external-agent task
432
+ (issue #887).** The engine merges **every** variable an external agent returns
433
+ into process scope **unfiltered** — the `io.nanobpm.dataEnvelope.out` shape is
434
+ codegen/typing-only and applies **no** runtime filtering or coercion. An agent
435
+ that echoes an engine-owned variable with a colliding name or drifted type (a
436
+ `senior:pr-review` job that returned `round: "4"` — a string) therefore
437
+ overwrites the real process value, and the next FEEL guard that reads it
438
+ (`round >= maxRounds` at `gw-guard`) raises `comparison not defined for string
439
+ and number` and wedges the loop on an unrecoverable incident. The categorical
440
+ guard is a **snapshot/restore** on each external-agent task (`review-round`,
441
+ `adversarial-review`, `classify-scope`): before the agent runs, each
442
+ engine-owned variable is copied into a task-**local** `guard<Name>` input
443
+ (`<zeebe:input source="=round" target="guardRound"/>` …), and the task's output
444
+ mapping **restores** the process variable from that snapshot
445
+ (`<zeebe:output source="=guardRound" target="round"/>`). Because the snapshot
446
+ holds the pre-merge value, the restore overwrites whatever the completion
447
+ merged in, so the agent's real outputs (`status`/`summary`/`question`,
448
+ `adversarialFindings`, the scope verdict) flow through but it can never alter
449
+ the protected set. The protected set is **every engine-owned variable the loop
450
+ relies on that the agent does not own as an output**: the control counters and
451
+ budgets (`round`, `maxRounds`, `advPass`, `advMax`, `ackRetryRound`,
452
+ `ackRetryMax`, `huskRetries`, `reviewWaitTimeout`), the `prKey` correlation
453
+ key, **and** the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last
454
+ three because the unfiltered merge lets an input-echo replace the identity that
455
+ `progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
456
+ `prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
457
+ the gate controls `humanApproval` and `convergeOnly`. These last two are
458
+ decision flags the loop acts on after the agent returns: `humanApproval = true`
459
+ routes a converged PR through the `gw-approval` human gate instead of
460
+ auto-merging, and `convergeOnly = true` ends a review-only run without ever
461
+ handing the PR to the merge loop. Left unguarded, a converged completion that
462
+ echoed `humanApproval: false` would **bypass** the approval gate, and one that
463
+ echoed `convergeOnly: false` would hand a review-only PR to the merge loop.
464
+ `convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
465
+ restore applies. `humanApproval` is the one engine-owned flag that is **also
466
+ legitimately mutated mid-flight** — a gated caller that adopts a live loop
467
+ raises it `false → true` via `engine.setVariables` (`submitPr` in
468
+ `app/service.ts`); it is **monotonic** (only ever rises). A plain
469
+ `=guardHumanApproval` restore would clobber that concurrent host-set true, so
470
+ the restore is the monotonic join
471
+ `=(guardHumanApproval = true) or (humanApproval = true)`: it recovers the
472
+ snapshot's `true`, honours a concurrent adopter's `true`, and ignores an agent
473
+ echo of `false`. The direction is **fail-safe** — the only state it can force
474
+ is *more* human review, never a merge the loop would otherwise have gated. The
475
+ protected set also covers the two **progress bookkeeping** values the loop
476
+ reads back after the agent returns: `reviewStale` (the converge-gate's
477
+ stale-body signal — `f_guardMax` escalates only when `round >= maxRounds and
478
+ reviewStale != true`, so a forged `reviewStale: true` would suppress the
479
+ max-round escalation) and `roundEntryHead` (the within-round baseline
480
+ `pr.capture-head` captures and `pr.progress-check` consumes — a forged head
481
+ would falsify the no-progress decision). Both are plain process-owned reads, so
482
+ the usual `=guardReviewStale` / `=guardRoundEntryHead` restore pins them. The
483
+ protected set further covers the two **seeded cooperative-abandon** reads:
484
+ `abandonUrl` (the capability `pr.persist-round`/`pr.persist-escalation`/
485
+ `pr.finalize` record for a human — a forged echo would redirect the abandon
486
+ token) and `abandonBrief` (which feeds the NEXT agent task's `appendPrompt`
487
+ input `=(if abandonBrief = null then "" else abandonBrief)` — a non-string echo
488
+ would carry a wrong/non-string brief into that later prompt). Both are plain
489
+ process-owned reads, pinned by the usual `=guardAbandonUrl` / `=guardAbandonBrief`
490
+ restore. The reserved **`io.nanobpm.agentTask`** repository envelope is
491
+ *deliberately not* in this list: it is a flat dotted framework key with no
492
+ FEEL-path access, so snapshotting it would need backtick-escaped FEEL
493
+ (`` =`io.nanobpm.agentTask` ``) that has no precedent on this engine and would,
494
+ if evaluated as the default dotted path, resolve to `null` and **null the
495
+ fleet-critical envelope on restore** — a strictly worse failure than the
496
+ hypothetical clobber (an agent would have to emit a result-JSON key literally
497
+ named `io.nanobpm.agentTask`). A counter the task itself advances is incremented
498
+ **from the snapshot** (`adversarial-review` computes `advPass` as
499
+ `(if guardAdvPass = null then 0
500
+ else guardAdvPass) + 1`), never from the mergeable process variable. Any new
501
+ external-agent task added to this loop must apply the same guard to every
502
+ engine-owned variable in scope.
503
+
431
504
 
432
505
  ## 5. Agent job contract (`senior:pr-review`)
433
506
 
@@ -817,6 +890,37 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
817
890
  `pr.persist-escalation` worker as the review stage (status `escalated`), answered
818
891
  via the native `wait-merge-answer` `userTask` (the same `completeUserTask` door as
819
892
  the review escalation, #256); answering re-arms and retries.
893
+ - **Engine-owned variable guard (issue #887)** — the merge-loop applies the same
894
+ snapshot/restore invariant as the convergence loop (§4) around its external-agent
895
+ tasks (`fix-ci`, `rebase`). Each guards the loop counters and budgets
896
+ (`ciFixRound`, `ciFixMax`, `rebaseRound`, `rebaseMax`, `mergeRetryRound`,
897
+ `mergeRetryMax`, `mergeStallRounds`, `mergeStallMax`), the `prKey` correlation
898
+ key, and the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last three
899
+ because `pr.merge` reads them directly for GitHub operations after the agent
900
+ re-arms the poller, so an input-echo that replaced them would merge the wrong
901
+ PR. It further guards the **lifecycle marker and timeout config** the loop
902
+ re-arms on every pass: `ciBlockedReconciled` (arm-merge seeds it `null`;
903
+ `ci-reconcile` sets it `true`; `gw-ci-blocked` runs the one-shot ground-truth
904
+ reconcile exactly once per episode while it is `!= true`) and the seeded timer
905
+ durations (`agentSlaTimeout`, `landedWaitTimeout`, `mergeableWaitTimeout`,
906
+ `mergeableRepollInterval`). Left unguarded, a `{status:"blocked", pushed:false,
907
+ ciBlockedReconciled:true}` completion would **skip** the mandatory first
908
+ reconcile and page a human immediately, and a clobbered timeout would silently
909
+ reshape a wait a later loop pass re-arms. The guard also pins the two values
910
+ `pr.merge` (attempt-merge) consumes directly after a successful fix/rebase
911
+ re-arms the poller: `round` (the converged round `ensurePr` records on the
912
+ merge audit row) and `abandonUrl` (the cooperative-abandon capability URL whose
913
+ token the merge worker extracts). A completion echoing either would corrupt the
914
+ recorded round or replace the abandon token, so `=guardRound` /
915
+ `=guardAbandonUrl` restores pin both. It likewise guards `abandonBrief`, which
916
+ feeds the fix-ci/rebase `appendPrompt` input (`=(if abandonBrief = null then ""
917
+ else abandonBrief)`) on the next re-arm — a non-string echo would carry a
918
+ non-string brief into that later prompt. (As in the convergence loop (§4), the
919
+ reserved dotted `io.nanobpm.agentTask` repository envelope is *not* guarded: it
920
+ has no FEEL-path access and a backtick-escaped restore, untested on this engine,
921
+ would risk nulling the envelope used for later agent provisioning.) The counter
922
+ the task advances is incremented from the snapshot (`ciFixRound`/`rebaseRound`
923
+ become `guard<Name> + 1`).
820
924
  - **Terminal** — `merged` (with `merged_at`), or `converged` when
821
925
  `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
822
926
 
@@ -0,0 +1,464 @@
1
+ // Regression guard for issue #887 — an external-agent job's completion variables must not clobber
2
+ // engine-owned process state.
3
+ //
4
+ // The defect (merlin PI 351101, incident 363350 at `gw-guard`): a `senior:pr-review` job returned
5
+ // `round: "4"` (a STRING) alongside its result. The engine merges EVERY variable an external agent
6
+ // returns into process scope UNFILTERED (the `io.nanobpm.dataEnvelope.out` shape is typing-only — no
7
+ // runtime filtering/coercion), so the agent's string overwrote the engine-owned integer counter
8
+ // `round`. The next `gw-guard` evaluation (`round >= maxRounds`) then raised
9
+ // "comparison not defined for string and number"
10
+ // and wedged the loop on an unrecoverable incident.
11
+ //
12
+ // Failure CLASS: any external-agent task can corrupt engine-owned state (round, maxRounds, the
13
+ // adversarial/ack/husk counters, prKey…) by name collision or type drift. The categorical fix snapshots
14
+ // each engine-owned control variable into a task-LOCAL input before the agent runs and RESTORES it from
15
+ // that snapshot on the task's output mapping, so an agent completion can never alter it (the snapshot is
16
+ // the pre-merge value; an output `source="=round"` would read the already-clobbered merge — proven by the
17
+ // `app/agentCompletion*`/testkit notes). The agent's real outputs (status/summary/question for a review
18
+ // round, findings for the adversarial pass, the scope verdict) still flow through untouched.
19
+ //
20
+ // These deploy the committed convergence-loop into the real WASM engine and drive a token to `gw-guard`
21
+ // with an agent that echoes `round: "4"`; the fix means NO incident is raised and `round` stays the
22
+ // engine-managed integer.
23
+ import { after, test } from "node:test";
24
+ import { assert, assertEquals } from "#test-assert";
25
+ import { readFileSync } from "node:fs";
26
+ import { createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
27
+
28
+ const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
29
+
30
+ type Output = Record<string, unknown>;
31
+ type Responder = Output | ((job: { variables: Record<string, unknown> }) => Output);
32
+
33
+ const ALL_JOB_TYPES = [
34
+ "senior:pr-review",
35
+ "senior:adversarial-review",
36
+ "pr.capture-head",
37
+ "pr.persist-round",
38
+ "pr.progress-check",
39
+ "pr.converge-gate",
40
+ "senior:scope-classify",
41
+ "pr.finalize",
42
+ "pr.persist-escalation",
43
+ "pr.answer-escalation",
44
+ ] as const;
45
+
46
+ const DEFAULT_RESPONSES: Record<string, Responder> = {
47
+ "senior:pr-review": { status: "addressed", summary: "fixed" },
48
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean" },
49
+ "pr.capture-head": { roundEntryHead: "sha-entry" },
50
+ "pr.persist-round": {},
51
+ "pr.progress-check": { progressed: true },
52
+ "pr.converge-gate": { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
53
+ "senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
54
+ "pr.finalize": {},
55
+ "pr.persist-escalation": { escalated: true },
56
+ "pr.answer-escalation": {},
57
+ };
58
+
59
+ const DEFAULT_VARS: Record<string, unknown> = {
60
+ prKey: "o/r#1",
61
+ repo: "o/r",
62
+ prNumber: 1,
63
+ prUrl: "https://example.test/pr/1",
64
+ round: 1,
65
+ maxRounds: 20,
66
+ reviewWaitTimeout: "PT30M",
67
+ ackRetryRound: 0,
68
+ ackRetryMax: 2,
69
+ abandonUrl: "https://hooks.test/abandon?token=seed",
70
+ abandonBrief: "\n\n---\nAbort if cancelled: curl …",
71
+ status: null,
72
+ question: null,
73
+ summary: null,
74
+ answer: null,
75
+ scopePending: false,
76
+ scopeAnswer: null,
77
+ convergeBlocked: null,
78
+ convergeBlockReason: null,
79
+ convergeAckOnly: null,
80
+ reviewStale: null,
81
+ roundEntryHead: null,
82
+ huskRetries: 0,
83
+ progressed: true,
84
+ escalated: null,
85
+ humanApproval: false,
86
+ convergeOnly: false,
87
+ };
88
+
89
+ const engines: WasmEngineClient[] = [];
90
+ after(async () => {
91
+ await Promise.all(engines.map((e) => e.close()));
92
+ });
93
+
94
+ async function boot(opts: { responses?: Record<string, Responder>; vars?: Record<string, unknown> } = {}) {
95
+ const engine = await createWasmEngineClient();
96
+ engines.push(engine);
97
+ await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
98
+ const responses = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
99
+ for (const jobType of ALL_JOB_TYPES) {
100
+ const responder = responses[jobType];
101
+ await engine.registerWorker(jobType, (job) =>
102
+ typeof responder === "function" ? responder(job as { variables: Record<string, unknown> }) : (responder ?? {}),
103
+ );
104
+ }
105
+ const created = await engine.createInstance({
106
+ processDefinitionId: "convergence-loop",
107
+ awaitCompletion: false,
108
+ variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
109
+ });
110
+ return { engine, processInstanceKey: created.processInstanceKey };
111
+ }
112
+
113
+ function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
114
+ const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
115
+ return snap.instances?.[0]?.variables ?? {};
116
+ }
117
+
118
+ /** Read one variable's deserialized value via the engine read channel — works on a COMPLETED instance. */
119
+ async function variableValue(engine: WasmEngineClient, processInstanceKey: string, name: string): Promise<unknown> {
120
+ const rows = await engine.searchVariables({ processInstanceKey, name });
121
+ const row = rows.find((v) => v.name === name);
122
+ assert(row, `expected variable ${name} to be present`);
123
+ return JSON.parse(row.value);
124
+ }
125
+
126
+ test("a review-round agent echoing round:\"4\" does not clobber the engine-owned counter or wedge gw-guard", async () => {
127
+ const { engine } = await boot({
128
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", round: "4" } },
129
+ });
130
+ // gw-guard evaluates `round >= maxRounds`. If the agent's string "4" clobbered `round`, the engine
131
+ // raises "comparison not defined for string and number" and parks the token on an open incident.
132
+ const incidents = await engine.searchIncidents();
133
+ assertEquals(incidents.length, 0, `no incident must be raised; got: ${JSON.stringify(incidents)}`);
134
+ // The engine-owned counter must remain the integer it was — untouched by the agent's echo.
135
+ assertEquals(instanceVars(engine).round, 1, "engine-owned `round` must stay the integer 1, unclobbered");
136
+ });
137
+
138
+ test("a review-round agent echoing numeric engine counters cannot alter them", async () => {
139
+ // A well-formed numeric echo must still be ignored — the agent owns NONE of these control variables.
140
+ const { engine } = await boot({
141
+ responses: {
142
+ "senior:pr-review": { status: "addressed", summary: "fixed", round: 99, maxRounds: 1, prKey: "evil/repo#999" },
143
+ },
144
+ });
145
+ const incidents = await engine.searchIncidents();
146
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
147
+ const vars = instanceVars(engine);
148
+ assertEquals(vars.round, 1, "round must be unchanged");
149
+ assertEquals(vars.maxRounds, 20, "maxRounds must be unchanged");
150
+ assertEquals(vars.prKey, "o/r#1", "prKey correlation key must be unchanged");
151
+ });
152
+
153
+ test("a review-round agent echoing PR identity inputs cannot alter them", async () => {
154
+ // `repo`/`prNumber`/`prUrl` are the agent's declared *inputs* (PrReviewRoundIn), not its outputs —
155
+ // but the engine merges a completion UNFILTERED, so an input-echo would replace the valid process
156
+ // identity that `progress-check`, `converge-gate`, and `finalize` go on to read. The snapshot/restore
157
+ // guard must pin them.
158
+ const { engine } = await boot({
159
+ responses: {
160
+ "senior:pr-review": {
161
+ status: "addressed",
162
+ summary: "fixed",
163
+ repo: "evil/repo",
164
+ prNumber: 999,
165
+ prUrl: "https://evil.test/pr/999",
166
+ },
167
+ },
168
+ });
169
+ const incidents = await engine.searchIncidents();
170
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
171
+ const vars = instanceVars(engine);
172
+ assertEquals(vars.repo, "o/r", "repo must be unchanged");
173
+ assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
174
+ assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
175
+ });
176
+
177
+ // The adversarial reviewer (`senior:adversarial-review`) and scope classifier
178
+ // (`senior:scope-classify`) are hardened by the SAME snapshot/restore mappings as `review-round`;
179
+ // these drive each of those tasks with an engine-owned-field echo so removing either task's guard
180
+ // turns this suite red (the `review-round` regressions above would stay green).
181
+
182
+ test("an adversarial-review agent echoing engine counters cannot alter them", async () => {
183
+ // advMax: 1 makes the adversarial pass due on round 1. The agent echoes `advPass`/`advMax` (and a
184
+ // counter) as the WRONG type; the guard must keep the engine's own increment (0 → 1) and budget.
185
+ const { engine } = await boot({
186
+ vars: { advMax: 1 },
187
+ responses: {
188
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", advPass: "7", advMax: "0", round: "9" },
189
+ },
190
+ });
191
+ const incidents = await engine.searchIncidents();
192
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
193
+ const vars = instanceVars(engine);
194
+ assertEquals(vars.advPass, 1, "advPass must be the engine increment (0 → 1), not the agent's \"7\"");
195
+ assertEquals(vars.advMax, 1, "advMax budget ceiling must be unchanged");
196
+ assertEquals(vars.round, 1, "round must be unchanged");
197
+ });
198
+
199
+ test("an adversarial-review agent echoing PR identity inputs cannot alter them", async () => {
200
+ const { engine } = await boot({
201
+ vars: { advMax: 1 },
202
+ responses: {
203
+ "senior:adversarial-review": {
204
+ adversarialFindings: "",
205
+ adversarialSummary: "clean",
206
+ repo: "evil/repo",
207
+ prNumber: 999,
208
+ prUrl: "https://evil.test/pr/999",
209
+ },
210
+ },
211
+ });
212
+ const incidents = await engine.searchIncidents();
213
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
214
+ const vars = instanceVars(engine);
215
+ assertEquals(vars.repo, "o/r", "repo must be unchanged");
216
+ assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
217
+ assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
218
+ });
219
+
220
+ test("a scope-classify agent echoing engine-owned fields cannot alter them", async () => {
221
+ // Drive to the scope classifier via a `converged` review. The classifier echoes prKey + the PR
222
+ // identity inputs; the guard must keep them. The instance runs to completion, so read the
223
+ // variables back through the read channel (a completed instance's `snapshot().variables` is folded).
224
+ const { engine, processInstanceKey } = await boot({
225
+ responses: {
226
+ "senior:pr-review": { status: "converged", summary: "done" },
227
+ "senior:scope-classify": {
228
+ scopeBlocked: false,
229
+ scopeBlockReason: "",
230
+ prKey: "evil/repo#999",
231
+ repo: "evil/repo",
232
+ prNumber: 999,
233
+ prUrl: "https://evil.test/pr/999",
234
+ },
235
+ },
236
+ });
237
+ const incidents = await engine.searchIncidents();
238
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
239
+ assertEquals(await variableValue(engine, processInstanceKey, "prKey"), "o/r#1", "prKey correlation key must be unchanged");
240
+ assertEquals(await variableValue(engine, processInstanceKey, "repo"), "o/r", "repo must be unchanged");
241
+ assertEquals(await variableValue(engine, processInstanceKey, "prNumber"), 1, "prNumber must be unchanged");
242
+ assertEquals(await variableValue(engine, processInstanceKey, "prUrl"), "https://example.test/pr/1", "prUrl must be unchanged");
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Issue #887 (round 3): the seeded merge-control gates `humanApproval` and
247
+ // `convergeOnly` are ALSO engine-owned and must survive an agent completion.
248
+ // A converged review that echoes `humanApproval:false` would bypass `gw-approval`
249
+ // (skip the required human merge approval), and `convergeOnly:false` would hand a
250
+ // review-only PR to the merge loop. The snapshot/restore guard pins both.
251
+ //
252
+ // `humanApproval` is MONOTONIC (false→true only — the host flips it true via
253
+ // `engine.setVariables` when a gated caller adopts a live loop, app/service.ts).
254
+ // So its restore is `(guardHumanApproval = true) or (humanApproval = true)`: it
255
+ // recovers a snapshot-true against an agent's false AND preserves a concurrent
256
+ // host-set true, but never lets an agent force it back to false.
257
+ // ---------------------------------------------------------------------------
258
+
259
+ test("a review-round agent echoing humanApproval:false cannot bypass the approval gate", async () => {
260
+ // Seeded gated (humanApproval:true). The agent echoes false; the monotonic restore must keep it true.
261
+ const { engine } = await boot({
262
+ vars: { humanApproval: true },
263
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", humanApproval: false } },
264
+ });
265
+ const incidents = await engine.searchIncidents();
266
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
267
+ assertEquals(instanceVars(engine).humanApproval, true, "seeded humanApproval:true must survive the agent's false echo");
268
+ });
269
+
270
+ test("a review-round agent echoing convergeOnly:false cannot re-target a review-only run at the merge loop", async () => {
271
+ // Seeded review-only (convergeOnly:true). The agent echoes false; the restore must keep it true.
272
+ const { engine } = await boot({
273
+ vars: { convergeOnly: true },
274
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", convergeOnly: false } },
275
+ });
276
+ const incidents = await engine.searchIncidents();
277
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
278
+ assertEquals(instanceVars(engine).convergeOnly, true, "seeded convergeOnly:true must survive the agent's false echo");
279
+ });
280
+
281
+ test("an adversarial-review agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
282
+ const { engine } = await boot({
283
+ vars: { advMax: 1, humanApproval: true, convergeOnly: true },
284
+ responses: {
285
+ "senior:adversarial-review": {
286
+ adversarialFindings: "",
287
+ adversarialSummary: "clean",
288
+ humanApproval: false,
289
+ convergeOnly: false,
290
+ },
291
+ },
292
+ });
293
+ const incidents = await engine.searchIncidents();
294
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
295
+ const vars = instanceVars(engine);
296
+ assertEquals(vars.humanApproval, true, "humanApproval gate must be unchanged");
297
+ assertEquals(vars.convergeOnly, true, "convergeOnly flag must be unchanged");
298
+ });
299
+
300
+ test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
301
+ // Drive to the scope classifier via a `converged` review with the gates seeded true; the classifier
302
+ // echoes them false. With humanApproval:true the instance then parks at the merge-approval user task
303
+ // (still ACTIVE), so read the gates back through the variables channel after classify-scope ran.
304
+ const { engine, processInstanceKey } = await boot({
305
+ vars: { humanApproval: true, convergeOnly: true },
306
+ responses: {
307
+ "senior:pr-review": { status: "converged", summary: "done" },
308
+ "senior:scope-classify": {
309
+ scopeBlocked: false,
310
+ scopeBlockReason: "",
311
+ humanApproval: false,
312
+ convergeOnly: false,
313
+ },
314
+ },
315
+ });
316
+ const incidents = await engine.searchIncidents();
317
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
318
+ assertEquals(await variableValue(engine, processInstanceKey, "humanApproval"), true, "humanApproval gate must be unchanged");
319
+ assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
324
+ // `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
325
+ // so an agent completion must not overwrite them:
326
+ // * `f_guardMax` escalates only when `round >= maxRounds and reviewStale != true`
327
+ // — a forged `reviewStale:true` suppresses the max-round escalation.
328
+ // * `check-progress` (pr.progress-check) consumes `roundEntryHead` as the
329
+ // within-round baseline — a forged head falsifies the progress decision.
330
+ // The snapshot/restore guard pins both on all three agent tasks.
331
+ // ---------------------------------------------------------------------------
332
+
333
+ test("a review-round agent echoing reviewStale:true cannot suppress the max-round escalation", async () => {
334
+ // Seeded at the max-round boundary with reviewStale false. The agent echoes reviewStale:true;
335
+ // without the guard, gw-guard's `reviewStale != true` arm would go false and suppress escalation.
336
+ const { engine } = await boot({
337
+ vars: { round: 20, maxRounds: 20, reviewStale: false },
338
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", reviewStale: true } },
339
+ });
340
+ const incidents = await engine.searchIncidents();
341
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
342
+ assertEquals(instanceVars(engine).reviewStale, false, "seeded reviewStale:false must survive the agent's true echo");
343
+ });
344
+
345
+ test("a review-round agent echoing a forged roundEntryHead cannot falsify the progress baseline", async () => {
346
+ // capture-head seeds roundEntryHead="sha-entry"; the agent echoes a forged head. The restore must keep it.
347
+ const { engine } = await boot({
348
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", roundEntryHead: "sha-forged" } },
349
+ });
350
+ const incidents = await engine.searchIncidents();
351
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
352
+ assertEquals(instanceVars(engine).roundEntryHead, "sha-entry", "captured roundEntryHead must survive the agent's forged echo");
353
+ });
354
+
355
+ test("an adversarial-review agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
356
+ const { engine } = await boot({
357
+ vars: { advMax: 1, reviewStale: false },
358
+ responses: {
359
+ "senior:adversarial-review": {
360
+ adversarialFindings: "",
361
+ adversarialSummary: "clean",
362
+ reviewStale: true,
363
+ roundEntryHead: "sha-forged",
364
+ },
365
+ },
366
+ });
367
+ const incidents = await engine.searchIncidents();
368
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
369
+ const vars = instanceVars(engine);
370
+ assertEquals(vars.reviewStale, false, "reviewStale must be unchanged");
371
+ assertEquals(vars.roundEntryHead, "sha-entry", "roundEntryHead must be unchanged");
372
+ });
373
+
374
+ test("a scope-classify agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
375
+ // Drive to the scope classifier via a `converged` review; the classifier echoes both. With
376
+ // humanApproval:false the instance runs to completion, so read the vars back via the channel.
377
+ const { engine, processInstanceKey } = await boot({
378
+ vars: { reviewStale: false },
379
+ responses: {
380
+ "senior:pr-review": { status: "converged", summary: "done" },
381
+ "senior:scope-classify": {
382
+ scopeBlocked: false,
383
+ scopeBlockReason: "",
384
+ reviewStale: true,
385
+ roundEntryHead: "sha-forged",
386
+ },
387
+ },
388
+ });
389
+ const incidents = await engine.searchIncidents();
390
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
391
+ assertEquals(await variableValue(engine, processInstanceKey, "reviewStale"), false, "reviewStale must be unchanged");
392
+ assertEquals(await variableValue(engine, processInstanceKey, "roundEntryHead"), "sha-entry", "roundEntryHead must be unchanged");
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Issue #887 (round 5 — Copilot "previously missed"): `abandonUrl` and
397
+ // `abandonBrief` are ALSO seeded engine-owned state readable by downstream
398
+ // persistence and prompt mappings:
399
+ // * `abandonUrl` is the cooperative-abandon capability recorded by
400
+ // `pr.persist-round`/`pr.persist-escalation`/`pr.finalize` (EscalationIn.abandonUrl,
401
+ // PrPersistRoundIn.abandonUrl, PrFinalizeIn.abandonUrl) — a forged echo
402
+ // redirects the abandon token persisted for a human.
403
+ // * `abandonBrief` feeds the `appendPrompt` input of the NEXT agent task
404
+ // (`=(if (abandonBrief = null) then "" else abandonBrief)`) — a non-string
405
+ // echo makes that later prompt mapping carry a non-string / wrong brief.
406
+ // The snapshot/restore guard pins both on all three agent tasks. (The reserved
407
+ // dotted `io.nanobpm.agentTask` envelope was DECLINED — it has no FEEL-path
408
+ // access and a backtick-escaped restore, untested on this engine, would null the
409
+ // fleet-critical repository envelope; see the PR discussion.)
410
+
411
+ test("a review-round agent echoing abandonUrl/abandonBrief cannot clobber the seeded capability or brief", async () => {
412
+ const { engine } = await boot({
413
+ responses: {
414
+ "senior:pr-review": {
415
+ status: "addressed",
416
+ summary: "fixed",
417
+ abandonUrl: "https://evil.test/abandon?token=forged",
418
+ abandonBrief: 42,
419
+ },
420
+ },
421
+ });
422
+ const incidents = await engine.searchIncidents();
423
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
424
+ const vars = instanceVars(engine);
425
+ assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "seeded abandonUrl must survive the agent's forged echo");
426
+ assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "seeded abandonBrief must survive the agent's non-string echo");
427
+ });
428
+
429
+ test("an adversarial-review agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
430
+ const { engine } = await boot({
431
+ vars: { advMax: 1 },
432
+ responses: {
433
+ "senior:adversarial-review": {
434
+ adversarialFindings: "",
435
+ adversarialSummary: "clean",
436
+ abandonUrl: "https://evil.test/abandon?token=forged",
437
+ abandonBrief: 42,
438
+ },
439
+ },
440
+ });
441
+ const incidents = await engine.searchIncidents();
442
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
443
+ const vars = instanceVars(engine);
444
+ assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
445
+ assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
446
+ });
447
+
448
+ test("a scope-classify agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
449
+ const { engine, processInstanceKey } = await boot({
450
+ responses: {
451
+ "senior:pr-review": { status: "converged", summary: "done" },
452
+ "senior:scope-classify": {
453
+ scopeBlocked: false,
454
+ scopeBlockReason: "",
455
+ abandonUrl: "https://evil.test/abandon?token=forged",
456
+ abandonBrief: 42,
457
+ },
458
+ },
459
+ });
460
+ const incidents = await engine.searchIncidents();
461
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
462
+ assertEquals(await variableValue(engine, processInstanceKey, "abandonUrl"), "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
463
+ assertEquals(await variableValue(engine, processInstanceKey, "abandonBrief"), "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
464
+ });
@@ -79,6 +79,54 @@ derivation half:
79
79
  a chosen offset, so an exited session can be branched and replayed independently, offset-parity
80
80
  preserved. Byte-replay and resume-from-offset (`transcript-read.ts`) are untouched.
81
81
 
82
+ ## Job-ownership claims & authoritative reconnect resync (#713, #893)
83
+
84
+ The `claim` / `release` family (`families/claim.family.ts`) backs the {@link ClaimRegistry}
85
+ (`claim-registry.ts`) — the authoritative "which jobs a worker instance is running" visibility source
86
+ the supply snapshot reads. Each `claim` (wire code 8) / `release` (wire code 9) frame names its OWNING
87
+ `instance` explicitly, so one per-host supervisor connection can carry many workers' ownership frames.
88
+
89
+ **Authoritative resync (the supervisor's view wins).** On a WS reconnect a supervisor re-`register`s
90
+ every worker and re-`claim`s every active jobKey. Re-claims alone are *additive* — a claim that is not
91
+ re-asserted is never dropped, so a `release` lost during a hub outage would leave the job shown as
92
+ "working" until the worker exits entirely. To close that gap a supervisor may end its resync with an
93
+ **authoritative snapshot**: a `claim` frame enriched with the full `jobKeys` set it currently owns
94
+ (`{ instance, jobKeys: [...] }` — possibly empty, the meaningful "owns nothing now" resync). The hub
95
+ then calls `ClaimRegistry.replaceInstance(instance, jobKeys)`, which **drops every claim that instance
96
+ held but did not re-assert** and (re-)claims the snapshot's set. The snapshot rides the existing
97
+ `claim` wire family as a forward-compatible enrichment (the protocol's structural validators tolerate
98
+ the extra field), so **no new wire code** is needed.
99
+
100
+ The `claim` family thus carries **two payload variants** — the classic per-job claim
101
+ `{ instance, jobKey }` and the resync snapshot `{ instance, jobKeys }` (the `claim.snapshot` wire
102
+ contract in `app/contracts.ts`). The pinned `@nanobpm/agentic` validator predates the snapshot variant
103
+ and requires a non-empty `jobKey`, so the hub validates the **union** itself: a frame is well-formed
104
+ iff it is a valid snapshot (non-empty `instance` + a `jobKeys` array of **non-empty** strings) or a
105
+ valid per-job claim. This is a **union with a validated overlap**, not an exclusive-or: a frame
106
+ carrying *both* fields is accepted, but only when *both* halves validate, so the snapshot branch never
107
+ bypasses validation. The **presence** of `jobKeys` (not its validity) is the snapshot discriminant, so
108
+ a present-but-malformed `jobKeys` — a non-array, a non-string element, **or an empty-string element**
109
+ (held to the same non-empty bar as the per-job `jobKey`) — is a broken snapshot and is **rejected**,
110
+ never treated as absent and laundered through the per-job validator (which would accept it on any
111
+ frame that also carries a valid `jobKey`). The empty *array* remains the meaningful "owns nothing now"
112
+ resync — only empty *elements* are rejected. Versioning the snapshot into the canonical `@nanobpm/agentic` protocol payloads is the
113
+ nano-ide follow-up issue #893 scopes ("coordinate with the `@nanobpm/agentic` package owners"); the
114
+ producer half (one snapshot per instance, including empty sets) is nano-supervisor#63.
115
+
116
+ Guarantees `replaceInstance` keeps:
117
+
118
+ - A claim re-asserted in the snapshot is unchanged.
119
+ - A jobKey that has **moved to another instance** is never blanked by the old instance's resync (the
120
+ old instance no longer holds it, so the drop pass cannot touch it).
121
+ - Repeated resyncs are safe (idempotent).
122
+ - **Compatibility:** a supervisor that never sends a snapshot keeps the pre-#893 additive behaviour —
123
+ claims are then cleared only by an explicit `release` or the presence-driven `reconcile`.
124
+
125
+ Authoritative replacement is safe because ownership is advisory, short-lived visibility the supervisor
126
+ can always restate; the registry never gates a BPMN flow (ADR 0056). This is what lets a downstream hub
127
+ client (nano-supervisor#63) **bound** its RELEASE buffer during an outage instead of buffering every
128
+ RELEASE without limit.
129
+
82
130
  ## Invariants (ADR 0056)
83
131
 
84
132
  - **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;