@nanobpm/nano-workforce 0.190.0 → 0.191.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +6 -6
  2. package/CHANGELOG.md +12 -0
  3. package/app/deliveryGraph.test.ts +447 -0
  4. package/app/deliveryGraph.ts +356 -1
  5. package/app/deliveryGraphCompiler.test.ts +1465 -3
  6. package/app/deliveryGraphCompiler.ts +833 -36
  7. package/app/deliveryGraphDispatch.test.ts +118 -2
  8. package/app/deliveryGraphDispatch.ts +138 -10
  9. package/app/deliveryGraphProposals.test.ts +243 -0
  10. package/app/deliveryGraphProposals.ts +130 -56
  11. package/app/deliveryGraphRun.test.ts +67 -4
  12. package/app/deliveryGraphRun.ts +62 -10
  13. package/app/deliveryHuman.test.ts +79 -2
  14. package/app/deliveryHuman.ts +92 -0
  15. package/app/deliveryRunner.test.ts +21 -0
  16. package/app/deliveryRunner.ts +9 -2
  17. package/app/mergeProtocol.ts +1 -1
  18. package/app/pollUserTasks.test.ts +39 -0
  19. package/app/readiness.test.ts +72 -0
  20. package/app/readiness.ts +234 -8
  21. package/app/service.ts +8 -3
  22. package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
  23. package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
  24. package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
  25. package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
  26. package/docs/adr/0003-epic-base-branch-admission.md +1 -1
  27. package/docs/adr/0004-shared-contract-coordination.md +1 -1
  28. package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
  29. package/docs/adr/0006-delivery-units-one-representation.md +1 -1
  30. package/operations/dispatchDeliveryGraph.test.ts +255 -0
  31. package/operations/dispatchDeliveryGraph.ts +156 -5
  32. package/package.json +1 -1
  33. package/workers/merge/worker.test.ts +2 -2
  34. package/workers/merge/worker.ts +1 -1
@@ -20,8 +20,10 @@
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
22
  import { isPlausibleBranchName } from "./baseBranch.ts";
23
+ import { isEnvKey } from "./contracts.ts";
23
24
  import { isConvergeTarget } from "./convergeTargets.ts";
24
25
  import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
26
+ import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped, parsePrTarget, redactEmbeddedCredentialUrl, redactEmbeddedSchemeRelativeUrl, redactEmbeddedUrl, redactString } from "./readiness.ts";
25
27
  import { isResolvableRepo } from "./repoEnvelope.ts";
26
28
 
27
29
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
@@ -88,6 +90,7 @@ export type DeliveryGraphErrorCode =
88
90
  | "guard-default-conflict"
89
91
  | "bad-when"
90
92
  | "guard-type-mismatch"
93
+ | "guard-invalid-equals"
91
94
  | "mixed-fan-out"
92
95
  | "multiple-defaults"
93
96
  | "non-exhaustive-split"
@@ -98,6 +101,13 @@ export type DeliveryGraphErrorCode =
98
101
  | "converge-merge-type"
99
102
  | "invalid-node-repository"
100
103
  | "invalid-node-base-branch"
104
+ | "invalid-job-type"
105
+ | "url-shaped-job-type"
106
+ | "credential-in-job-type"
107
+ | "embedded-url-in-job-type"
108
+ | "scheme-relative-url-in-job-type"
109
+ | "invalid-credential-env"
110
+ | "invalid-backoff"
101
111
  | "unbound-pr";
102
112
 
103
113
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
@@ -115,6 +125,111 @@ function isRecord(value: unknown): value is Record<string, unknown> {
115
125
  return typeof value === "object" && value !== null && !Array.isArray(value);
116
126
  }
117
127
 
128
+ /** Strip the characters XML 1.0's `Char` production forbids anywhere in a document: the C0 control
129
+ * characters (except tab `#x9`, LF `#xA`, CR `#xD`), the noncharacters U+FFFE/U+FFFF, and unpaired
130
+ * UTF-16 surrogates — none can be represented by an entity, so any of them in an element
131
+ * `name`/`documentation` makes `layoutBpmn`/deployment reject the whole semantic BPMN. VALID astral
132
+ * pairs are preserved; dropping an unrepresentable character is the only well-formed rendering.
133
+ * Canonical here (the low-level graph module) so both the validator (which must REJECT such a
134
+ * character in executable FEEL — see {@link hasXmlInvalidChars}) and the compiler (which strips it
135
+ * from DISPLAY text) share ONE character-class definition — no drift surface. Deterministic and
136
+ * total. */
137
+ export function stripXmlInvalidChars(value: string): string {
138
+ return (
139
+ value
140
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this IS the XML-1.0 control filter.
141
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
142
+ // Unpaired surrogates (a high not followed by a low, or a low not preceded by a high); valid pairs stay.
143
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
144
+ // XML-1.0 noncharacters just past the BMP `Char` range end (#xFFFD).
145
+ .replace(/[\uFFFE\uFFFF]/g, "")
146
+ );
147
+ }
148
+
149
+ /** True when `value` contains any XML-1.0-forbidden character (see {@link stripXmlInvalidChars}).
150
+ * Defined in terms of the strip so detection and stripping can never disagree — a single source of
151
+ * truth for the character class. Used to REJECT such a character in an executable FEEL guard literal
152
+ * at validation time (rather than let the compiler's display-text sanitiser silently rewrite the
153
+ * guard and route the process down the wrong edge). Deterministic and total. */
154
+ export function hasXmlInvalidChars(value: string): boolean {
155
+ return stripXmlInvalidChars(value) !== value;
156
+ }
157
+
158
+ /** Credential redaction for a free-form connector value (`target`, `dedupeKey`, a bound
159
+ * `payload.pr`) or an `agent.jobType` descriptor. Such a value is frequently an OPAQUE identifier —
160
+ * `slack:#releases`, `owner/repo#42`, a `<node>.pr` ref, `pkg@version` — in which `#`/`?`/`@` are
161
+ * MEANINGFUL, so blind {@link redactString} would mangle it (e.g. `slack:#releases` → `slack:#***`,
162
+ * `owner/repo#42` → `owner/repo#***`). Only a value that is actually a URL — a `scheme://authority` OR a
163
+ * scheme-relative `//authority` form ({@link isUrlShaped}), either of which can hide a credential in
164
+ * userinfo/query/fragment (`redactString` redacts both) — is redacted in FULL (userinfo AND
165
+ * `?query`/`#fragment`). But an embedded `//<userinfo>@` credential can also ride AFTER a non-URL
166
+ * prefix in a free-form value that later parsers accept (`parsePrTarget` takes any prefix before
167
+ * `#<digits>`, so `prefix //user:pass@host#42`), which the anchored {@link isUrlShaped} check misses.
168
+ * Because a `//<userinfo>@` span is UNAMBIGUOUSLY a credential wherever it sits (an opaque id never
169
+ * contains one), a scheme-relative `//<userinfo>@authority…` run is treated as an embedded URL and
170
+ * redacted in FULL — userinfo AND its `?query`/`#fragment` — via {@link redactEmbeddedCredentialUrl},
171
+ * since userinfo marks the run a URL whose `?`/`#` ARE URL syntax rather than a meaningful opaque token
172
+ * character; an opaque `//host#42` (no userinfo) keeps its `#42` (issue #778 review — threads
173
+ * deliveryGraph.ts:162/566, :188). An embedded ABSOLUTE URL
174
+ * (an explicit `scheme://…` token) after a non-URL prefix (`prefix https://host/path?token=secret`)
175
+ * likewise hides a `?query`/`#fragment` token the anchored whole-value {@link isUrlShaped} check
176
+ * misses; its `?`/`#` ARE URL syntax (explicit scheme), so redact that token in full via
177
+ * {@link redactEmbeddedUrl}, while a scheme-relative `//host#42` keeps its meaningful opaque `#42`
178
+ * (issue #778 review — thread deliveryGraph.ts:181). Canonical HERE (the low-level
179
+ * graph module, alongside {@link stripXmlInvalidChars} and the URL classifier it shares) so the
180
+ * compiler's DISPLAY path AND `validateDeliveryGraph`'s reject/error path use ONE redactor — no drift
181
+ * surface (issue #778 review — thread deliveryGraphCompiler.ts:1606). Deterministic and total. */
182
+ export function redactConnectorValue(value: string): string {
183
+ // Strip XML-invalid display characters BEFORE classifying/redacting: the anchored `^(scheme:)?//`
184
+ // check and the redaction both run on the exact string the renderer will emit. Otherwise a target
185
+ // prefixed by an unrepresentable control char (e.g. `\x01//user:pass@host/?token=…`) fails the
186
+ // anchored check, escapes redaction, then loses that prefix during `escapeXml`/`stripXmlInvalidChars`
187
+ // — surfacing the credential verbatim in the BPMN name/documentation and connector escalation FEEL.
188
+ const cleaned = stripXmlInvalidChars(value);
189
+ // A whole-value URL gets the full redact (userinfo + query/fragment) — EXCEPT a whole-value opaque
190
+ // scheme-relative PR ref (`//host#42`: no explicit scheme, no `//…@` credential, no `?query`), whose
191
+ // only URL-ish payload is a MEANINGFUL `#<digits>` fragment (a `parsePrTarget` PR ref). `isUrlShaped`
192
+ // matches such a ref (scheme-relative `//authority`), so the whole-value branch would `redactString`
193
+ // its `#42` → `#***` — yet the EMBEDDED contract deliberately PRESERVES the same `//host#42` (the
194
+ // embedded redactors leave a userinfo-/query-less `//host#42` untouched). Route it through the embedded
195
+ // path so a whole-value `//host#42` keeps its `#42` exactly like `prefix //host#42`, closing that
196
+ // whole-value/embedded inconsistency (issue #778 review — thread deliveryGraph.ts:198). Only a
197
+ // NUMERIC `#<digits>` fragment is a valid `parsePrTarget` PR handle — `//host#access-token` is an
198
+ // ordinary URL fragment that can hide a secret, so the exception REQUIRES `parsePrTarget` to accept
199
+ // the whole (trimmed) value; a non-numeric fragment falls to the full whole-value redact like any
200
+ // other URL fragment (issue #778 review — thread deliveryGraph.ts:206). An explicit
201
+ // `scheme://host#42`, a `//user:pass@host#…` credential, or a userinfo-less `//host?token=…` query all
202
+ // still fall to the full whole-value redact.
203
+ // Any non-URL value keeps its meaningful `#`/`?` opaque-token characters, but still has (a) each
204
+ // embedded ABSOLUTE-URL (`scheme://…`) token redacted in full — its `?query`/`#fragment` IS URL syntax
205
+ // — and (b) each embedded scheme-relative `//<userinfo>@authority…` credential-URL redacted in full
206
+ // (userinfo AND `?query`/`#fragment`), and (c) each embedded userinfo-LESS scheme-relative URL that
207
+ // carries a `?query` (`//host?token=secret`) redacted (its `?` is unambiguously URL syntax — an opaque
208
+ // PR ref uses a `#<digits>` fragment, never a `//…?…` query), while a userinfo-less opaque `//host#42`
209
+ // (no `?`) keeps its `#42` (issue #778 review — thread deliveryGraph.ts:633).
210
+ const isOpaqueSchemeRelativePrRef =
211
+ cleaned.trim().startsWith("//") &&
212
+ !hasEmbeddedCredential(cleaned) &&
213
+ !cleaned.includes("?") &&
214
+ parsePrTarget(cleaned) !== null;
215
+ return isUrlShaped(cleaned) && !isOpaqueSchemeRelativePrRef
216
+ ? redactString(cleaned)
217
+ : redactEmbeddedSchemeRelativeUrl(redactEmbeddedCredentialUrl(redactEmbeddedUrl(cleaned)));
218
+ }
219
+
220
+ /** True when `value` contains a whitespace character that XML **attribute-value normalization**
221
+ * rewrites to a space (literal TAB `#x9`, LF `#xA`, or CR `#xD`). Such characters are perfectly
222
+ * valid XML `Char`s — so {@link hasXmlInvalidChars} does NOT flag them — yet when a value is emitted
223
+ * verbatim into a raw XML attribute (`<zeebe:taskDefinition type="…">`), a conforming parser folds
224
+ * each of them to a single space at deploy time. An executable value carrying one (e.g.
225
+ * `senior:\nfeature`) is therefore silently deployed as a DIFFERENT worker type (`senior: feature`),
226
+ * routing the cell to the wrong worker. Rejected — not normalized — for the same reason as
227
+ * {@link hasXmlInvalidChars}: an executable value must never be silently mutated. Deterministic and
228
+ * total. */
229
+ export function hasAttrNormalizedWhitespace(value: string): boolean {
230
+ return /[\t\n\r]/.test(value);
231
+ }
232
+
118
233
  /** True when `kind` is a member of the closed allowlist. */
119
234
  function isDeliveryNodeKind(kind: unknown): kind is DeliveryNodeKind {
120
235
  if (typeof kind !== "string") return false;
@@ -372,7 +487,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
372
487
  }
373
488
  // A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
374
489
  // end on the not-ready-at-boundary path, but the engine treats terminate-end events as
375
- // parsed-not-executed (Magikcraft/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
490
+ // parsed-not-executed (nanobpm/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
376
491
  // end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
377
492
  // until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
378
493
  // (default) and `continue` ARE honored.
@@ -385,6 +500,88 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
385
500
  code: "unsupported-on-timeout",
386
501
  });
387
502
  }
503
+ // A `wait` node's `poll.backoff` names a policy `parseProbe`→`parsePoll` validates against the
504
+ // closed `BACKOFFS` enum at DISPATCH: an unrecognised value (`"linear"`) THROWS there. But the
505
+ // compiler's display/digest path calls `normalizePoll` on the RAW graph, which silently maps every
506
+ // unrecognised backoff to `exponential` (the default). With the doc suppressing a default backoff
507
+ // and `digestInvisibleRawValues` only fingerprinting XML-strip differences (`"linear"` is XML-
508
+ // clean), a `backoff:"linear"` graph shares the omitted/default-poll graph's semantic digest+run
509
+ // key — so keyless dispatch can short-circuit the malformed proposal onto a valid running instance
510
+ // and mark it DISPATCHED instead of rejecting it. Enforce the SAME `isBackoff` contract here at the
511
+ // semantic boundary — trimming first, exactly like `parsePoll` (`str(raw.backoff).trim()`), so a
512
+ // padded-but-valid `" fixed "` the runtime accepts is not false-rejected — so a malformed backoff
513
+ // fails loudly at the preview/stage door BEFORE defaulting can mask it (issue #778 review — thread
514
+ // readiness.ts:432, same class as the `credentialEnv` semantic-boundary checks below).
515
+ if (kind === "wait" && isRecord(config.poll) && config.poll.backoff !== undefined && config.poll.backoff !== null) {
516
+ const backoff = typeof config.poll.backoff === "string" ? config.poll.backoff.trim() : String(config.poll.backoff).trim();
517
+ if (backoff !== "" && !isBackoff(backoff)) {
518
+ errors.push({
519
+ path: `${path}.${configKey}.poll.backoff`,
520
+ message:
521
+ `\`wait.poll.backoff\` must be one of ${BACKOFFS.join(", ")} (got ${JSON.stringify(config.poll.backoff)}); ` +
522
+ "`parsePoll` rejects an unrecognised backoff at dispatch while the compiler would silently default " +
523
+ "it — colliding the malformed graph's digest with a valid default-poll graph — so reject it here " +
524
+ "at the semantic boundary rather than letting a malformed proposal stage and be marked dispatched",
525
+ code: "invalid-backoff",
526
+ });
527
+ }
528
+ }
529
+ // A wait probe's `credentialEnv` names a DECLARED env-contract KEY (the secret is read from the
530
+ // ambient env at execution time, never carried here) — but `parseProbe` only enforces that
531
+ // `isEnvKey` contract LATER, at dispatch. The compiler surfaces the `credentialEnv` value verbatim
532
+ // into the probe's `<bpmn:documentation>`, so a text-ingress graph could stage
533
+ // `credentialEnv: "an-actual-secret"` and expose it in the compiled BPMN the preview door returns
534
+ // BEFORE dispatch rejects it. Enforce the SAME `isEnvKey` contract here at the semantic boundary so
535
+ // a non-key value can never reach the compiler (issue #778 review — thread
536
+ // deliveryGraphCompiler.ts:1249). `parseProbe` ALSO rejects a `credentialEnv` on any non-`http`
537
+ // probe kind (the credential selects an HTTP Authorization header — no other kind reads it), but
538
+ // only LATER, at dispatch: a shape-valid `command`/`npm`/`github-check`/`capability` graph
539
+ // carrying a well-formed `credentialEnv` key stages successfully and then THROWS during dispatch
540
+ // instead of returning a compile-time validation error. Enforce the SAME `http`-only contract here
541
+ // at the semantic boundary so the mismatch surfaces as a 400 from the preview/stage door rather
542
+ // than a dispatch-time incident (issue #778 review — thread deliveryGraph.ts:474).
543
+ // A PRESENT `credentialEnv` that is neither null/undefined nor a string can never name an
544
+ // env-contract key: `parseProbe` coerces + rejects it at DISPATCH, so a shape-valid graph carrying
545
+ // e.g. `credentialEnv: 123` stages successfully and only fails later with an unlaunchable proposal.
546
+ // Reject a non-string here at the semantic boundary so the mismatch surfaces as a 400 from the
547
+ // preview/stage door (issue #778 review — suppressed advisory deliveryGraph.ts:473).
548
+ if (kind === "wait" && config.credentialEnv !== undefined && config.credentialEnv !== null && typeof config.credentialEnv !== "string") {
549
+ errors.push({
550
+ path: `${path}.${configKey}.credentialEnv`,
551
+ message:
552
+ "`wait.credentialEnv` must be a STRING naming a declared env-contract key; a non-string value " +
553
+ "can never resolve to a key and `parseProbe` rejects it at dispatch, so reject it here rather " +
554
+ "than let a shape-invalid graph stage and then throw during dispatch",
555
+ code: "invalid-credential-env",
556
+ });
557
+ }
558
+ if (kind === "wait" && typeof config.credentialEnv === "string" && config.credentialEnv.trim().length > 0) {
559
+ // Compare the TRIMMED value: `parseProbe` normalises `credentialEnv` with `.trim()` before its
560
+ // own `isEnvKey`/kind checks (readiness.ts:287), so validating the raw value here would
561
+ // false-reject a padded-but-valid `" GITHUB_TOKEN "` the runtime accepts — keep validation and
562
+ // execution in agreement (issue #778 review — suppressed advisory deliveryGraph.ts:466).
563
+ const credentialEnv = config.credentialEnv.trim();
564
+ if (!isEnvKey(credentialEnv)) {
565
+ errors.push({
566
+ path: `${path}.${configKey}.credentialEnv`,
567
+ message:
568
+ "`wait.credentialEnv` must name a DECLARED env-contract key (never a secret value — the " +
569
+ "credential is read from the ambient env at execution time); an undeclared key is rejected " +
570
+ "so a secret can never be smuggled into the compiled BPMN the preview door returns",
571
+ code: "invalid-credential-env",
572
+ });
573
+ } else if (typeof config.kind === "string" && config.kind.trim() !== "http") {
574
+ errors.push({
575
+ path: `${path}.${configKey}.credentialEnv`,
576
+ message:
577
+ `\`credentialEnv\` is only supported for the \`http\` wait kind (probe kind is ` +
578
+ `${JSON.stringify(config.kind)}); \`parseProbe\` rejects this combination at dispatch, so ` +
579
+ "reject it here at the semantic boundary rather than letting a shape-valid graph stage and " +
580
+ "then throw during dispatch",
581
+ code: "invalid-credential-env",
582
+ });
583
+ }
584
+ }
388
585
  // S5 (ADR 0006 §3): converge/merge are first-class, edge-gated CELL POLICY, not raw nodes.
389
586
  // A raw converge/merge agent job (`senior:converge`, `senior:merge`, or a bare `converge`/
390
587
  // `merge` verb) is retired as user-facing vocabulary — reject it at compile so "a raw converge
@@ -401,6 +598,133 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
401
598
  code: "raw-converge-node",
402
599
  });
403
600
  }
601
+ // `agent.jobType` is baked VERBATIM into the executable `<zeebe:taskDefinition type=…>` attribute
602
+ // (and mirrored into `resolved.calledElement`), so — unlike a display string — the compiler must
603
+ // NOT let its attribute sanitiser silently strip an XML-1.0-invalid character out of it, NOR may
604
+ // it carry a whitespace character that XML attribute-value normalization folds to a space at
605
+ // deploy time: either would deploy a worker type differing from the authored job type (e.g.
606
+ // `senior:\u0001feature` → `senior:feature`, or `senior:\nfeature` → `senior: feature`), silently
607
+ // routing the cell to the wrong worker. Reject it here rather than rewrite/normalize an executable
608
+ // value (issue #778 review — same rationale as `guard-invalid-equals`).
609
+ if (
610
+ kind === "agent" &&
611
+ typeof config.jobType === "string" &&
612
+ (hasXmlInvalidChars(config.jobType) || hasAttrNormalizedWhitespace(config.jobType))
613
+ ) {
614
+ errors.push({
615
+ path: `${path}.${configKey}.jobType`,
616
+ message:
617
+ `\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} contains a character that would be silently ` +
618
+ "rewritten when emitted as the executable `<zeebe:taskDefinition type=…>` attribute — an " +
619
+ "XML-1.0-invalid character (control characters, U+FFFE/U+FFFF, or an unpaired surrogate) that " +
620
+ "the sanitiser strips, or attribute whitespace (tab, LF, CR) that XML attribute-value " +
621
+ "normalization folds to a space — so it must be rejected rather than silently rewritten into " +
622
+ "a different (wrong) worker type",
623
+ code: "invalid-job-type",
624
+ });
625
+ }
626
+ // A URL-shaped `agent.jobType` (`//user:pass@host`, `https://…`) is baked VERBATIM into the
627
+ // executable `<zeebe:taskDefinition type=…>` for worker routing, so — unlike a display string — it
628
+ // CANNOT be redacted in place (that would change the routing target). The compiler's display path
629
+ // redacts a URL-shaped jobType wherever it surfaces to an operator, but the raw executable value
630
+ // still lands in the compiled BPMN returned by the preview door, defeating that guarantee, and a
631
+ // URL is never a legitimate Zeebe routing key anyway. Reject it at the semantic boundary (the same
632
+ // "reject rather than rewrite an executable value" rule as `invalid-job-type` above) so a
633
+ // credential-bearing job type can never reach the compiler (issue #778 review — thread
634
+ // deliveryGraphCompiler.ts:1606). The message shows the REDACTED form so the 400 never echoes a
635
+ // credential.
636
+ if (kind === "agent" && typeof config.jobType === "string" && isUrlShaped(config.jobType)) {
637
+ errors.push({
638
+ path: `${path}.${configKey}.jobType`,
639
+ message:
640
+ `\`agent.jobType\` ${JSON.stringify(redactConnectorValue(config.jobType))} is URL-shaped — a URL is ` +
641
+ "never a valid worker-routing job type and, since the executable `<zeebe:taskDefinition type=…>` " +
642
+ "must carry it verbatim, a credential embedded in it would leak into the compiled BPMN. Use a " +
643
+ "plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a runtime " +
644
+ "job variable instead",
645
+ code: "url-shaped-job-type",
646
+ });
647
+ }
648
+ // The url-shaped check above is ANCHORED (a job type that STARTS with a URL). A credential-bearing
649
+ // URL can also be EMBEDDED after an otherwise plausible token (`senior:feature //user:pass@host`),
650
+ // separated by a plain space — which `hasAttrNormalizedWhitespace` (TAB/LF/CR only) and the anchored
651
+ // `isUrlShaped` both miss — so the `//user:pass@host` substring still lands verbatim in the
652
+ // executable `<zeebe:taskDefinition type=…>`. Reject ANY embedded `//…@` credential token (a
653
+ // routing key never contains one) at the same trust boundary via {@link hasEmbeddedCredential} — the
654
+ // SAME whitespace-tolerant `//…@` span the display redactor strips, so a userinfo carrying a literal
655
+ // space (`//user:secret pass@host`) can neither slip past this reject nor escape the display redact
656
+ // (issue #778 review — thread deliveryGraph.ts:550/570). Message redacted.
657
+ if (kind === "agent" && typeof config.jobType === "string" && hasEmbeddedCredential(config.jobType)) {
658
+ errors.push({
659
+ path: `${path}.${configKey}.jobType`,
660
+ message:
661
+ `\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} embeds a ` +
662
+ "credential-bearing URL userinfo token (`//<userinfo>@host` — the userinfo need not contain a " +
663
+ "colon; a passwordless `//token@host` bearer token counts) that a plain worker-routing job type " +
664
+ "never contains and that would land verbatim in the executable `<zeebe:taskDefinition type=…>`, " +
665
+ "leaking the credential into the compiled BPMN. Use a plain job-type token and carry any " +
666
+ "endpoint/credential as a runtime job variable instead",
667
+ code: "credential-in-job-type",
668
+ });
669
+ }
670
+ // The two checks above catch a WHOLE-value URL (anchored `isUrlShaped`) and a `//<userinfo>@`
671
+ // credential token, but a userinfo-LESS embedded absolute URL slips both: `senior:feature
672
+ // https://host/path?token=secret` starts with a plausible token (so the anchored check misses it)
673
+ // and has no `//…@` (so `hasEmbeddedCredential` misses it), yet its `?token=secret` query rides an
674
+ // explicit-scheme URL and, since the executable `<zeebe:taskDefinition type=…>` carries `jobType`
675
+ // VERBATIM, persists in the compiled BPMN even though `nodeDisplay` redacts the operator-visible
676
+ // descriptor. Reject ANY embedded absolute-URL token (a routing key never contains one) at this
677
+ // same trust boundary via {@link hasEmbeddedUrl} — the SAME `scheme://…` span the display redactor
678
+ // ({@link redactEmbeddedUrl}) strips (issue #778 review — thread deliveryGraph.ts:602). Message
679
+ // redacted.
680
+ if (kind === "agent" && typeof config.jobType === "string" && hasEmbeddedUrl(config.jobType)) {
681
+ errors.push({
682
+ path: `${path}.${configKey}.jobType`,
683
+ message:
684
+ `\`agent.jobType\` ${JSON.stringify(redactConnectorValue(config.jobType))} embeds an absolute URL ` +
685
+ "(`scheme://…`) whose `?query`/`#fragment` can hide a credential token that a plain " +
686
+ "worker-routing job type never contains and that, since the executable " +
687
+ "`<zeebe:taskDefinition type=…>` carries the job type verbatim, would land in the compiled BPMN. " +
688
+ "Use a plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a " +
689
+ "runtime job variable instead",
690
+ code: "embedded-url-in-job-type",
691
+ });
692
+ }
693
+ // The three checks above catch a whole-value URL (anchored `isUrlShaped`), a `//<userinfo>@`
694
+ // credential token, and an explicit-scheme `scheme://…` token — but a userinfo-LESS,
695
+ // schemeless embedded scheme-relative URL slips all three: `senior:feature //host?token=secret`
696
+ // starts with a plausible token (anchored `isUrlShaped` misses it), has no `//…@`
697
+ // (`hasEmbeddedCredential` misses it), and no explicit `scheme:` before the `//`
698
+ // (`hasEmbeddedUrl` misses it), yet its `?token=secret` query rides a `//host` authority and,
699
+ // since the executable `<zeebe:taskDefinition type=…>` carries the job type VERBATIM, persists in
700
+ // the compiled BPMN. A worker-routing job type has no legitimate need for `//` at all, so reject
701
+ // ANY embedded scheme-relative `//` authority token via {@link hasSchemeRelativeAuthority} — the
702
+ // residual gap the three checks above leave (issue #778 review — thread deliveryGraph.ts:633).
703
+ // Message redacted via `redactString` (strips `//…@` userinfo AND `?query`/`#fragment`) so the
704
+ // 400 never echoes a credential — `redactConnectorValue` also now redacts this token, but a
705
+ // schemeless `//host` bare authority would still surface here, so use the total `redactString`.
706
+ // Gated behind the three checks above (each of which already REJECTS its shape with a tailored
707
+ // code) so a value they catch is not double-reported — this fires ONLY for the residual
708
+ // userinfo-less, schemeless embedded scheme-relative URL they all miss.
709
+ if (
710
+ kind === "agent" &&
711
+ typeof config.jobType === "string" &&
712
+ !isUrlShaped(config.jobType) &&
713
+ !hasEmbeddedCredential(config.jobType) &&
714
+ !hasEmbeddedUrl(config.jobType) &&
715
+ hasSchemeRelativeAuthority(config.jobType)
716
+ ) {
717
+ errors.push({
718
+ path: `${path}.${configKey}.jobType`,
719
+ message:
720
+ `\`agent.jobType\` ${JSON.stringify(redactString(stripXmlInvalidChars(config.jobType)))} embeds a scheme-relative ` +
721
+ "URL authority (`//…`) — a routing key never contains `//`, and its `?query`/`#fragment` can hide a credential " +
722
+ "token that, since the executable `<zeebe:taskDefinition type=…>` carries the job type verbatim, would land in the " +
723
+ "compiled BPMN. Use a plain job-type token (e.g. `senior:feature`) and carry any endpoint/credential as a runtime " +
724
+ "job variable instead",
725
+ code: "scheme-relative-url-in-job-type",
726
+ });
727
+ }
404
728
  // S5 trust boundary: `validateDeliveryGraph` is the gate before `dispatchDeliveryGraphRun`
405
729
  // narrows `graph: unknown` with `as DeliveryGraph`, so a graph that bypassed OpenAPI validation
406
730
  // must not smuggle a non-boolean `converge`/`merge` past the policy checks below (which compare
@@ -925,6 +1249,22 @@ function validateGuardedEdges(
925
1249
  });
926
1250
  continue;
927
1251
  }
1252
+ // A string `equals` is baked VERBATIM into the compiled `<bpmn:conditionExpression>` FEEL literal,
1253
+ // so an XML-1.0-invalid character in it (a C0 control, U+FFFE/U+FFFF, a lone surrogate) cannot be
1254
+ // entity-escaped and the compiler's display-text sanitiser would silently STRIP it — mutating
1255
+ // executable FEEL (e.g. `"a\uFFFEb"` → `"ab"`) and potentially routing the split down the wrong
1256
+ // edge. Reject it here rather than silently rewrite the guard (issue #778 review).
1257
+ if (typeof e.equals === "string" && hasXmlInvalidChars(e.equals)) {
1258
+ errors.push({
1259
+ path: `${path}.equals`,
1260
+ message:
1261
+ `guard \`equals\` for "${whenStr}" contains XML-1.0-invalid characters (control characters, ` +
1262
+ "U+FFFE/U+FFFF, or an unpaired surrogate) that cannot be represented in the compiled FEEL " +
1263
+ "condition — remove them rather than let the guard be silently rewritten",
1264
+ code: "guard-invalid-equals",
1265
+ });
1266
+ continue;
1267
+ }
928
1268
  guardFactTypeByIndex.set(e.index, factType);
929
1269
  }
930
1270
 
@@ -1215,3 +1555,18 @@ export function resolveDeliveryFrom(
1215
1555
  const { nodeId, fact } = resolveFrom(from, nodeFacts);
1216
1556
  return fact !== undefined ? { nodeId, fact } : { nodeId };
1217
1557
  }
1558
+
1559
+ /** A DETERMINISTIC canonical JSON serialization: object keys sorted recursively while ARRAY order is
1560
+ * preserved (object key order and insignificant whitespace are not semantic). Two byte-different-but-
1561
+ * equivalent encodings (reordered keys, reflowed whitespace) serialize identically, while any genuine
1562
+ * value difference still diverges. Shared single source of truth (issue #778 review): the dispatch
1563
+ * run-key hashes it and the compiler's `payload` disambiguator canonicalises with it, so neither can
1564
+ * drift from the other. */
1565
+ export function canonicalJson(value: unknown): string {
1566
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
1567
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
1568
+ return `{${Object.entries(value)
1569
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1570
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)
1571
+ .join(",")}}`;
1572
+ }