@nanobpm/nano-workforce 0.113.0 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.114.0](https://github.com/nanobpm/nano-workforce/compare/v0.113.0...v0.114.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graphs:** integration runner deploys a compiled delivery graph as an engine-native process ([#397](https://github.com/nanobpm/nano-workforce/issues/397)) ([4ae2394](https://github.com/nanobpm/nano-workforce/commit/4ae239412194bfd346cde3213b15d17fad487bbc)), closes [#379](https://github.com/nanobpm/nano-workforce/issues/379)
7
+
1
8
  # [0.113.0](https://github.com/nanobpm/nano-workforce/compare/v0.112.0...v0.113.0) (2026-08-20)
2
9
 
3
10
 
@@ -23,7 +23,7 @@
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
- import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
26
+ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
27
 
28
28
  const now = () => new Date().toISOString();
29
29
 
@@ -320,12 +320,24 @@ async function resolveEscalationTask(
320
320
  const open = await engine.openUserTasks();
321
321
  const match = open.find((t) => t.userTaskKey === userTaskKey);
322
322
  if (!match) return { ok: false, reason: "no open completable task" };
323
- if (!match.elementId || !allowed.has(match.elementId)) {
323
+ if (!match.elementId || !isCompletableElement(match.elementId, allowed)) {
324
324
  return { ok: false, reason: "not a completable task" };
325
325
  }
326
326
  return { ok: true, elementId: match.elementId };
327
327
  }
328
328
 
329
+ /** Whether an open task's `elementId` is completable through the given `allowed` surface. Exact-set
330
+ * membership PLUS the delivery-human convention: any surface that admits the bare `DELIVERY_HUMAN_ELEMENT`
331
+ * (both `ESCALATION_TASK_ELEMENTS` and `HUMAN_COMPLETABLE_ELEMENTS` do) also admits the per-node inlined
332
+ * human tasks and their bounded-timeout escalation twins (`delivery-human-task__<el>[__esc]`) the S4
333
+ * compiler emits — matched through the single-source-of-truth `isDeliveryHumanElement` predicate so the
334
+ * routing can never drift from the compiler's id form (a human node is answerable by a human OR an
335
+ * agent, ADR 0046). */
336
+ function isCompletableElement(elementId: string, allowed: ReadonlySet<string>): boolean {
337
+ if (allowed.has(elementId)) return true;
338
+ return allowed.has(DELIVERY_HUMAN_ELEMENT) && isDeliveryHumanElement(elementId);
339
+ }
340
+
329
341
  /** Complete an escalation user task AS AN AGENT (ADR 0046). Resolves the parked task by its key,
330
342
  * refuses anything that is not one of the migrated escalation tasks, and routes the typed form
331
343
  * variables through the shared attributed completer with the agent's identity. Reuses the exact
@@ -0,0 +1,215 @@
1
+ // Unit coverage for the delivery-graph `connector` node's idempotency envelope (ADR 0005 slice S4,
2
+ // Decision 7). The connector is the epic's one side-effecting node kind, so it MUST fire its outbound
3
+ // action AT-MOST-ONCE per dedupe key even though the engine delivers a job AT-LEAST-ONCE. These tests
4
+ // exercise the durable-fence ledger + claim→act envelope directly against an in-memory app data layer,
5
+ // no engine:
6
+ // • a first dispatch DELIVERS (performs the action, records the claim),
7
+ // • a redelivery on the same key DEDUPES (never re-acts, reports the original detail),
8
+ // • distinct keys each deliver once,
9
+ // • the graph-derived key falls back to `<processInstanceKey>:<elementId>` when no author key is set.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals, assertRejects } from "#test-assert";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import type { DataLayer } from "@nanobpm/urban";
16
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
17
+ import {
18
+ connectorDedupeKey,
19
+ type DeliveryConnectorDispatchRow,
20
+ deliveryConnectorDispatches,
21
+ dispatchConnector,
22
+ } from "./deliveryConnector.ts";
23
+
24
+ const APP_ROOT = resolve(import.meta.dirname, "..");
25
+
26
+ /** A minimal in-memory ledger that deterministically drives the concurrent-race fence-LOSER path.
27
+ * The winning claim `seed` is already present, so the caller's INSERT hits the UNIQUE fence; `missFirstFindOne`
28
+ * makes the caller's PRE-insert `findOne` miss it (the classic findOne→insert race window), so `dispatchConnector`
29
+ * falls into its catch branch and rediscovers the winning row there. Returns the live `rows` for assertions. */
30
+ function racingLedgerData(
31
+ seed: DeliveryConnectorDispatchRow,
32
+ opts: { missFirstFindOne: boolean },
33
+ ): { data: DataLayer; rows: DeliveryConnectorDispatchRow[] } {
34
+ const rows: DeliveryConnectorDispatchRow[] = [{ ...seed, id: 1 }];
35
+ let nextId = 2;
36
+ let firstFindOne = opts.missFirstFindOne;
37
+ const table = {
38
+ async findOne(where: Record<string, unknown> = {}) {
39
+ if (firstFindOne) {
40
+ firstFindOne = false;
41
+ return undefined;
42
+ }
43
+ return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
44
+ },
45
+ async insert(row: DeliveryConnectorDispatchRow) {
46
+ if (rows.some((r) => r.dedupe_key === row.dedupe_key)) {
47
+ throw new Error("UNIQUE constraint failed: delivery_connector_dispatches.dedupe_key");
48
+ }
49
+ const id = nextId++;
50
+ rows.push({ ...row, id });
51
+ return id;
52
+ },
53
+ async update(id: number, patch: Partial<DeliveryConnectorDispatchRow>) {
54
+ const r = rows.find((x) => x.id === id);
55
+ if (r) Object.assign(r, patch);
56
+ },
57
+ async find(where: Record<string, unknown> = {}) {
58
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
59
+ },
60
+ };
61
+ return { data: { table: () => table } as any as DataLayer, rows };
62
+ }
63
+
64
+ /** Boot an app purely for its provisioned data layer (migration 055 applied), run `fn`, tear down. */
65
+ async function withApp(fn: (app: TestApp) => Promise<void>): Promise<void> {
66
+ const dir = mkdtempSync(join(tmpdir(), "nwf-connector-"));
67
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
68
+ try {
69
+ await fn(app);
70
+ } finally {
71
+ await app.stop?.();
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ }
75
+
76
+ test("connectorDedupeKey: author key wins; else derives <processInstanceKey>:<elementId>; else null", () => {
77
+ assertEquals(connectorDedupeKey({ dedupeKey: "author-1" }), "author-1");
78
+ assertEquals(connectorDedupeKey({ dedupeKey: " spaced " }), "spaced");
79
+ assertEquals(connectorDedupeKey({ processInstanceKey: "pi9", elementId: "n3" }), "pi9:n3");
80
+ assertEquals(connectorDedupeKey({ dedupeKey: " ", processInstanceKey: "pi9", elementId: "n3" }), "pi9:n3");
81
+ assertEquals(connectorDedupeKey({ dedupeKey: null }), null);
82
+ assertEquals(connectorDedupeKey({ processInstanceKey: "pi9" }), null);
83
+ // The engine can return a NUMERIC processInstanceKey — it must still derive a key, not fail closed.
84
+ assertEquals(connectorDedupeKey({ processInstanceKey: 12345, elementId: "n3" }), "12345:n3");
85
+ });
86
+
87
+ test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
88
+ await withApp(async (app) => {
89
+ const at = "2025-01-01T00:00:00.000Z";
90
+ const first = await dispatchConnector(app.db, { dedupeKey: "k1", target: "slack" }, at);
91
+ assertEquals(first.connectorOutcome, "delivered");
92
+ assert(first.connectorDetail.length > 0, "delivered dispatch carries an action detail");
93
+
94
+ // A redelivery (at-least-once) of the SAME job — same key — must NOT perform the action again.
95
+ const replay = await dispatchConnector(app.db, { dedupeKey: "k1", target: "slack" }, "2025-01-01T01:00:00.000Z");
96
+ assertEquals(replay.connectorOutcome, "deduped");
97
+ assertEquals(replay.connectorDetail, first.connectorDetail);
98
+
99
+ // Exactly one durable ledger row exists for the key — the side effect fired at most once.
100
+ const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "k1" });
101
+ assertEquals(rows.length, 1);
102
+ assertEquals(rows[0].outcome, "delivered");
103
+ });
104
+ });
105
+
106
+ test("a redelivery of a CLAIMED-but-not-delivered row RESUMES the action (never wedges on a permanent dedupe)", async () => {
107
+ await withApp(async (app) => {
108
+ const ledger = deliveryConnectorDispatches(app.db);
109
+ // Simulate a worker that crashed AFTER claiming the key but BEFORE recording delivery: a lone
110
+ // `claimed` row whose action never fired.
111
+ await ledger.insert({ dedupe_key: "wedged-1", target: "slack", outcome: "claimed", detail: null, dispatched_at: "2025-01-01T00:00:00.000Z" });
112
+
113
+ // A redelivery must RESUME (perform the action + record delivery), not report `deduped` forever.
114
+ const resumed = await dispatchConnector(app.db, { dedupeKey: "wedged-1", target: "slack" }, "2025-01-01T01:00:00.000Z");
115
+ assertEquals(resumed.connectorOutcome, "delivered");
116
+ assert(resumed.connectorDetail.length > 0, "the resumed dispatch carries an action detail");
117
+
118
+ const rows = await ledger.find({ dedupe_key: "wedged-1" });
119
+ assertEquals(rows.length, 1, "resume records on the existing row — no duplicate ledger entry");
120
+ assertEquals(rows[0].outcome, "delivered");
121
+
122
+ // And a subsequent redelivery of the now-DELIVERED row terminally dedupes.
123
+ const replay = await dispatchConnector(app.db, { dedupeKey: "wedged-1", target: "slack" }, "2025-01-01T02:00:00.000Z");
124
+ assertEquals(replay.connectorOutcome, "deduped");
125
+ assertEquals(replay.connectorDetail, resumed.connectorDetail);
126
+ });
127
+ });
128
+
129
+ test("the concurrent-race fence LOSER RESUMES a still-CLAIMED winning row (never dedupes on an un-acted claim)", async () => {
130
+ // The winner CLAIMED the key (row present) but has NOT yet recorded delivery. The loser races: its
131
+ // pre-insert lookup misses (the findOne→insert window), its INSERT hits the UNIQUE fence, and in the
132
+ // catch it rediscovers the winner's STILL-`claimed` row. If the loser deduped and completed the job
133
+ // here, the engine — having taken the loser's ack — would never redeliver, so a winner that then
134
+ // crashed would strand the side effect FOREVER. The loser must RESUME the claimed row instead.
135
+ const { data, rows } = racingLedgerData(
136
+ { dedupe_key: "race", target: "slack", outcome: "claimed", detail: null, dispatched_at: "2025-01-01T00:00:00.000Z" },
137
+ { missFirstFindOne: true },
138
+ );
139
+ const out = await dispatchConnector(data, { dedupeKey: "race", target: "slack" }, "2025-01-01T01:00:00.000Z");
140
+ assertEquals(out.connectorOutcome, "delivered");
141
+ assert(out.connectorDetail.length > 0, "the resumed dispatch carries an action detail");
142
+ const settled = rows.filter((r) => r.dedupe_key === "race");
143
+ assertEquals(settled.length, 1, "resume records on the winning row — no duplicate ledger entry");
144
+ assertEquals(settled[0].outcome, "delivered");
145
+ });
146
+
147
+ test("the fence LOSER still DEDUPES a DELIVERED winning row (reports its detail, never re-acts)", async () => {
148
+ const { data, rows } = racingLedgerData(
149
+ { dedupe_key: "race2", target: "slack", outcome: "delivered", detail: "winner-detail", dispatched_at: "2025-01-01T00:00:00.000Z" },
150
+ { missFirstFindOne: true },
151
+ );
152
+ const out = await dispatchConnector(data, { dedupeKey: "race2", target: "slack" }, "2025-01-01T01:00:00.000Z");
153
+ assertEquals(out.connectorOutcome, "deduped");
154
+ assertEquals(out.connectorDetail, "winner-detail");
155
+ assertEquals(rows.filter((r) => r.dedupe_key === "race2").length, 1, "no re-act, no duplicate row");
156
+ assertEquals(rows.find((r) => r.dedupe_key === "race2")?.outcome, "delivered");
157
+ });
158
+
159
+ test("the fence LOSER fails closed when the winning row carries a DIFFERENT target", async () => {
160
+ const { data, rows } = racingLedgerData(
161
+ { dedupe_key: "race3", target: "slack", outcome: "claimed", detail: null, dispatched_at: "2025-01-01T00:00:00.000Z" },
162
+ { missFirstFindOne: true },
163
+ );
164
+ await assertRejects(
165
+ () => dispatchConnector(data, { dedupeKey: "race3", target: "pagerduty" }, "2025-01-01T01:00:00.000Z"),
166
+ Error,
167
+ "different target",
168
+ );
169
+ // The mismatch must not have mutated the winning row (still an un-acted claim on its original target).
170
+ const row = rows.find((r) => r.dedupe_key === "race3");
171
+ assertEquals(row?.target, "slack");
172
+ assertEquals(row?.outcome, "claimed");
173
+ });
174
+
175
+ test("distinct dedupe keys each deliver once", async () => {
176
+ await withApp(async (app) => {
177
+ const a = await dispatchConnector(app.db, { dedupeKey: "a", target: "t" }, "2025-01-01T00:00:00.000Z");
178
+ const b = await dispatchConnector(app.db, { dedupeKey: "b", target: "t" }, "2025-01-01T00:00:00.000Z");
179
+ assertEquals(a.connectorOutcome, "delivered");
180
+ assertEquals(b.connectorOutcome, "delivered");
181
+ const rows = await deliveryConnectorDispatches(app.db).find({});
182
+ assertEquals(rows.length, 2);
183
+ });
184
+ });
185
+
186
+ test("a dedupe key reused with a DIFFERENT target fails closed (never delivers/reports against the wrong destination)", async () => {
187
+ await withApp(async (app) => {
188
+ const ledger = deliveryConnectorDispatches(app.db);
189
+ // A DELIVERED row would otherwise short-circuit to `deduped` and report the ORIGINAL target's detail.
190
+ const first = await dispatchConnector(app.db, { dedupeKey: "reused", target: "slack" }, "2025-01-01T00:00:00.000Z");
191
+ assertEquals(first.connectorOutcome, "delivered");
192
+ await assertRejects(
193
+ () => dispatchConnector(app.db, { dedupeKey: "reused", target: "pagerduty" }, "2025-01-01T01:00:00.000Z"),
194
+ Error,
195
+ "different target",
196
+ );
197
+
198
+ // A still-CLAIMED (crashed mid-flight) row would otherwise RESUME — against the wrong target.
199
+ await ledger.insert({ dedupe_key: "claimed-reused", target: "slack", outcome: "claimed", detail: null, dispatched_at: "2025-01-01T00:00:00.000Z" });
200
+ await assertRejects(
201
+ () => dispatchConnector(app.db, { dedupeKey: "claimed-reused", target: "pagerduty" }, "2025-01-01T01:00:00.000Z"),
202
+ Error,
203
+ "different target",
204
+ );
205
+
206
+ // The mismatch must not have mutated either ledger row.
207
+ const slackRows = await ledger.find({ target: "slack" });
208
+ assertEquals(slackRows.length, 2, "both original-target rows are intact — no wrong-target write");
209
+
210
+ // Re-dispatching each with its ORIGINAL target still dedupes/resumes normally.
211
+ const replay = await dispatchConnector(app.db, { dedupeKey: "reused", target: "slack" }, "2025-01-01T02:00:00.000Z");
212
+ assertEquals(replay.connectorOutcome, "deduped");
213
+ assertEquals(replay.connectorDetail, first.connectorDetail);
214
+ });
215
+ });
@@ -0,0 +1,210 @@
1
+ // nano-workforce — the delivery-graph `connector` node's execution body (ADR 0005 Decision 6/7, slice
2
+ // S4). A `connector` is the epic's one SIDE-EFFECTING node kind — it drives an outbound action against
3
+ // the connector I/O surface. That surface is FORWARD-DECLARED here (the concrete connector scheme and
4
+ // payload land in a later slice, per the ADR non-goals): `performConnectorAction` is a deliberate STUB.
5
+ // But the node is REAL — it is deployed, scheduled engine-natively, and, crucially, IDEMPOTENT, so the
6
+ // dedupe contract every side-effecting node inherits is exercised end-to-end today rather than
7
+ // retrofitted onto a live integration later.
8
+ //
9
+ // Idempotency (Decision 7). The engine delivers a service-task job AT-LEAST-ONCE: a worker/hub restart,
10
+ // a lost completion ack, or a graph resume re-activates the same job. So the dispatch is claimed in a
11
+ // durable ledger (`delivery_connector_dispatches`, migration 055) BEFORE the action fires; a redelivery
12
+ // that finds the key already `delivered` short-circuits to the recorded outcome (`deduped`) and never
13
+ // re-performs the side effect, while one that finds a still-`claimed` (crashed mid-flight) row RESUMES
14
+ // it. The UNIQUE fence on `dedupe_key` makes the claim atomic even under a concurrent race — the loser
15
+ // is classified by the ONE canonical `isUniqueConstraintFence` (app/dbFence.ts) as a fence collision,
16
+ // not a spurious failure, then dedupes-or-resumes the winning row exactly as a sequential redelivery
17
+ // would (the durable-fence idiom, no drift).
18
+ import type { DataLayer } from "@nanobpm/urban";
19
+ import { isUniqueConstraintFence } from "./dbFence.ts";
20
+
21
+ /** The engine `taskType` a compiled `connector` node's inlined subProcess delegates to. The canonical
22
+ * source the compiler's delegation map (`DELEGATE_TASK_TYPE.connector` imports and uses this constant),
23
+ * so the compiled BPMN's task type is DERIVED here, not re-typed. The worker registration
24
+ * (`nano.app.json`) and the compiler tests pin the same literal by value — a manifest is JSON and a
25
+ * value assertion cannot import a TS const — so they read as verification of this constant, not a
26
+ * parallel source of truth. */
27
+ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
28
+
29
+ /** The connector ledger's two-step claim lifecycle. `claimed` — the row was fenced but the action has
30
+ * not yet been recorded as done (an in-flight or crashed attempt, which `dispatchConnector` RESUMES);
31
+ * `delivered` — the action completed and the row is terminally deduped. Named constants so the claim,
32
+ * the resume check, and the delivered short-circuit can never drift on a bare string. */
33
+ export const OUTCOME_CLAIMED = "claimed";
34
+ export const OUTCOME_DELIVERED = "delivered";
35
+
36
+ /** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
37
+ export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
38
+ id?: number;
39
+ dedupe_key: string;
40
+ target: string;
41
+ outcome: string;
42
+ detail: string | null;
43
+ dispatched_at: string;
44
+ }
45
+
46
+ /** The `delivery_connector_dispatches` ledger accessor (migration 055). Access goes through the RAD
47
+ * `Table<T>` gateway (`data.table`), never hand-written SQL — matching every other data path in the app. */
48
+ export const deliveryConnectorDispatches = (data: DataLayer) =>
49
+ data.table<DeliveryConnectorDispatchRow>("delivery_connector_dispatches", "id");
50
+
51
+ /** A late-bound upstream fact threaded into a consuming node (the `boundFacts` list the compiler emits). */
52
+ export interface BoundFact {
53
+ from: string;
54
+ name: string;
55
+ value: unknown;
56
+ }
57
+
58
+ /** The effective dedupe key for a connector dispatch: the author-supplied `connector.dedupeKey` when
59
+ * present, else a graph-derived `<processInstanceKey>:<elementId>` — both STABLE across a re-activation
60
+ * of the same node instance (the engine re-delivers the same job with the same identity), so an
61
+ * at-least-once redelivery collapses onto the same ledger row. The single derivation site so the runner
62
+ * (which may pre-seed an author key) and the worker agree on the key shape. Returns `null` only when no
63
+ * key can be formed at all (no author key AND no engine identity) — the caller treats that as
64
+ * un-dedupable and MUST NOT perform the side effect (fail closed rather than double-fire). */
65
+ export function connectorDedupeKey(input: {
66
+ dedupeKey?: string | null;
67
+ processInstanceKey?: string | number | null;
68
+ elementId?: string | null;
69
+ }): string | null {
70
+ const authored = typeof input.dedupeKey === "string" ? input.dedupeKey.trim() : "";
71
+ if (authored) return authored;
72
+ // The engine can hand back a NUMERIC processInstanceKey — coerce (codebase-wide `String(...)` pattern)
73
+ // so a connector node without an authored dedupeKey stays dedupable instead of failing closed.
74
+ const pik = input.processInstanceKey == null ? "" : String(input.processInstanceKey).trim();
75
+ const el = typeof input.elementId === "string" ? input.elementId.trim() : "";
76
+ if (pik && el) return `${pik}:${el}`;
77
+ return null;
78
+ }
79
+
80
+ /** The forward-declared connector I/O surface (ADR non-goal — the concrete scheme is deferred). A STUB
81
+ * that "performs" the action by returning a deterministic acknowledgement; a later slice replaces the
82
+ * body with the real transport without touching the idempotency envelope around it. */
83
+ function performConnectorAction(_input: {
84
+ target: string;
85
+ payload: Record<string, unknown> | null;
86
+ boundFacts: readonly BoundFact[];
87
+ }): { detail: string } {
88
+ return { detail: "connector stub — I/O surface forward-declared (ADR 0005 non-goal)" };
89
+ }
90
+
91
+ /** The result of one connector dispatch attempt. `delivered` — the claim was won and the action fired
92
+ * exactly once; `deduped` — the key was already claimed (an at-least-once redelivery), so the recorded
93
+ * outcome is returned and NO side effect re-fired. */
94
+ export interface ConnectorDispatchResult extends Record<string, unknown> {
95
+ connectorOutcome: "delivered" | "deduped";
96
+ connectorDedupeKey: string;
97
+ connectorDetail: string;
98
+ }
99
+
100
+ /** Decide the outcome for a ledger row that ALREADY claims `dedupeKey`: DEDUPE it when it is terminally
101
+ * `delivered` (report the recorded detail, never re-act), or RESUME it when it is still `claimed` (its
102
+ * action never recorded delivery — perform the idempotent action now and record delivery on THIS row).
103
+ * The ONE place that decides resume-vs-dedupe for a rediscovered row, so the sequential-redelivery path
104
+ * and the concurrent-race fence-loser path can never drift on whether a still-`claimed` winner is
105
+ * resumed (AGENTS.md: "no drift surfaces"). */
106
+ async function resumeOrDedupe(
107
+ ledger: ReturnType<typeof deliveryConnectorDispatches>,
108
+ row: DeliveryConnectorDispatchRow,
109
+ input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
110
+ ): Promise<ConnectorDispatchResult> {
111
+ if (row.outcome === OUTCOME_DELIVERED) {
112
+ return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: row.detail ?? "" };
113
+ }
114
+ // Still `claimed` — a prior attempt (sequential or the concurrent-race winner) claimed the key but
115
+ // never recorded delivery. Resume on the existing row rather than dedupe forever on an un-acted claim.
116
+ const { detail } = performConnectorAction({
117
+ target: input.target,
118
+ payload: input.payload ?? null,
119
+ boundFacts: input.boundFacts ?? [],
120
+ });
121
+ if (row.id !== undefined) await ledger.update(row.id, { outcome: OUTCOME_DELIVERED, detail });
122
+ return { connectorOutcome: "delivered", connectorDedupeKey: input.dedupeKey, connectorDetail: detail };
123
+ }
124
+
125
+ /** Dispatch a connector action AT-MOST-ONCE against its dedupe key. CLAIMS the ledger row FIRST (the
126
+ * UNIQUE fence is the atomic gate that elects exactly one winner), and ONLY the claim winner performs
127
+ * the forward-declared action — so an at-least-once redelivery, or a concurrent racer, can never
128
+ * double-fire a SETTLED side effect. A redelivery whose key is already recorded `delivered` returns
129
+ * `deduped` WITHOUT acting, reporting the ORIGINAL detail.
130
+ *
131
+ * Resumability (the crash window). A claim is a two-step `claimed`→act→`delivered`. If a worker dies
132
+ * AFTER claiming but BEFORE recording delivery, the action never completed — so ANY dispatch that
133
+ * rediscovers a still-`claimed` (not yet `delivered`) row must RESUME it: perform the action and record
134
+ * delivery, rather than treating the un-acted claim as `deduped` and wedging the node on a side effect
135
+ * that never fires. Only a `delivered` row is terminally deduped. This rule is uniform across BOTH ways
136
+ * a row is rediscovered — the sequential `findOne` redelivery AND the concurrent-race fence LOSER below
137
+ * — routed through the ONE `resumeOrDedupe` decision so they can never drift. The fence loser MUST NOT
138
+ * dedupe a still-`claimed` winner: it would complete the job on the loser's ack, and the engine — having
139
+ * taken that ack — would never redeliver, so a winner that then crashed would strand the side effect
140
+ * forever (no later redelivery can recover it). Resuming instead may re-perform an action the winner is
141
+ * still delivering, but the STUB action is idempotent so a concurrent double-resume is free — exactly as
142
+ * the sequential resume already is; a real transport later slots a resumable `applied` reconcile in — as
143
+ * the world-store ledger does — to make the resume itself at-most-once, without changing this contract.
144
+ *
145
+ * Target drift (fail closed). The ledger keys only by `dedupeKey`; a key reused with a DIFFERENT
146
+ * `target` than its recorded row is a contract violation (a legitimate redelivery always repeats the
147
+ * same target), so this throws rather than deliver/resume/report against the wrong destination. */
148
+ export async function dispatchConnector(
149
+ data: DataLayer,
150
+ input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
151
+ at: string,
152
+ ): Promise<ConnectorDispatchResult> {
153
+ const ledger = deliveryConnectorDispatches(data);
154
+ const existing = await ledger.findOne({ dedupe_key: input.dedupeKey });
155
+ if (existing && existing.target !== input.target) {
156
+ // FAIL CLOSED on target drift. The ledger keys only by `dedupeKey`; if the same key is ever reused
157
+ // (accidentally or maliciously) with a DIFFERENT target, both the `delivered` short-circuit and the
158
+ // `claimed` resume below would act on / report the recorded row — delivering to, or attributing the
159
+ // outcome of, the WRONG destination and leaving the persisted `target` describing neither action.
160
+ // Refuse rather than corrupt the at-most-once ledger; a legitimate redelivery always carries the
161
+ // same target for a given key.
162
+ throw new Error(
163
+ `connector dedupe key "${input.dedupeKey}" reused with a different target ` +
164
+ `(ledger="${existing.target}", input="${input.target}") — refusing to dispatch`,
165
+ );
166
+ }
167
+ if (existing) {
168
+ // A prior attempt recorded (`delivered`) or claimed-but-crashed (`claimed`) this key. Dedupe or
169
+ // resume it on the existing row — the ONE decision shared with the fence-loser path below.
170
+ return resumeOrDedupe(ledger, existing, input);
171
+ }
172
+ let claimId: number | bigint;
173
+ try {
174
+ claimId = await ledger.insert({
175
+ dedupe_key: input.dedupeKey,
176
+ target: input.target,
177
+ outcome: OUTCOME_CLAIMED,
178
+ detail: null,
179
+ dispatched_at: at,
180
+ });
181
+ } catch (err) {
182
+ // A concurrent redelivery won the claim between our `findOne` and `insert`. The UNIQUE fence on
183
+ // `dedupe_key` rejects our loser — tolerate ONLY that collision as the same idempotent `deduped`
184
+ // outcome, and NEVER perform the action (the winner is the one that acts).
185
+ if (!isUniqueConstraintFence(err)) throw err;
186
+ const won = await ledger.findOne({ dedupe_key: input.dedupeKey });
187
+ if (won && won.target !== input.target) {
188
+ // Same target-drift anomaly as above, surfaced via a concurrent racer that won the claim with a
189
+ // different target — fail closed rather than report the wrong destination's outcome as ours.
190
+ throw new Error(
191
+ `connector dedupe key "${input.dedupeKey}" reused with a different target ` +
192
+ `(ledger="${won.target}", input="${input.target}") — refusing to dispatch`,
193
+ );
194
+ }
195
+ // Dedupe (winner `delivered`) OR resume (winner still `claimed`) the winning row — the SAME decision
196
+ // as the sequential path. Deduping a still-`claimed` winner here would complete the job on our ack,
197
+ // so a winner that then crashed would strand the side effect forever (the engine won't redeliver an
198
+ // acked job); resuming closes that gap and is safe because the action is idempotent.
199
+ if (won) return resumeOrDedupe(ledger, won, input);
200
+ return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: "" };
201
+ }
202
+ // We alone won the claim — perform the side effect exactly once and record its outcome on our row.
203
+ const { detail } = performConnectorAction({
204
+ target: input.target,
205
+ payload: input.payload ?? null,
206
+ boundFacts: input.boundFacts ?? [],
207
+ });
208
+ await ledger.update(claimId, { outcome: OUTCOME_DELIVERED, detail });
209
+ return { connectorOutcome: "delivered", connectorDedupeKey: input.dedupeKey, connectorDetail: detail };
210
+ }
@@ -6,8 +6,9 @@
6
6
  // • DETERMINISM (same JSON → byte-identical bpmn/diagram/resolved — the core trust property),
7
7
  // • rejection of every malformed class (unknown-kind / dangling / bad-from / cycle) as ok:false
8
8
  // with path-qualified errors forwarded verbatim from the validator,
9
- // • the trust bound — only allowlisted kinds are instantiated (callActivity/userTask, allowlisted
10
- // calledElement targets; a non-allowlisted kind never reaches compilation),
9
+ // • the trust bound — every node inlines an embedded subProcess whose inner body delegates to an
10
+ // allowlisted engine-native worker (serviceTask `type`) or user task (human); no scriptTask/
11
+ // callActivity ever appears (call activities are a no-op on the pinned WASM engine, ADR 0005 S4),
11
12
  // • fan-in / fan-out / multi-root / multi-leaf → explicit parallel gateways,
12
13
  // • humanNodes[] and sideEffects[] extraction.
13
14
  import { test } from "node:test";
@@ -82,16 +83,35 @@ test("determinism: the same JSON always yields byte-identical bpmn/diagram/resol
82
83
  assertEquals(c.diagram, a.diagram);
83
84
  });
84
85
 
85
- test("trust bound: only allowlisted kinds are instantiated — no other BPMN activity type appears", () => {
86
+ test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", () => {
86
87
  const r = compileOk(RELEASE_RUNBOOK);
87
- // Every node compiles to a callActivity (agent/wait/connector) or a userTask (human) nothing else.
88
- assertEquals((r.bpmn.match(/<bpmn:callActivity /g) ?? []).length, 3);
89
- assertEquals((r.bpmn.match(/<bpmn:userTask /g) ?? []).length, 1);
88
+ // Each of the 4 nodes compiles to an EMBEDDED subProcess (call activities are a no-op on the pinned
89
+ // WASM engine, so delegation is an inlined subProcess sharing the parent scope — never a callActivity).
90
+ assertEquals((r.bpmn.match(/<bpmn:callActivity/g) ?? []).length, 0);
91
+ assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 4);
90
92
  assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
91
- assert(!r.bpmn.includes("<bpmn:serviceTask"), "no bespoke service task is ever emitted");
92
- // Every call activity delegates to an allowlisted engine-native body.
93
- const called = [...r.bpmn.matchAll(/processId="([^"]+)"/g)].map((m) => m[1]);
94
- assertEquals(new Set(called), new Set(["delivery-node-agent", "readiness-gate", "delivery-node-connector"]));
93
+ // Each node's inner body delegates to an allowlisted engine-native body: a `serviceTask` typed to a
94
+ // worker (agent its `senior:*` job; wait → `pr.readiness-probe`; connector → `pr.delivery-connector`)
95
+ // or a `userTask` (human). Collect the service delegation targets.
96
+ const types = new Set([...r.bpmn.matchAll(/<zeebe:taskDefinition type="([^"]+)"/g)].map((m) => m[1]));
97
+ assert(types.has("senior:feature"), "agent delegates to its named job type");
98
+ assert(types.has("pr.readiness-probe"), "wait delegates to the readiness-probe gate");
99
+ assert(types.has("pr.delivery-connector"), "connector delegates to the connector worker");
100
+ // The human node inlines the S3 user-task body under the per-node convention id, and the bounded
101
+ // service nodes inline a human-completable escalation userTask under the same convention.
102
+ assert(/<bpmn:userTask id="delivery-human-task__n\d+"/.test(r.bpmn), "human node inlines its per-node user task");
103
+ assert(/<bpmn:userTask id="delivery-human-task__n\d+__esc"/.test(r.bpmn), "a bounded node inlines an escalation user task");
104
+ });
105
+
106
+ test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", () => {
107
+ const r = compileOk(RELEASE_RUNBOOK);
108
+ // `publish.resolvedArtifact -> consume`: the connector subProcess receives the human's emitted fact as
109
+ // a boundFacts list entry, read from the flat `<producerElement>_<fact>` variable the producer publishes.
110
+ // FEEL string literals must use single-quote XML-attribute delimiters (the engine deploy path drops
111
+ // `&quot;`-encoded quotes silently), so the boundFacts source is single-quoted with literal quotes.
112
+ assert(r.bpmn.includes("target=\"boundFacts\""), "the consumer receives a boundFacts input");
113
+ const boundInput = /<zeebe:input source='=\[\{from: "publish"[^']*\}\]' target="boundFacts"/.test(r.bpmn);
114
+ assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
95
115
  });
96
116
 
97
117
  test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", () => {
@@ -223,10 +243,12 @@ test("resolved edges carry the resolved fromNode and the referenced fact", () =>
223
243
  assertEquals(plainEdge?.fromFact, undefined);
224
244
  });
225
245
 
226
- test("BPMN is structurally coherent: one start, one end, every flow endpoint declared", () => {
246
+ test("BPMN is structurally coherent: one process start, one process end, every flow endpoint declared", () => {
227
247
  const r = compileOk(RELEASE_RUNBOOK);
228
- assertEquals((r.bpmn.match(/<bpmn:startEvent /g) ?? []).length, 1);
229
- assertEquals((r.bpmn.match(/<bpmn:endEvent /g) ?? []).length, 1);
248
+ // The TOP-LEVEL process has exactly one Start and one End (each inlined subProcess has its OWN
249
+ // start/end events, so a raw `<bpmn:startEvent>` count is not the process boundary — the fixed ids are).
250
+ assertEquals((r.bpmn.match(/ id="Start"/g) ?? []).length, 1);
251
+ assertEquals((r.bpmn.match(/ id="End"/g) ?? []).length, 1);
230
252
  // Every sequenceFlow source/target id is declared as an element id in the document.
231
253
  const declaredIds = new Set([...r.bpmn.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]));
232
254
  for (const m of r.bpmn.matchAll(/sourceRef="([^"]+)" targetRef="([^"]+)"/g)) {