@nanobpm/urban 0.95.0 → 0.96.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.
@@ -216,14 +216,33 @@ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
216
216
  * never surface in the `taskInbox` as if it were actionable.
217
217
  */
218
218
  export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
219
- /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
220
- export interface ProcessInstanceSnapshot {
221
- readonly processInstanceKey: string;
222
- readonly state: ProcessInstanceState;
219
+ /**
220
+ * Which deployed process definition an instance runs — shared by the instance a
221
+ * {@link EngineClient.createInstance} starts and the {@link ProcessInstanceSnapshot} a
222
+ * {@link EngineClient.searchProcessInstances} reports, so the two describe the same instance
223
+ * identically. Each field is absent when the engine omits it (or reports it blank/malformed).
224
+ */
225
+ export interface ProcessDefinitionIdentity {
223
226
  /** The key of the deployed process definition this instance runs — the handle a caller feeds to
224
227
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
225
- * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
228
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Unique per deployed version. */
226
229
  readonly processDefinitionKey?: string;
230
+ /** The BPMN process id (`<bpmn:process id>`) — stable across versions; the selector
231
+ * {@link EngineClient.searchProcessInstances} accepts as `processDefinitionId`. */
232
+ readonly processDefinitionId?: string;
233
+ /** The deployed version of `processDefinitionId` this instance runs (1-based). */
234
+ readonly processDefinitionVersion?: number;
235
+ }
236
+ /** The instance {@link EngineClient.createInstance} started: its key, the exact deployed
237
+ * definition version it runs, and — when `awaitCompletion` was requested — its final variables. */
238
+ export interface CreatedProcessInstance extends ProcessDefinitionIdentity {
239
+ readonly processInstanceKey: string;
240
+ readonly variables?: Record<string, unknown>;
241
+ }
242
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
243
+ export interface ProcessInstanceSnapshot extends ProcessDefinitionIdentity {
244
+ readonly processInstanceKey: string;
245
+ readonly state: ProcessInstanceState;
227
246
  /** The key of this instance's *immediate* parent process instance — the caller that instantiated
228
247
  * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
229
248
  * and absent when the engine omits it (same optional/engine-provided convention as
@@ -664,15 +683,14 @@ export interface EngineClient {
664
683
  }[]): Promise<{
665
684
  deployed: number;
666
685
  }>;
667
- /** Start a process instance. */
686
+ /** Start a process instance of the latest deployed version of `processDefinitionId`. Returns
687
+ * the new instance's key plus the {@link ProcessDefinitionIdentity} (key, id, version) of the
688
+ * definition it actually runs. */
668
689
  createInstance(input: {
669
690
  processDefinitionId: string;
670
691
  variables?: Record<string, unknown>;
671
692
  awaitCompletion?: boolean;
672
- }): Promise<{
673
- processInstanceKey: string;
674
- variables?: Record<string, unknown>;
675
- }>;
693
+ }): Promise<CreatedProcessInstance>;
676
694
  /** Cancel a running process instance (the pages surface's row-cancel action). */
677
695
  cancelInstance(input: {
678
696
  processInstanceKey: string;
@@ -725,7 +743,8 @@ export interface EngineClient {
725
743
  /** Complete a user task. */
726
744
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
727
745
  /**
728
- * Search process instances by key and/or lifecycle state. The instance-tracking
746
+ * Search process instances by key, BPMN process id (`processDefinitionId`, any version),
747
+ * and/or lifecycle state. The instance-tracking
729
748
  * reconciler uses this to detect a tracked instance reaching a terminal state
730
749
  * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
731
750
  * read-model row it backs would otherwise stay "active" forever. An eventually
@@ -735,6 +754,9 @@ export interface EngineClient {
735
754
  searchProcessInstances(filter?: {
736
755
  processInstanceKeys?: string[];
737
756
  state?: ProcessInstanceState;
757
+ /** Select only instances of this BPMN process id (every deployed version). A blank or
758
+ * whitespace-only value is treated as absent (no filter). */
759
+ processDefinitionId?: string;
738
760
  /** Select only instances whose *immediate* parent is this process instance — the reduced-path
739
761
  * query for "the native children spawned by this subject". */
740
762
  parentProcessInstanceKey?: string;
@@ -428,11 +428,12 @@ function buildDebugTools(app) {
428
428
  return [
429
429
  {
430
430
  name: `${DEBUG_PREFIX}search_process_instances`,
431
- description: "Engine truth: search process instances by key and/or lifecycle state.",
431
+ description: "Engine truth: search process instances by key, BPMN process id (processDefinitionId, any version), and/or lifecycle state.",
432
432
  inputSchema: {
433
433
  type: "object",
434
434
  properties: {
435
435
  processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
436
+ processDefinitionId: OPTIONAL_STRING,
436
437
  state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
437
438
  },
438
439
  },
@@ -444,6 +445,9 @@ function buildDebugTools(app) {
444
445
  const state = readProcessInstanceState(args, "state");
445
446
  if (state)
446
447
  filter.state = state;
448
+ const processDefinitionId = readPresentString(args, "processDefinitionId");
449
+ if (processDefinitionId)
450
+ filter.processDefinitionId = processDefinitionId;
447
451
  return app.engine.searchProcessInstances(filter);
448
452
  },
449
453
  },
@@ -0,0 +1,21 @@
1
+ import type { ProcessDefinitionIdentity } from "./host.ts";
2
+ export type { ProcessDefinitionIdentity };
3
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
4
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
5
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
6
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
7
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
8
+ export declare function presentEngineKey(value: unknown): string | undefined;
9
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
10
+ * non-string is absent and never `String(...)`-coerced. */
11
+ export declare function presentString(value: unknown): string | undefined;
12
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
13
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
14
+ * so a created instance and its later snapshot describe the same definition identically. Each field
15
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
16
+ * (never coerced). */
17
+ export declare function pickProcessDefinitionIdentity(row: {
18
+ processDefinitionKey?: unknown;
19
+ processDefinitionId?: unknown;
20
+ processDefinitionVersion?: unknown;
21
+ }): ProcessDefinitionIdentity;
@@ -0,0 +1,56 @@
1
+ // The single, transport-agnostic normalizer for a {@link ProcessDefinitionIdentity}. Both engine
2
+ // adapters — the live SDK/REST adapter (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process
3
+ // WASM test adapter (`@nanobpm/urban-testkit`, `WasmEngineClient`) — report which deployed
4
+ // definition an instance runs on *both* `createInstance` and `searchProcessInstances`. Each used to
5
+ // carry its own hand-mirrored `pickProcessDefinitionIdentity` (plus a re-declared identity type), so
6
+ // the two could silently diverge the moment identity validation changed — exactly the drift class
7
+ // the shared form-contract normalizer exists to kill (`./form-contract.ts`, issue #252). Centralizing
8
+ // the mapper (and reusing the canonical {@link ProcessDefinitionIdentity}) here removes that surface:
9
+ // a created instance and its later snapshot describe the same definition identically wherever this
10
+ // normalizer runs, because they run the *same* code.
11
+ //
12
+ // Ownership today: only the SDK adapter runs this implementation. `WasmEngineClient` still defines
13
+ // and calls its own behaviourally identical copy (`urban-testkit/src/wasm-engine.ts`) — a deliberate,
14
+ // temporary fork: this module is introduced by the same change, so it exists in no published urban
15
+ // release, and a value import would force the testkit's peer floor up to an urban version that does
16
+ // not yet exist (AGENTS.md: depend on an unreleased sibling only after it is published). Follow-up
17
+ // #585 switches the WASM adapter to this canonical import once the urban release carrying it is
18
+ // published; until then the testkit's own identity cases pin its copy to this normalizer's behaviour.
19
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
20
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
21
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
22
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
23
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
24
+ export function presentEngineKey(value) {
25
+ if (typeof value === "number") {
26
+ return Number.isFinite(value) ? String(value) : undefined;
27
+ }
28
+ if (typeof value !== "string")
29
+ return undefined;
30
+ const trimmed = value.trim();
31
+ return trimmed === "" ? undefined : trimmed;
32
+ }
33
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
34
+ * non-string is absent and never `String(...)`-coerced. */
35
+ export function presentString(value) {
36
+ if (typeof value !== "string")
37
+ return undefined;
38
+ const trimmed = value.trim();
39
+ return trimmed === "" ? undefined : trimmed;
40
+ }
41
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
42
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
43
+ * so a created instance and its later snapshot describe the same definition identically. Each field
44
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
45
+ * (never coerced). */
46
+ export function pickProcessDefinitionIdentity(row) {
47
+ const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
48
+ const processDefinitionId = presentString(row.processDefinitionId);
49
+ const version = row.processDefinitionVersion;
50
+ const processDefinitionVersion = typeof version === "number" && Number.isInteger(version) && version > 0 ? version : undefined;
51
+ return {
52
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
53
+ ...(processDefinitionId ? { processDefinitionId } : {}),
54
+ ...(processDefinitionVersion !== undefined ? { processDefinitionVersion } : {}),
55
+ };
56
+ }
@@ -1,4 +1,4 @@
1
- import type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobFilter, JobHandler, JobSummary, CreatedProcessInstance, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WorkerSubscription } from "../core/host.ts";
2
2
  import type { EngineSdkClient } from "./sdk.ts";
3
3
  /** Coerce an engine response's process-instance key to a non-empty string, or
4
4
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -233,10 +233,7 @@ export declare class SdkEngineClient implements EngineClient {
233
233
  processDefinitionId: string;
234
234
  variables?: Record<string, unknown>;
235
235
  awaitCompletion?: boolean;
236
- }): Promise<{
237
- processInstanceKey: string;
238
- variables?: Record<string, unknown>;
239
- }>;
236
+ }): Promise<CreatedProcessInstance>;
240
237
  cancelInstance(input: {
241
238
  processInstanceKey: string;
242
239
  }): Promise<void>;
@@ -260,6 +257,7 @@ export declare class SdkEngineClient implements EngineClient {
260
257
  searchProcessInstances(filter?: {
261
258
  processInstanceKeys?: string[];
262
259
  state?: ProcessInstanceState;
260
+ processDefinitionId?: string;
263
261
  parentProcessInstanceKey?: string;
264
262
  rootProcessInstanceKey?: string;
265
263
  }): Promise<ProcessInstanceSnapshot[]>;
@@ -19,6 +19,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
19
19
  return path;
20
20
  };
21
21
  import { assertDeployedWaitStateType, isBpmnError } from "../core/host.js";
22
+ import { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "../core/process-identity.js";
22
23
  import { buildFormSchema, parseFormSchema, pickFormLinkage, resolveFormIdentifier, } from "../core/form-contract.js";
23
24
  import { isRecord } from "../core/guards.js";
24
25
  import { applyAmbientLineage } from "../core/lineage.js";
@@ -111,20 +112,11 @@ export function normalizeIncidentState(raw) {
111
112
  return "UNKNOWN";
112
113
  }
113
114
  }
114
- /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
115
- * whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
116
- * either way) but never `String(...)`-coerces an arbitrary object into a garbage
117
- * `"[object Object]"` id. The blank check trims, matching `getElementInstance`'s blank-key
118
- * guard and the form-key presence helpers, so a `" "` key can never leak into a result. */
119
- function presentEngineKey(value) {
120
- if (typeof value === "number") {
121
- return Number.isFinite(value) ? String(value) : undefined;
122
- }
123
- if (typeof value !== "string")
124
- return undefined;
125
- const trimmed = value.trim();
126
- return trimmed === "" ? undefined : trimmed;
127
- }
115
+ // Engine-key / process-definition-identity normalization now lives in the shared, adapter-agnostic
116
+ // `../core/process-identity.ts` (imported above), so this adapter's `createInstance` and
117
+ // `searchProcessInstances` run the *same* mapping — see that module's header for the drift class
118
+ // this removes. The WASM test adapter keeps a mirrored local copy until #585 (that module header's
119
+ // "Ownership today" note), so the shared mapping is not yet a cross-adapter guarantee.
128
120
  /** The non-empty, trimmed string form of a *required* engine key, or throws when the value is
129
121
  * absent/blank (including a whitespace-only string). A mutating seam operation addresses a
130
122
  * single entity by key, so a blank key is a caller bug that must fail fast with an actionable
@@ -632,6 +624,7 @@ export class SdkEngineClient {
632
624
  const variables = isRecord(body.variables) ? body.variables : undefined;
633
625
  return {
634
626
  processInstanceKey: requireProcessInstanceKey(key),
627
+ ...pickProcessDefinitionIdentity(body),
635
628
  variables,
636
629
  };
637
630
  }
@@ -749,6 +742,10 @@ export class SdkEngineClient {
749
742
  const f = {};
750
743
  if (filter?.state)
751
744
  f.state = filter.state;
745
+ // A blank BPMN process-id selector is absent, not an empty filter that matches nothing.
746
+ const processDefinitionId = presentString(filter?.processDefinitionId);
747
+ if (processDefinitionId)
748
+ f.processDefinitionId = processDefinitionId;
752
749
  // Normalize each requested key through the shared presence rule (drop blank/whitespace-only
753
750
  // entries, trim padded ones like `" 123 "`) so the `$in` filter carries only present keys that
754
751
  // match the normalized keys we surface — mirroring `WasmEngineClient` and the selector
@@ -792,13 +789,12 @@ export class SdkEngineClient {
792
789
  });
793
790
  return [];
794
791
  }
795
- const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
796
792
  const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
797
793
  const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
798
794
  return [{
799
795
  processInstanceKey,
800
796
  state,
801
- ...(processDefinitionKey ? { processDefinitionKey } : {}),
797
+ ...pickProcessDefinitionIdentity(it),
802
798
  ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
803
799
  ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
804
800
  }];
@@ -8,9 +8,10 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
8
8
  export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
9
9
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
10
10
  export type { ValidationIssue } from "./core/validate.ts";
11
- export type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceMetrics, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
11
+ export type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceMetrics, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobFilter, JobHandler, JobSummary, CreatedProcessInstance, ProcessDefinitionIdentity, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
12
12
  export type { TranscriptContentBlock, TranscriptContentType, TranscriptToolCall, TranscriptTurnMetrics, TranscriptTurnRole, } from "@nanobpm/agentic/transcript";
13
13
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
14
+ export { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "./core/process-identity.ts";
14
15
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
15
16
  export { createLogger } from "./core/logger.ts";
16
17
  export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
@@ -12,6 +12,11 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
12
12
  // both engine adapters (SDK/REST + WASM test double) call, so their form-identifier,
13
13
  // form-schema, and user-task form-linkage handling cannot drift.
14
14
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.js";
15
+ // The adapter-agnostic process-definition-identity normalizer (the single mapping both
16
+ // `createInstance` and `searchProcessInstances` use), owned today by the live SDK adapter; the WASM
17
+ // test adapter keeps a mirrored local copy until #585, so cross-adapter drift-freedom is not yet
18
+ // claimed. `ProcessDefinitionIdentity` itself is re-exported from `./core/host.ts`.
19
+ export { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "./core/process-identity.js";
15
20
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
16
21
  // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
17
22
  export { createLogger } from "./core/logger.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.95.0",
3
+ "version": "0.96.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",
@@ -106,7 +106,7 @@
106
106
  "@nanobpm/agentic": "^0.15.0",
107
107
  "@nanobpm/nano-app-schema": "^0.11.0",
108
108
  "@nanobpm/nano-sdk": "^1.2.5",
109
- "@nanobpm/workflow": "^0.14.0",
109
+ "@nanobpm/workflow": "^0.15.0",
110
110
  "bpmn-auto-layout": "^2.0.0-alpha.2",
111
111
  "create-urban-app": "^0.15.2",
112
112
  "yaml": "^2.9.0"
@@ -257,14 +257,35 @@ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
257
257
  */
258
258
  export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
259
259
 
260
- /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
261
- export interface ProcessInstanceSnapshot {
262
- readonly processInstanceKey: string;
263
- readonly state: ProcessInstanceState;
260
+ /**
261
+ * Which deployed process definition an instance runs — shared by the instance a
262
+ * {@link EngineClient.createInstance} starts and the {@link ProcessInstanceSnapshot} a
263
+ * {@link EngineClient.searchProcessInstances} reports, so the two describe the same instance
264
+ * identically. Each field is absent when the engine omits it (or reports it blank/malformed).
265
+ */
266
+ export interface ProcessDefinitionIdentity {
264
267
  /** The key of the deployed process definition this instance runs — the handle a caller feeds to
265
268
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
266
- * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
269
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Unique per deployed version. */
267
270
  readonly processDefinitionKey?: string;
271
+ /** The BPMN process id (`<bpmn:process id>`) — stable across versions; the selector
272
+ * {@link EngineClient.searchProcessInstances} accepts as `processDefinitionId`. */
273
+ readonly processDefinitionId?: string;
274
+ /** The deployed version of `processDefinitionId` this instance runs (1-based). */
275
+ readonly processDefinitionVersion?: number;
276
+ }
277
+
278
+ /** The instance {@link EngineClient.createInstance} started: its key, the exact deployed
279
+ * definition version it runs, and — when `awaitCompletion` was requested — its final variables. */
280
+ export interface CreatedProcessInstance extends ProcessDefinitionIdentity {
281
+ readonly processInstanceKey: string;
282
+ readonly variables?: Record<string, unknown>;
283
+ }
284
+
285
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
286
+ export interface ProcessInstanceSnapshot extends ProcessDefinitionIdentity {
287
+ readonly processInstanceKey: string;
288
+ readonly state: ProcessInstanceState;
268
289
  /** The key of this instance's *immediate* parent process instance — the caller that instantiated
269
290
  * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
270
291
  * and absent when the engine omits it (same optional/engine-provided convention as
@@ -741,12 +762,14 @@ export interface EngineClient {
741
762
  deployResources(
742
763
  resources: { name: string; content: string; contentType: string }[],
743
764
  ): Promise<{ deployed: number }>;
744
- /** Start a process instance. */
765
+ /** Start a process instance of the latest deployed version of `processDefinitionId`. Returns
766
+ * the new instance's key plus the {@link ProcessDefinitionIdentity} (key, id, version) of the
767
+ * definition it actually runs. */
745
768
  createInstance(input: {
746
769
  processDefinitionId: string;
747
770
  variables?: Record<string, unknown>;
748
771
  awaitCompletion?: boolean;
749
- }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }>;
772
+ }): Promise<CreatedProcessInstance>;
750
773
  /** Cancel a running process instance (the pages surface's row-cancel action). */
751
774
  cancelInstance(input: { processInstanceKey: string }): Promise<void>;
752
775
  /** Publish a message for correlation. */
@@ -794,7 +817,8 @@ export interface EngineClient {
794
817
  /** Complete a user task. */
795
818
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
796
819
  /**
797
- * Search process instances by key and/or lifecycle state. The instance-tracking
820
+ * Search process instances by key, BPMN process id (`processDefinitionId`, any version),
821
+ * and/or lifecycle state. The instance-tracking
798
822
  * reconciler uses this to detect a tracked instance reaching a terminal state
799
823
  * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
800
824
  * read-model row it backs would otherwise stay "active" forever. An eventually
@@ -804,6 +828,9 @@ export interface EngineClient {
804
828
  searchProcessInstances(filter?: {
805
829
  processInstanceKeys?: string[];
806
830
  state?: ProcessInstanceState;
831
+ /** Select only instances of this BPMN process id (every deployed version). A blank or
832
+ * whitespace-only value is treated as absent (no filter). */
833
+ processDefinitionId?: string;
807
834
  /** Select only instances whose *immediate* parent is this process instance — the reduced-path
808
835
  * query for "the native children spawned by this subject". */
809
836
  parentProcessInstanceKey?: string;
@@ -617,6 +617,30 @@ test("the framework debug tools appear and return engine truth", async () => {
617
617
  assert.deepEqual(JSON.parse(text), [snapshot]);
618
618
  });
619
619
 
620
+ test("urban_debug_search_process_instances forwards a processDefinitionId selector", async () => {
621
+ const seen: unknown[] = [];
622
+ const engine = fakeEngine({
623
+ searchProcessInstances: async (filter) => {
624
+ seen.push(filter);
625
+ return [];
626
+ },
627
+ });
628
+ const { router } = buildHarness({ engine });
629
+ const session = await connect(router);
630
+ const list = await rpc(router, session, "tools/list", {});
631
+ const tools = list.result?.tools;
632
+ assert.ok(Array.isArray(tools));
633
+ const tool = tools.find((t) => Reflect.get(t, "name") === "urban_debug_search_process_instances");
634
+ const props = Reflect.get(Reflect.get(tool ?? {}, "inputSchema") ?? {}, "properties");
635
+ assert.ok(props && typeof props === "object" && "processDefinitionId" in props);
636
+
637
+ await rpc(router, session, "tools/call", {
638
+ name: "urban_debug_search_process_instances",
639
+ arguments: { processDefinitionId: "order", state: "ACTIVE" },
640
+ });
641
+ assert.deepEqual(seen, [{ state: "ACTIVE", processDefinitionId: "order" }]);
642
+ });
643
+
620
644
  test("urban_debug_search_variables / search_jobs / get_process_definition_xml are read tools returning engine truth", async () => {
621
645
  const variable = {
622
646
  variableKey: "v-1",
@@ -70,6 +70,7 @@ import type {
70
70
  HttpResponse,
71
71
  IncidentFilter,
72
72
  JobFilter,
73
+ EngineClient,
73
74
  ProcessInstanceState,
74
75
  SqliteDb,
75
76
  UserTaskFilter,
@@ -544,20 +545,24 @@ function buildDebugTools(app: AppApi): DebugTool[] {
544
545
  return [
545
546
  {
546
547
  name: `${DEBUG_PREFIX}search_process_instances`,
547
- description: "Engine truth: search process instances by key and/or lifecycle state.",
548
+ description:
549
+ "Engine truth: search process instances by key, BPMN process id (processDefinitionId, any version), and/or lifecycle state.",
548
550
  inputSchema: {
549
551
  type: "object",
550
552
  properties: {
551
553
  processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
554
+ processDefinitionId: OPTIONAL_STRING,
552
555
  state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
553
556
  },
554
557
  },
555
558
  run: (args) => {
556
- const filter: { processInstanceKeys?: string[]; state?: ProcessInstanceState } = {};
559
+ const filter: NonNullable<Parameters<EngineClient["searchProcessInstances"]>[0]> = {};
557
560
  const keys = readStringArray(args, "processInstanceKeys");
558
561
  if (keys) filter.processInstanceKeys = keys;
559
562
  const state = readProcessInstanceState(args, "state");
560
563
  if (state) filter.state = state;
564
+ const processDefinitionId = readPresentString(args, "processDefinitionId");
565
+ if (processDefinitionId) filter.processDefinitionId = processDefinitionId;
561
566
  return app.engine.searchProcessInstances(filter);
562
567
  },
563
568
  },
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ pickProcessDefinitionIdentity,
5
+ presentEngineKey,
6
+ presentString,
7
+ } from "./process-identity.ts";
8
+
9
+ // The canonical, adapter-agnostic process-definition-identity normalizer. These cases pin the
10
+ // class the shared helper exists to keep consistent (No Drift Surfaces): a padded/numeric key is
11
+ // normalized, a blank/whitespace/non-string/non-finite value is *absent* and never coerced, and a
12
+ // version is kept only when a positive integer number. Scope today: only the SDK adapter
13
+ // (`engine/nanosdk.ts`) routes through this mapping; the WASM test adapter
14
+ // (`@nanobpm/urban-testkit`) runs a deliberately mirrored local copy until #585, pinned to this
15
+ // behaviour by its own identity cases (`wasm-engine.test.ts`).
16
+
17
+ test("presentEngineKey coerces a finite numeric key, trims a string, and drops blank/non-finite/non-string", () => {
18
+ assert.equal(presentEngineKey(7), "7");
19
+ assert.equal(presentEngineKey(" order "), "order");
20
+ assert.equal(presentEngineKey(" "), undefined);
21
+ assert.equal(presentEngineKey(""), undefined);
22
+ assert.equal(presentEngineKey(Number.NaN), undefined);
23
+ assert.equal(presentEngineKey(Number.POSITIVE_INFINITY), undefined);
24
+ assert.equal(presentEngineKey({}), undefined);
25
+ assert.equal(presentEngineKey(null), undefined);
26
+ assert.equal(presentEngineKey(undefined), undefined);
27
+ });
28
+
29
+ test("presentString keeps a trimmed non-empty string and drops blank/non-string (never coerces)", () => {
30
+ assert.equal(presentString(" human "), "human");
31
+ assert.equal(presentString(" "), undefined);
32
+ assert.equal(presentString(""), undefined);
33
+ // A number id is absent — never String(...)-coerced into a garbage id.
34
+ assert.equal(presentString(5), undefined);
35
+ assert.equal(presentString({}), undefined);
36
+ assert.equal(presentString(undefined), undefined);
37
+ });
38
+
39
+ test("pickProcessDefinitionIdentity normalizes present fields and omits blank/malformed ones", () => {
40
+ assert.deepEqual(
41
+ pickProcessDefinitionIdentity({
42
+ processDefinitionKey: 7,
43
+ processDefinitionId: " order ",
44
+ processDefinitionVersion: 2,
45
+ }),
46
+ { processDefinitionKey: "7", processDefinitionId: "order", processDefinitionVersion: 2 },
47
+ );
48
+ });
49
+
50
+ test("pickProcessDefinitionIdentity drops a blank key, a non-string id, and a stringly version", () => {
51
+ assert.deepEqual(
52
+ pickProcessDefinitionIdentity({
53
+ processDefinitionKey: " ",
54
+ processDefinitionId: 5,
55
+ processDefinitionVersion: "2",
56
+ }),
57
+ {},
58
+ );
59
+ });
60
+
61
+ test("pickProcessDefinitionIdentity keeps a version only when a positive integer number", () => {
62
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 1.5 }), {});
63
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 0 }), {});
64
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: -1 }), {});
65
+ assert.deepEqual(pickProcessDefinitionIdentity({ processDefinitionVersion: 3 }), {
66
+ processDefinitionVersion: 3,
67
+ });
68
+ });
69
+
70
+ test("pickProcessDefinitionIdentity on an empty row returns an empty identity (fully absent)", () => {
71
+ assert.deepEqual(pickProcessDefinitionIdentity({}), {});
72
+ });
@@ -0,0 +1,66 @@
1
+ // The single, transport-agnostic normalizer for a {@link ProcessDefinitionIdentity}. Both engine
2
+ // adapters — the live SDK/REST adapter (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process
3
+ // WASM test adapter (`@nanobpm/urban-testkit`, `WasmEngineClient`) — report which deployed
4
+ // definition an instance runs on *both* `createInstance` and `searchProcessInstances`. Each used to
5
+ // carry its own hand-mirrored `pickProcessDefinitionIdentity` (plus a re-declared identity type), so
6
+ // the two could silently diverge the moment identity validation changed — exactly the drift class
7
+ // the shared form-contract normalizer exists to kill (`./form-contract.ts`, issue #252). Centralizing
8
+ // the mapper (and reusing the canonical {@link ProcessDefinitionIdentity}) here removes that surface:
9
+ // a created instance and its later snapshot describe the same definition identically wherever this
10
+ // normalizer runs, because they run the *same* code.
11
+ //
12
+ // Ownership today: only the SDK adapter runs this implementation. `WasmEngineClient` still defines
13
+ // and calls its own behaviourally identical copy (`urban-testkit/src/wasm-engine.ts`) — a deliberate,
14
+ // temporary fork: this module is introduced by the same change, so it exists in no published urban
15
+ // release, and a value import would force the testkit's peer floor up to an urban version that does
16
+ // not yet exist (AGENTS.md: depend on an unreleased sibling only after it is published). Follow-up
17
+ // #585 switches the WASM adapter to this canonical import once the urban release carrying it is
18
+ // published; until then the testkit's own identity cases pin its copy to this normalizer's behaviour.
19
+
20
+ import type { ProcessDefinitionIdentity } from "./host.ts";
21
+
22
+ export type { ProcessDefinitionIdentity };
23
+
24
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
25
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
26
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
27
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
28
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
29
+ export function presentEngineKey(value: unknown): string | undefined {
30
+ if (typeof value === "number") {
31
+ return Number.isFinite(value) ? String(value) : undefined;
32
+ }
33
+ if (typeof value !== "string") return undefined;
34
+ const trimmed = value.trim();
35
+ return trimmed === "" ? undefined : trimmed;
36
+ }
37
+
38
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
39
+ * non-string is absent and never `String(...)`-coerced. */
40
+ export function presentString(value: unknown): string | undefined {
41
+ if (typeof value !== "string") return undefined;
42
+ const trimmed = value.trim();
43
+ return trimmed === "" ? undefined : trimmed;
44
+ }
45
+
46
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
47
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
48
+ * so a created instance and its later snapshot describe the same definition identically. Each field
49
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
50
+ * (never coerced). */
51
+ export function pickProcessDefinitionIdentity(row: {
52
+ processDefinitionKey?: unknown;
53
+ processDefinitionId?: unknown;
54
+ processDefinitionVersion?: unknown;
55
+ }): ProcessDefinitionIdentity {
56
+ const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
57
+ const processDefinitionId = presentString(row.processDefinitionId);
58
+ const version = row.processDefinitionVersion;
59
+ const processDefinitionVersion =
60
+ typeof version === "number" && Number.isInteger(version) && version > 0 ? version : undefined;
61
+ return {
62
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
63
+ ...(processDefinitionId ? { processDefinitionId } : {}),
64
+ ...(processDefinitionVersion !== undefined ? { processDefinitionVersion } : {}),
65
+ };
66
+ }
@@ -200,6 +200,43 @@ test("createInstance routes through the SDK and coerces the key", async () => {
200
200
  assert.ok(client.calls.includes("createProcessInstance"));
201
201
  });
202
202
 
203
+ test("createInstance returns the started definition's identity (key, id, version)", async () => {
204
+ // The create response names exactly which deployed definition version the instance runs; the
205
+ // seam must surface it (normalized through the same presence rule as the search snapshot) so a
206
+ // caller need not race an eventually consistent search to learn it.
207
+ const client = fakeSdkClient({
208
+ createProcessInstance: async () => ({
209
+ processInstanceKey: 99,
210
+ processDefinitionKey: 2251799813685249,
211
+ processDefinitionId: " order ",
212
+ processDefinitionVersion: 3,
213
+ }),
214
+ });
215
+ const engine = new SdkEngineClient(client);
216
+ const res = await engine.createInstance({ processDefinitionId: "order" });
217
+ assert.deepEqual(res, {
218
+ processInstanceKey: "99",
219
+ processDefinitionKey: "2251799813685249",
220
+ processDefinitionId: "order",
221
+ processDefinitionVersion: 3,
222
+ variables: undefined,
223
+ });
224
+ });
225
+
226
+ test("createInstance omits definition identity fields the engine leaves blank or malformed", async () => {
227
+ const client = fakeSdkClient({
228
+ createProcessInstance: async () => ({
229
+ processInstanceKey: "99",
230
+ processDefinitionKey: " ",
231
+ processDefinitionId: "",
232
+ processDefinitionVersion: "3", // not a number → omitted, never coerced
233
+ }),
234
+ });
235
+ const engine = new SdkEngineClient(client);
236
+ const res = await engine.createInstance({ processDefinitionId: "order" });
237
+ assert.deepEqual(res, { processInstanceKey: "99", variables: undefined });
238
+ });
239
+
203
240
  test("createInstance throws when the SDK response omits the instance key", async () => {
204
241
  const client = fakeSdkClient({
205
242
  createProcessInstance: async () => ({ variables: { ok: true } }),
@@ -622,6 +659,51 @@ test("searchProcessInstances surfaces the parent/root linkage keys and drops bla
622
659
  ]);
623
660
  });
624
661
 
662
+ test("searchProcessInstances forwards a processDefinitionId selector (trimmed, blank dropped)", async () => {
663
+ const seen: unknown[] = [];
664
+ const client = fakeSdkClient({
665
+ searchProcessInstances: async (input) => {
666
+ seen.push(input);
667
+ return { items: [] };
668
+ },
669
+ });
670
+ const engine = new SdkEngineClient(client);
671
+ await engine.searchProcessInstances({ processDefinitionId: " order ", state: "ACTIVE" });
672
+ await engine.searchProcessInstances({ processDefinitionId: " " });
673
+ assert.deepEqual(seen, [
674
+ { filter: { state: "ACTIVE", processDefinitionId: "order" } },
675
+ { filter: {} },
676
+ ]);
677
+ });
678
+
679
+ test("searchProcessInstances surfaces each instance's definition identity", async () => {
680
+ const client = fakeSdkClient({
681
+ searchProcessInstances: async () => ({
682
+ items: [
683
+ {
684
+ processInstanceKey: 10,
685
+ state: "ACTIVE",
686
+ processDefinitionKey: 5,
687
+ processDefinitionId: "order",
688
+ processDefinitionVersion: 2,
689
+ },
690
+ { processInstanceKey: 11, state: "ACTIVE", processDefinitionId: " ", processDefinitionVersion: null },
691
+ ],
692
+ }),
693
+ });
694
+ const engine = new SdkEngineClient(client);
695
+ assert.deepEqual(await engine.searchProcessInstances(), [
696
+ {
697
+ processInstanceKey: "10",
698
+ state: "ACTIVE",
699
+ processDefinitionKey: "5",
700
+ processDefinitionId: "order",
701
+ processDefinitionVersion: 2,
702
+ },
703
+ { processInstanceKey: "11", state: "ACTIVE" },
704
+ ]);
705
+ });
706
+
625
707
  test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
626
708
  assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
627
709
  assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
@@ -32,6 +32,7 @@ import type {
32
32
  JobFilter,
33
33
  JobHandler,
34
34
  JobSummary,
35
+ CreatedProcessInstance,
35
36
  ProcessInstanceSnapshot,
36
37
  ProcessInstanceState,
37
38
  UserTaskState,
@@ -49,6 +50,11 @@ import type {
49
50
  TranscriptTurnRole,
50
51
  } from "@nanobpm/agentic/transcript";
51
52
  import { assertDeployedWaitStateType, isBpmnError } from "../core/host.ts";
53
+ import {
54
+ pickProcessDefinitionIdentity,
55
+ presentEngineKey,
56
+ presentString,
57
+ } from "../core/process-identity.ts";
52
58
  import {
53
59
  buildFormSchema,
54
60
  parseFormSchema,
@@ -153,19 +159,11 @@ export function normalizeIncidentState(raw: unknown): IncidentState {
153
159
  }
154
160
  }
155
161
 
156
- /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
157
- * whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
158
- * either way) but never `String(...)`-coerces an arbitrary object into a garbage
159
- * `"[object Object]"` id. The blank check trims, matching `getElementInstance`'s blank-key
160
- * guard and the form-key presence helpers, so a `" "` key can never leak into a result. */
161
- function presentEngineKey(value: unknown): string | undefined {
162
- if (typeof value === "number") {
163
- return Number.isFinite(value) ? String(value) : undefined;
164
- }
165
- if (typeof value !== "string") return undefined;
166
- const trimmed = value.trim();
167
- return trimmed === "" ? undefined : trimmed;
168
- }
162
+ // Engine-key / process-definition-identity normalization now lives in the shared, adapter-agnostic
163
+ // `../core/process-identity.ts` (imported above), so this adapter's `createInstance` and
164
+ // `searchProcessInstances` run the *same* mapping — see that module's header for the drift class
165
+ // this removes. The WASM test adapter keeps a mirrored local copy until #585 (that module header's
166
+ // "Ownership today" note), so the shared mapping is not yet a cross-adapter guarantee.
169
167
 
170
168
  /** The non-empty, trimmed string form of a *required* engine key, or throws when the value is
171
169
  * absent/blank (including a whitespace-only string). A mutating seam operation addresses a
@@ -858,7 +856,7 @@ export class SdkEngineClient implements EngineClient {
858
856
  processDefinitionId: string;
859
857
  variables?: Record<string, unknown>;
860
858
  awaitCompletion?: boolean;
861
- }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }> {
859
+ }): Promise<CreatedProcessInstance> {
862
860
  // Auto-thread the `_urban.lineage` envelope (issue #254): a handler that spawns an
863
861
  // instance propagates its `rootRequestKey` and stamps `causedByInstanceKey`; a genuine
864
862
  // top-level request mints a fresh root. An explicit caller-supplied envelope wins.
@@ -873,6 +871,7 @@ export class SdkEngineClient implements EngineClient {
873
871
  const variables = isRecord(body.variables) ? body.variables : undefined;
874
872
  return {
875
873
  processInstanceKey: requireProcessInstanceKey(key),
874
+ ...pickProcessDefinitionIdentity(body),
876
875
  variables,
877
876
  };
878
877
  }
@@ -1003,11 +1002,15 @@ export class SdkEngineClient implements EngineClient {
1003
1002
  async searchProcessInstances(filter?: {
1004
1003
  processInstanceKeys?: string[];
1005
1004
  state?: ProcessInstanceState;
1005
+ processDefinitionId?: string;
1006
1006
  parentProcessInstanceKey?: string;
1007
1007
  rootProcessInstanceKey?: string;
1008
1008
  }): Promise<ProcessInstanceSnapshot[]> {
1009
1009
  const f: Record<string, unknown> = {};
1010
1010
  if (filter?.state) f.state = filter.state;
1011
+ // A blank BPMN process-id selector is absent, not an empty filter that matches nothing.
1012
+ const processDefinitionId = presentString(filter?.processDefinitionId);
1013
+ if (processDefinitionId) f.processDefinitionId = processDefinitionId;
1011
1014
  // Normalize each requested key through the shared presence rule (drop blank/whitespace-only
1012
1015
  // entries, trim padded ones like `" 123 "`) so the `$in` filter carries only present keys that
1013
1016
  // match the normalized keys we surface — mirroring `WasmEngineClient` and the selector
@@ -1051,13 +1054,12 @@ export class SdkEngineClient implements EngineClient {
1051
1054
  });
1052
1055
  return [];
1053
1056
  }
1054
- const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
1055
1057
  const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
1056
1058
  const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
1057
1059
  return [{
1058
1060
  processInstanceKey,
1059
1061
  state,
1060
- ...(processDefinitionKey ? { processDefinitionKey } : {}),
1062
+ ...pickProcessDefinitionIdentity(it),
1061
1063
  ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
1062
1064
  ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
1063
1065
  }];
@@ -108,6 +108,8 @@ export type {
108
108
  JobFilter,
109
109
  JobHandler,
110
110
  JobSummary,
111
+ CreatedProcessInstance,
112
+ ProcessDefinitionIdentity,
111
113
  ProcessInstanceSnapshot,
112
114
  ProcessInstanceState,
113
115
  SqliteDb,
@@ -140,6 +142,15 @@ export {
140
142
  presentFormIdentifier,
141
143
  resolveFormIdentifier,
142
144
  } from "./core/form-contract.ts";
145
+ // The adapter-agnostic process-definition-identity normalizer (the single mapping both
146
+ // `createInstance` and `searchProcessInstances` use), owned today by the live SDK adapter; the WASM
147
+ // test adapter keeps a mirrored local copy until #585, so cross-adapter drift-freedom is not yet
148
+ // claimed. `ProcessDefinitionIdentity` itself is re-exported from `./core/host.ts`.
149
+ export {
150
+ pickProcessDefinitionIdentity,
151
+ presentEngineKey,
152
+ presentString,
153
+ } from "./core/process-identity.ts";
143
154
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
144
155
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
145
156
  // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).