@nanobpm/urban 0.89.0 → 0.90.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.
@@ -202,6 +202,18 @@ export interface ProcessInstanceSnapshot {
202
202
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
203
203
  * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
204
204
  readonly processDefinitionKey?: string;
205
+ /** The key of this instance's *immediate* parent process instance — the caller that instantiated
206
+ * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
207
+ * and absent when the engine omits it (same optional/engine-provided convention as
208
+ * `processDefinitionKey`). Lets a reduced-capability read path map a native child back to the
209
+ * subject that spawned it without a raw-REST channel. */
210
+ readonly parentProcessInstanceKey?: string;
211
+ /** The key of this instance's *root* process instance — the top-level ancestor of the
212
+ * call-activity hierarchy this instance belongs to (equal to `processInstanceKey` for a
213
+ * top-level instance). `undefined`/absent when the engine omits it (only present for hierarchies
214
+ * created on an engine version that reports it). Lets a reduced-capability read path correlate a
215
+ * deep native descendant straight back to its root subject. */
216
+ readonly rootProcessInstanceKey?: string;
205
217
  }
206
218
  /**
207
219
  * A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
@@ -219,6 +231,24 @@ export interface UserTaskSummary {
219
231
  readonly formKey?: string;
220
232
  /** An external form reference (a form linked outside the deployment), if any. */
221
233
  readonly externalFormReference?: string;
234
+ /** The key of the process instance this task belongs to. Absent when the engine omits it (same
235
+ * optional/engine-provided convention as `processDefinitionKey`). For a task owned by a native
236
+ * child instance, this is the child's own key — the handle a reduced-capability read path feeds
237
+ * to {@link EngineClient.searchProcessInstances} to resolve the child's parent/root linkage. */
238
+ readonly processInstanceKey?: string;
239
+ /** The key of the *immediate* parent process instance of this task's owning instance — the caller
240
+ * that spawned it as a native child. Absent when the engine's user-task read model omits it (as
241
+ * it does today: the engine surfaces the parent linkage on {@link ProcessInstanceSnapshot}, so a
242
+ * caller resolves it via `processInstanceKey` → `searchProcessInstances`). Declared here for
243
+ * parity with the process-instance snapshot and to flow through automatically if the read model
244
+ * starts carrying it. */
245
+ readonly parentProcessInstanceKey?: string;
246
+ /** The key of the *root* process instance of this task's owning instance — the top-level ancestor
247
+ * of the call-activity hierarchy the task lives in. Absent when the engine omits it. Lets a
248
+ * reduced-capability read path (`pollUserTasks` and other reconcile/affordance surfaces)
249
+ * correlate a native-child (e.g. human-escalation grandchild) task straight back to its root
250
+ * subject through the typed seam, with no raw-REST fallback. */
251
+ readonly rootProcessInstanceKey?: string;
222
252
  }
223
253
  /**
224
254
  * The lifecycle-state-agnostic selectors for a user-task search: which process instance,
@@ -230,6 +260,14 @@ export interface UserTaskFilter {
230
260
  processInstanceKey?: string;
231
261
  assignee?: string;
232
262
  candidateGroup?: string;
263
+ /** Select only tasks whose owning instance has this *immediate* parent process instance. Honored
264
+ * server-side by the live gateway where the read model supports it; the in-process testkit
265
+ * applies it client-side against the row's parent linkage (absent today on the user-task read
266
+ * model, so it matches nothing until the engine surfaces it there). */
267
+ parentProcessInstanceKey?: string;
268
+ /** Select only tasks whose owning instance belongs to this *root* process-instance hierarchy — the
269
+ * selector a reduced-capability path uses to fetch every open task under one root subject. */
270
+ rootProcessInstanceKey?: string;
233
271
  }
234
272
  /**
235
273
  * An element instance's lifecycle state, as the engine reports it on a
@@ -582,6 +620,12 @@ export interface EngineClient {
582
620
  searchProcessInstances(filter?: {
583
621
  processInstanceKeys?: string[];
584
622
  state?: ProcessInstanceState;
623
+ /** Select only instances whose *immediate* parent is this process instance — the reduced-path
624
+ * query for "the native children spawned by this subject". */
625
+ parentProcessInstanceKey?: string;
626
+ /** Select only instances belonging to this *root* process-instance hierarchy — "every instance
627
+ * under this root subject", the deep-descendant correlation query. */
628
+ rootProcessInstanceKey?: string;
585
629
  }): Promise<ProcessInstanceSnapshot[]>;
586
630
  /**
587
631
  * Search element instances ("tokens") by process instance, element, and/or lifecycle state
@@ -236,6 +236,8 @@ export declare class SdkEngineClient implements EngineClient {
236
236
  searchProcessInstances(filter?: {
237
237
  processInstanceKeys?: string[];
238
238
  state?: ProcessInstanceState;
239
+ parentProcessInstanceKey?: string;
240
+ rootProcessInstanceKey?: string;
239
241
  }): Promise<ProcessInstanceSnapshot[]>;
240
242
  searchElementInstances(filter?: ElementInstanceFilter): Promise<ElementInstanceSummary[]>;
241
243
  searchElementInstanceWaitStates(filter?: ElementInstanceWaitStateFilter): Promise<ElementInstanceWaitState[]>;
@@ -465,7 +465,20 @@ export class SdkEngineClient {
465
465
  async searchUserTasks(filter) {
466
466
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
467
467
  // the search reflects what is currently visible without blocking.
468
- const body = await this.client.searchUserTasks({ filter: { ...(filter ?? {}) } }, { consistency: { waitUpToMs: 0 } });
468
+ // Normalize the process-instance key selectors through the shared presence rule so a
469
+ // blank/whitespace-only selector is *dropped* rather than forwarded as a present filter that
470
+ // silently matches nothing, and a padded key (`" 123 "`) matches the normalized keys we
471
+ // surface — mirroring `WasmEngineClient` (which normalizes via `presentKey`) and
472
+ // `searchProcessInstances` below (No Drift Surfaces).
473
+ const f = { ...(filter ?? {}) };
474
+ for (const sel of ["processInstanceKey", "parentProcessInstanceKey", "rootProcessInstanceKey"]) {
475
+ const normalized = presentEngineKey(filter?.[sel]);
476
+ if (normalized)
477
+ f[sel] = normalized;
478
+ else
479
+ delete f[sel];
480
+ }
481
+ const body = await this.client.searchUserTasks({ filter: f }, { consistency: { waitUpToMs: 0 } });
469
482
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
470
483
  return items.flatMap((it) => {
471
484
  const userTaskKey = it.userTaskKey ?? it.key;
@@ -476,10 +489,16 @@ export class SdkEngineClient {
476
489
  // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
477
490
  // task creation, so `formKey` (not a form id) is the linkage it reports on the task;
478
491
  // no `resolveFormKeyByFormId` resolver is passed because the gateway already resolved it.
492
+ const processInstanceKey = presentEngineKey(it.processInstanceKey);
493
+ const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
494
+ const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
479
495
  return [{
480
496
  userTaskKey: String(userTaskKey),
481
497
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
482
498
  variables: isRecord(it.variables) ? it.variables : undefined,
499
+ ...(processInstanceKey ? { processInstanceKey } : {}),
500
+ ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
501
+ ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
483
502
  ...pickFormLinkage(it),
484
503
  }];
485
504
  });
@@ -545,9 +564,24 @@ export class SdkEngineClient {
545
564
  const f = {};
546
565
  if (filter?.state)
547
566
  f.state = filter.state;
548
- const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
567
+ // Normalize each requested key through the shared presence rule (drop blank/whitespace-only
568
+ // entries, trim padded ones like `" 123 "`) so the `$in` filter carries only present keys that
569
+ // match the normalized keys we surface — mirroring `WasmEngineClient` and the selector
570
+ // handling above (No Drift Surfaces).
571
+ const keys = filter?.processInstanceKeys
572
+ ?.map((k) => presentEngineKey(k))
573
+ .filter((k) => k !== undefined);
549
574
  if (keys && keys.length > 0)
550
575
  f.processInstanceKey = { $in: keys };
576
+ // Parent/root selectors let the reduced path query a subject's native descendants
577
+ // server-side. Normalize each through the shared presence rule so a blank selector is
578
+ // dropped rather than sent as an empty filter that would match nothing.
579
+ const parentProcessInstanceKey = presentEngineKey(filter?.parentProcessInstanceKey);
580
+ if (parentProcessInstanceKey)
581
+ f.parentProcessInstanceKey = parentProcessInstanceKey;
582
+ const rootProcessInstanceKey = presentEngineKey(filter?.rootProcessInstanceKey);
583
+ if (rootProcessInstanceKey)
584
+ f.rootProcessInstanceKey = rootProcessInstanceKey;
551
585
  // A process-instance search is an eventually consistent read; ask for zero-wait
552
586
  // consistency so it reflects what is currently visible without blocking. Cap the page
553
587
  // to the number of keys asked for (each key matches at most one instance), so a bounded
@@ -556,24 +590,32 @@ export class SdkEngineClient {
556
590
  const body = await this.client.searchProcessInstances({ filter: f, ...(page ? { page } : {}) }, { consistency: { waitUpToMs: 0 } });
557
591
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
558
592
  return items.flatMap((it) => {
559
- const key = it.processInstanceKey;
560
- if (key == null || key === "") {
593
+ // Normalize the surfaced primary key through the shared presence rule (trim padded,
594
+ // drop blank/whitespace-only) so it matches the normalized parent/root linkage keys
595
+ // and the `searchUserTasks` mapping — the surfaced process-instance key must not drift
596
+ // between the two reduced-path searches (No Drift Surfaces).
597
+ const processInstanceKey = presentEngineKey(it.processInstanceKey);
598
+ if (!processInstanceKey) {
561
599
  this.log("warn", "skipping process instance with no key in engine response");
562
600
  return [];
563
601
  }
564
602
  const state = normalizeProcessInstanceState(it.state);
565
603
  if (!state) {
566
604
  this.log("warn", "skipping process instance with unrecognized state", {
567
- processInstanceKey: String(key),
605
+ processInstanceKey,
568
606
  state: String(it.state),
569
607
  });
570
608
  return [];
571
609
  }
572
610
  const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
611
+ const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
612
+ const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
573
613
  return [{
574
- processInstanceKey: String(key),
614
+ processInstanceKey,
575
615
  state,
576
616
  ...(processDefinitionKey ? { processDefinitionKey } : {}),
617
+ ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
618
+ ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
577
619
  }];
578
620
  });
579
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -233,6 +233,18 @@ export interface ProcessInstanceSnapshot {
233
233
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
234
234
  * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
235
235
  readonly processDefinitionKey?: string;
236
+ /** The key of this instance's *immediate* parent process instance — the caller that instantiated
237
+ * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
238
+ * and absent when the engine omits it (same optional/engine-provided convention as
239
+ * `processDefinitionKey`). Lets a reduced-capability read path map a native child back to the
240
+ * subject that spawned it without a raw-REST channel. */
241
+ readonly parentProcessInstanceKey?: string;
242
+ /** The key of this instance's *root* process instance — the top-level ancestor of the
243
+ * call-activity hierarchy this instance belongs to (equal to `processInstanceKey` for a
244
+ * top-level instance). `undefined`/absent when the engine omits it (only present for hierarchies
245
+ * created on an engine version that reports it). Lets a reduced-capability read path correlate a
246
+ * deep native descendant straight back to its root subject. */
247
+ readonly rootProcessInstanceKey?: string;
236
248
  }
237
249
 
238
250
  /**
@@ -251,6 +263,24 @@ export interface UserTaskSummary {
251
263
  readonly formKey?: string;
252
264
  /** An external form reference (a form linked outside the deployment), if any. */
253
265
  readonly externalFormReference?: string;
266
+ /** The key of the process instance this task belongs to. Absent when the engine omits it (same
267
+ * optional/engine-provided convention as `processDefinitionKey`). For a task owned by a native
268
+ * child instance, this is the child's own key — the handle a reduced-capability read path feeds
269
+ * to {@link EngineClient.searchProcessInstances} to resolve the child's parent/root linkage. */
270
+ readonly processInstanceKey?: string;
271
+ /** The key of the *immediate* parent process instance of this task's owning instance — the caller
272
+ * that spawned it as a native child. Absent when the engine's user-task read model omits it (as
273
+ * it does today: the engine surfaces the parent linkage on {@link ProcessInstanceSnapshot}, so a
274
+ * caller resolves it via `processInstanceKey` → `searchProcessInstances`). Declared here for
275
+ * parity with the process-instance snapshot and to flow through automatically if the read model
276
+ * starts carrying it. */
277
+ readonly parentProcessInstanceKey?: string;
278
+ /** The key of the *root* process instance of this task's owning instance — the top-level ancestor
279
+ * of the call-activity hierarchy the task lives in. Absent when the engine omits it. Lets a
280
+ * reduced-capability read path (`pollUserTasks` and other reconcile/affordance surfaces)
281
+ * correlate a native-child (e.g. human-escalation grandchild) task straight back to its root
282
+ * subject through the typed seam, with no raw-REST fallback. */
283
+ readonly rootProcessInstanceKey?: string;
254
284
  }
255
285
 
256
286
  /**
@@ -263,6 +293,14 @@ export interface UserTaskFilter {
263
293
  processInstanceKey?: string;
264
294
  assignee?: string;
265
295
  candidateGroup?: string;
296
+ /** Select only tasks whose owning instance has this *immediate* parent process instance. Honored
297
+ * server-side by the live gateway where the read model supports it; the in-process testkit
298
+ * applies it client-side against the row's parent linkage (absent today on the user-task read
299
+ * model, so it matches nothing until the engine surfaces it there). */
300
+ parentProcessInstanceKey?: string;
301
+ /** Select only tasks whose owning instance belongs to this *root* process-instance hierarchy — the
302
+ * selector a reduced-capability path uses to fetch every open task under one root subject. */
303
+ rootProcessInstanceKey?: string;
266
304
  }
267
305
 
268
306
  /**
@@ -636,6 +674,12 @@ export interface EngineClient {
636
674
  searchProcessInstances(filter?: {
637
675
  processInstanceKeys?: string[];
638
676
  state?: ProcessInstanceState;
677
+ /** Select only instances whose *immediate* parent is this process instance — the reduced-path
678
+ * query for "the native children spawned by this subject". */
679
+ parentProcessInstanceKey?: string;
680
+ /** Select only instances belonging to this *root* process-instance hierarchy — "every instance
681
+ * under this root subject", the deep-descendant correlation query. */
682
+ rootProcessInstanceKey?: string;
639
683
  }): Promise<ProcessInstanceSnapshot[]>;
640
684
  /**
641
685
  * Search element instances ("tokens") by process instance, element, and/or lifecycle state
@@ -315,6 +315,72 @@ test("searchUserTasks surfaces the resolved form linkage", async () => {
315
315
  ]);
316
316
  });
317
317
 
318
+ test("searchUserTasks normalizes the process-instance key selectors and drops blank ones", async () => {
319
+ // Guards the selector-normalization defect class: a padded selector (`" 42 "`) must reach the
320
+ // engine trimmed (so it matches the normalized keys the adapter surfaces), a blank/whitespace-only
321
+ // selector must be *dropped* (not forwarded as a present filter that silently matches nothing),
322
+ // and unrelated selectors must pass through untouched — mirroring `searchProcessInstances`.
323
+ let input: { filter?: Record<string, unknown> } | undefined;
324
+ const client = fakeSdkClient({
325
+ searchUserTasks: async (i) => {
326
+ input = i;
327
+ return { items: [] };
328
+ },
329
+ });
330
+ const engine = new SdkEngineClient(client);
331
+ await engine.searchUserTasks({
332
+ processInstanceKey: " 42 ", // padded → trimmed to "42"
333
+ parentProcessInstanceKey: " ", // whitespace-only → dropped
334
+ rootProcessInstanceKey: "", // blank → dropped
335
+ assignee: "alice", // unrelated selector passes through unchanged
336
+ });
337
+ const filter = input?.filter ?? {};
338
+ assert.equal(filter.processInstanceKey, "42", "padded key selector is trimmed");
339
+ assert.equal("parentProcessInstanceKey" in filter, false, "whitespace-only parent selector is dropped");
340
+ assert.equal("rootProcessInstanceKey" in filter, false, "blank root selector is dropped");
341
+ assert.equal(filter.assignee, "alice", "unrelated selectors pass through unchanged");
342
+ });
343
+
344
+ test("searchUserTasks surfaces the process-instance linkage keys and drops blank ones", async () => {
345
+ // Guards the linkage-output defect class: the three linkage keys the engine reports on a task
346
+ // (processInstanceKey/parentProcessInstanceKey/rootProcessInstanceKey) must be surfaced on the
347
+ // returned `UserTaskSummary`, normalized through the same presence rule — a padded value trimmed,
348
+ // a blank/whitespace-only value omitted rather than surfaced as an empty string.
349
+ const client = fakeSdkClient({
350
+ searchUserTasks: async () => ({
351
+ items: [
352
+ {
353
+ userTaskKey: 1,
354
+ elementId: "a",
355
+ processInstanceKey: 10,
356
+ parentProcessInstanceKey: 20,
357
+ rootProcessInstanceKey: 30,
358
+ },
359
+ {
360
+ userTaskKey: 2,
361
+ elementId: "b",
362
+ processInstanceKey: " 11 ", // padded → trimmed
363
+ parentProcessInstanceKey: " ", // whitespace-only → omitted
364
+ rootProcessInstanceKey: "", // blank → omitted
365
+ },
366
+ ],
367
+ }),
368
+ });
369
+ const engine = new SdkEngineClient(client);
370
+ const tasks = await engine.searchUserTasks();
371
+ assert.deepEqual(tasks, [
372
+ {
373
+ userTaskKey: "1",
374
+ elementId: "a",
375
+ variables: undefined,
376
+ processInstanceKey: "10",
377
+ parentProcessInstanceKey: "20",
378
+ rootProcessInstanceKey: "30",
379
+ },
380
+ { userTaskKey: "2", elementId: "b", variables: undefined, processInstanceKey: "11" },
381
+ ]);
382
+ });
383
+
318
384
  test("getForm resolves by formKey and parses the serialized schema", async () => {
319
385
  let seen: unknown;
320
386
  const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name" }] };
@@ -486,6 +552,64 @@ test("searchProcessInstances skips keyless and unrecognized-state rows", async (
486
552
  assert.deepEqual(out, [{ processInstanceKey: "10", state: "COMPLETED" }]);
487
553
  });
488
554
 
555
+ test("searchProcessInstances normalizes the parent/root key selectors and drops blank ones", async () => {
556
+ // Guards the request-mapping defect class for the reduced path's parent/root selectors: a padded
557
+ // selector must be trimmed, a blank/whitespace-only selector must be dropped rather than sent as
558
+ // an empty filter that matches nothing — mirroring `searchUserTasks` (No Drift Surfaces).
559
+ let seenInput: { filter?: Record<string, unknown> } | undefined;
560
+ const client = fakeSdkClient({
561
+ searchProcessInstances: async (input) => {
562
+ seenInput = input;
563
+ return { items: [] };
564
+ },
565
+ });
566
+ const engine = new SdkEngineClient(client);
567
+ await engine.searchProcessInstances({
568
+ parentProcessInstanceKey: " 42 ", // padded → trimmed to "42"
569
+ rootProcessInstanceKey: " ", // whitespace-only → dropped
570
+ });
571
+ const filter = seenInput?.filter ?? {};
572
+ assert.equal(filter.parentProcessInstanceKey, "42", "padded parent selector is trimmed");
573
+ assert.equal("rootProcessInstanceKey" in filter, false, "whitespace-only root selector is dropped");
574
+ // A selector-only search carries no key `$in` and no page cap.
575
+ assert.deepEqual(seenInput, { filter: { parentProcessInstanceKey: "42" } });
576
+ });
577
+
578
+ test("searchProcessInstances surfaces the parent/root linkage keys and drops blank ones", async () => {
579
+ // Guards the linkage-output defect class: the parent/root keys the engine reports on an instance
580
+ // must be surfaced on the returned `ProcessInstanceSnapshot`, normalized through the same presence
581
+ // rule — a padded value trimmed, a blank/whitespace-only value omitted rather than an empty string.
582
+ const client = fakeSdkClient({
583
+ searchProcessInstances: async () => ({
584
+ items: [
585
+ {
586
+ processInstanceKey: 10,
587
+ state: "ACTIVE",
588
+ parentProcessInstanceKey: 20,
589
+ rootProcessInstanceKey: 30,
590
+ },
591
+ {
592
+ processInstanceKey: " 11 ", // padded → trimmed
593
+ state: "COMPLETED",
594
+ parentProcessInstanceKey: " ", // whitespace-only → omitted
595
+ rootProcessInstanceKey: "", // blank → omitted
596
+ },
597
+ ],
598
+ }),
599
+ });
600
+ const engine = new SdkEngineClient(client);
601
+ const out = await engine.searchProcessInstances();
602
+ assert.deepEqual(out, [
603
+ {
604
+ processInstanceKey: "10",
605
+ state: "ACTIVE",
606
+ parentProcessInstanceKey: "20",
607
+ rootProcessInstanceKey: "30",
608
+ },
609
+ { processInstanceKey: "11", state: "COMPLETED" },
610
+ ]);
611
+ });
612
+
489
613
  test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
490
614
  assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
491
615
  assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
@@ -677,8 +677,19 @@ export class SdkEngineClient implements EngineClient {
677
677
  }): Promise<UserTaskSummary[]> {
678
678
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
679
679
  // the search reflects what is currently visible without blocking.
680
+ // Normalize the process-instance key selectors through the shared presence rule so a
681
+ // blank/whitespace-only selector is *dropped* rather than forwarded as a present filter that
682
+ // silently matches nothing, and a padded key (`" 123 "`) matches the normalized keys we
683
+ // surface — mirroring `WasmEngineClient` (which normalizes via `presentKey`) and
684
+ // `searchProcessInstances` below (No Drift Surfaces).
685
+ const f: Record<string, unknown> = { ...(filter ?? {}) };
686
+ for (const sel of ["processInstanceKey", "parentProcessInstanceKey", "rootProcessInstanceKey"] as const) {
687
+ const normalized = presentEngineKey(filter?.[sel]);
688
+ if (normalized) f[sel] = normalized;
689
+ else delete f[sel];
690
+ }
680
691
  const body = await this.client.searchUserTasks(
681
- { filter: { ...(filter ?? {}) } },
692
+ { filter: f },
682
693
  { consistency: { waitUpToMs: 0 } },
683
694
  );
684
695
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
@@ -691,10 +702,16 @@ export class SdkEngineClient implements EngineClient {
691
702
  // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
692
703
  // task creation, so `formKey` (not a form id) is the linkage it reports on the task;
693
704
  // no `resolveFormKeyByFormId` resolver is passed because the gateway already resolved it.
705
+ const processInstanceKey = presentEngineKey(it.processInstanceKey);
706
+ const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
707
+ const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
694
708
  return [{
695
709
  userTaskKey: String(userTaskKey),
696
710
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
697
711
  variables: isRecord(it.variables) ? it.variables : undefined,
712
+ ...(processInstanceKey ? { processInstanceKey } : {}),
713
+ ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
714
+ ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
698
715
  ...pickFormLinkage(it),
699
716
  }];
700
717
  });
@@ -763,11 +780,26 @@ export class SdkEngineClient implements EngineClient {
763
780
  async searchProcessInstances(filter?: {
764
781
  processInstanceKeys?: string[];
765
782
  state?: ProcessInstanceState;
783
+ parentProcessInstanceKey?: string;
784
+ rootProcessInstanceKey?: string;
766
785
  }): Promise<ProcessInstanceSnapshot[]> {
767
786
  const f: Record<string, unknown> = {};
768
787
  if (filter?.state) f.state = filter.state;
769
- const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
788
+ // Normalize each requested key through the shared presence rule (drop blank/whitespace-only
789
+ // entries, trim padded ones like `" 123 "`) so the `$in` filter carries only present keys that
790
+ // match the normalized keys we surface — mirroring `WasmEngineClient` and the selector
791
+ // handling above (No Drift Surfaces).
792
+ const keys = filter?.processInstanceKeys
793
+ ?.map((k) => presentEngineKey(k))
794
+ .filter((k): k is string => k !== undefined);
770
795
  if (keys && keys.length > 0) f.processInstanceKey = { $in: keys };
796
+ // Parent/root selectors let the reduced path query a subject's native descendants
797
+ // server-side. Normalize each through the shared presence rule so a blank selector is
798
+ // dropped rather than sent as an empty filter that would match nothing.
799
+ const parentProcessInstanceKey = presentEngineKey(filter?.parentProcessInstanceKey);
800
+ if (parentProcessInstanceKey) f.parentProcessInstanceKey = parentProcessInstanceKey;
801
+ const rootProcessInstanceKey = presentEngineKey(filter?.rootProcessInstanceKey);
802
+ if (rootProcessInstanceKey) f.rootProcessInstanceKey = rootProcessInstanceKey;
771
803
  // A process-instance search is an eventually consistent read; ask for zero-wait
772
804
  // consistency so it reflects what is currently visible without blocking. Cap the page
773
805
  // to the number of keys asked for (each key matches at most one instance), so a bounded
@@ -779,24 +811,32 @@ export class SdkEngineClient implements EngineClient {
779
811
  );
780
812
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
781
813
  return items.flatMap((it) => {
782
- const key = it.processInstanceKey;
783
- if (key == null || key === "") {
814
+ // Normalize the surfaced primary key through the shared presence rule (trim padded,
815
+ // drop blank/whitespace-only) so it matches the normalized parent/root linkage keys
816
+ // and the `searchUserTasks` mapping — the surfaced process-instance key must not drift
817
+ // between the two reduced-path searches (No Drift Surfaces).
818
+ const processInstanceKey = presentEngineKey(it.processInstanceKey);
819
+ if (!processInstanceKey) {
784
820
  this.log("warn", "skipping process instance with no key in engine response");
785
821
  return [];
786
822
  }
787
823
  const state = normalizeProcessInstanceState(it.state);
788
824
  if (!state) {
789
825
  this.log("warn", "skipping process instance with unrecognized state", {
790
- processInstanceKey: String(key),
826
+ processInstanceKey,
791
827
  state: String(it.state),
792
828
  });
793
829
  return [];
794
830
  }
795
831
  const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
832
+ const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
833
+ const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
796
834
  return [{
797
- processInstanceKey: String(key),
835
+ processInstanceKey,
798
836
  state,
799
837
  ...(processDefinitionKey ? { processDefinitionKey } : {}),
838
+ ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
839
+ ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
800
840
  }];
801
841
  });
802
842
  }