@alvera-ai/platform-sdk 0.15.0-next.gf9ab4f0 → 0.16.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.
@@ -256,6 +256,63 @@ if (datalakeStatus !== 'ready') {
256
256
  }
257
257
  ```
258
258
 
259
+ ## 007 — a reusable "wait until the run has fired" helper
260
+
261
+ `workflows.run` only **schedules** a run. It records it and returns
262
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
263
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
264
+ later, when the run actually fires, and are read back from
265
+ `workflowRuns.get`.
266
+
267
+ Two traps live in that gap, so every foundation scenario shares one
268
+ helper rather than re-deriving it:
269
+
270
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
271
+ leaves `'scheduled'`.** Those are different moments: the run reaches
272
+ `processing` first and writes the log id a beat later. A predicate on
273
+ status alone releases you to read a `null`, and because
274
+ `typeof null === 'object'` the symptom is a baffling *"expected
275
+ string, got object"* rather than an obvious nil.
276
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
277
+ the deadline.** A scenario blocked on a run that will never fire
278
+ should say why on the first read, not thirty seconds later behind a
279
+ generic timeout.
280
+
281
+ The helper is stored on the shared `ctx` bag rather than declared as a
282
+ plain function because each numbered step compiles into its own `it()`
283
+ block — a bare `function` here would not be in scope for the steps that
284
+ call it.
285
+
286
+ ```typescript
287
+ const FIRED_TIMEOUT_MS = 60_000
288
+ const FIRED_POLL_MS = 1_000
289
+
290
+ ctx.waitForFiredRun = async (
291
+ runDatalakeSlug: string,
292
+ runId: string,
293
+ timeoutMs: number = FIRED_TIMEOUT_MS,
294
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
295
+ const deadline = Date.now() + timeoutMs
296
+ let lastStatus: string | undefined
297
+ while (Date.now() < deadline) {
298
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
299
+ lastStatus = data.status
300
+ if (data.status === 'failed') {
301
+ throw new Error(
302
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
303
+ )
304
+ }
305
+ if (typeof data.workflow_run_log_id === 'string') {
306
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
307
+ }
308
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
309
+ }
310
+ throw new Error(
311
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
312
+ )
313
+ }
314
+ ```
315
+
259
316
  # Rollback
260
317
 
261
318
  The cookbook doctest harness does not currently tear down the
@@ -282,6 +339,11 @@ After this setup runs, the closure-scoped slots are populated as:
282
339
  - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
283
340
  setup via `admin.createTenantApiKey`); thread it into any
284
341
  additional tenant-scoped `createSession` a scenario performs
342
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
343
+ until a scheduled run has actually fired, then returns its
344
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
345
+ reading `workflow_run_log_id` off the run response returns `null`
346
+ because run-workflow only schedules (see §007)
285
347
 
286
348
  Scenario cookbooks under `industry: foundation` start at their own
287
349
  `§001` with these slots already populated.
@@ -257,6 +257,63 @@ if (datalakeStatus !== 'ready') {
257
257
  }
258
258
  ```
259
259
 
260
+ ## 007 — a reusable "wait until the run has fired" helper
261
+
262
+ `workflows.run` only **schedules** a run. It records it and returns
263
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
264
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
265
+ later, when the run actually fires, and are read back from
266
+ `workflowRuns.get`.
267
+
268
+ Two traps live in that gap, so every healthcare scenario shares one
269
+ helper rather than re-deriving it:
270
+
271
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
272
+ leaves `'scheduled'`.** Those are different moments: the run reaches
273
+ `processing` first and writes the log id a beat later. A predicate on
274
+ status alone releases you to read a `null`, and because
275
+ `typeof null === 'object'` the symptom is a baffling *"expected
276
+ string, got object"* rather than an obvious nil.
277
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
278
+ the deadline.** A scenario blocked on a run that will never fire
279
+ should say why on the first read, not thirty seconds later behind a
280
+ generic timeout.
281
+
282
+ The helper is stored on the shared `ctx` bag rather than declared as a
283
+ plain function because each numbered step compiles into its own `it()`
284
+ block — a bare `function` here would not be in scope for the steps that
285
+ call it.
286
+
287
+ ```typescript
288
+ const FIRED_TIMEOUT_MS = 60_000
289
+ const FIRED_POLL_MS = 1_000
290
+
291
+ ctx.waitForFiredRun = async (
292
+ runDatalakeSlug: string,
293
+ runId: string,
294
+ timeoutMs: number = FIRED_TIMEOUT_MS,
295
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
296
+ const deadline = Date.now() + timeoutMs
297
+ let lastStatus: string | undefined
298
+ while (Date.now() < deadline) {
299
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
300
+ lastStatus = data.status
301
+ if (data.status === 'failed') {
302
+ throw new Error(
303
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
304
+ )
305
+ }
306
+ if (typeof data.workflow_run_log_id === 'string') {
307
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
308
+ }
309
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
310
+ }
311
+ throw new Error(
312
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
313
+ )
314
+ }
315
+ ```
316
+
260
317
  # Rollback
261
318
 
262
319
  The cookbook doctest harness does not currently tear down the
@@ -283,6 +340,11 @@ After this setup runs, the closure-scoped slots are populated as:
283
340
  - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
284
341
  setup via `admin.createTenantApiKey`); thread it into any
285
342
  additional tenant-scoped `createSession` a scenario performs
343
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
344
+ until a scheduled run has actually fired, then returns its
345
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
346
+ reading `workflow_run_log_id` off the run response returns `null`
347
+ because run-workflow only schedules (see §007)
286
348
 
287
349
  Scenario cookbooks under `industry: healthcare` start at their own
288
350
  `§001` with these slots already populated.
@@ -261,6 +261,63 @@ if (datalakeStatus !== 'ready') {
261
261
  }
262
262
  ```
263
263
 
264
+ ## 007 — a reusable "wait until the run has fired" helper
265
+
266
+ `workflows.run` only **schedules** a run. It records it and returns
267
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
268
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
269
+ later, when the run actually fires, and are read back from
270
+ `workflowRuns.get`.
271
+
272
+ Two traps live in that gap, so every payments scenario shares one
273
+ helper rather than re-deriving it:
274
+
275
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
276
+ leaves `'scheduled'`.** Those are different moments: the run reaches
277
+ `processing` first and writes the log id a beat later. A predicate on
278
+ status alone releases you to read a `null`, and because
279
+ `typeof null === 'object'` the symptom is a baffling *"expected
280
+ string, got object"* rather than an obvious nil.
281
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
282
+ the deadline.** A scenario blocked on a run that will never fire
283
+ should say why on the first read, not thirty seconds later behind a
284
+ generic timeout.
285
+
286
+ The helper is stored on the shared `ctx` bag rather than declared as a
287
+ plain function because each numbered step compiles into its own `it()`
288
+ block — a bare `function` here would not be in scope for the steps that
289
+ call it.
290
+
291
+ ```typescript
292
+ const FIRED_TIMEOUT_MS = 60_000
293
+ const FIRED_POLL_MS = 1_000
294
+
295
+ ctx.waitForFiredRun = async (
296
+ runDatalakeSlug: string,
297
+ runId: string,
298
+ timeoutMs: number = FIRED_TIMEOUT_MS,
299
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
300
+ const deadline = Date.now() + timeoutMs
301
+ let lastStatus: string | undefined
302
+ while (Date.now() < deadline) {
303
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
304
+ lastStatus = data.status
305
+ if (data.status === 'failed') {
306
+ throw new Error(
307
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
308
+ )
309
+ }
310
+ if (typeof data.workflow_run_log_id === 'string') {
311
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
312
+ }
313
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
314
+ }
315
+ throw new Error(
316
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
317
+ )
318
+ }
319
+ ```
320
+
264
321
  # Rollback
265
322
 
266
323
  The cookbook doctest harness does not currently tear down the
@@ -287,6 +344,11 @@ After this setup runs, the closure-scoped slots are populated as:
287
344
  - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
288
345
  setup via `admin.createTenantApiKey`); thread it into any
289
346
  additional tenant-scoped `createSession` a scenario performs
347
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
348
+ until a scheduled run has actually fired, then returns its
349
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
350
+ reading `workflow_run_log_id` off the run response returns `null`
351
+ because run-workflow only schedules (see §007)
290
352
 
291
353
  Scenario cookbooks under `industry: payments` start at their
292
354
  own `§001` with these slots already populated.
@@ -260,6 +260,63 @@ if (datalakeStatus !== 'ready') {
260
260
  }
261
261
  ```
262
262
 
263
+ ## 007 — a reusable "wait until the run has fired" helper
264
+
265
+ `workflows.run` only **schedules** a run. It records it and returns
266
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
267
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
268
+ later, when the run actually fires, and are read back from
269
+ `workflowRuns.get`.
270
+
271
+ Two traps live in that gap, so every subscription scenario shares one
272
+ helper rather than re-deriving it:
273
+
274
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
275
+ leaves `'scheduled'`.** Those are different moments: the run reaches
276
+ `processing` first and writes the log id a beat later. A predicate on
277
+ status alone releases you to read a `null`, and because
278
+ `typeof null === 'object'` the symptom is a baffling *"expected
279
+ string, got object"* rather than an obvious nil.
280
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
281
+ the deadline.** A scenario blocked on a run that will never fire
282
+ should say why on the first read, not thirty seconds later behind a
283
+ generic timeout.
284
+
285
+ The helper is stored on the shared `ctx` bag rather than declared as a
286
+ plain function because each numbered step compiles into its own `it()`
287
+ block — a bare `function` here would not be in scope for the steps that
288
+ call it.
289
+
290
+ ```typescript
291
+ const FIRED_TIMEOUT_MS = 60_000
292
+ const FIRED_POLL_MS = 1_000
293
+
294
+ ctx.waitForFiredRun = async (
295
+ runDatalakeSlug: string,
296
+ runId: string,
297
+ timeoutMs: number = FIRED_TIMEOUT_MS,
298
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
299
+ const deadline = Date.now() + timeoutMs
300
+ let lastStatus: string | undefined
301
+ while (Date.now() < deadline) {
302
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
303
+ lastStatus = data.status
304
+ if (data.status === 'failed') {
305
+ throw new Error(
306
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
307
+ )
308
+ }
309
+ if (typeof data.workflow_run_log_id === 'string') {
310
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
311
+ }
312
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
313
+ }
314
+ throw new Error(
315
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
316
+ )
317
+ }
318
+ ```
319
+
263
320
  # Rollback
264
321
 
265
322
  The cookbook doctest harness does not currently tear down the
@@ -286,6 +343,11 @@ After this setup runs, the closure-scoped slots are populated as:
286
343
  - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
287
344
  setup via `admin.createTenantApiKey`); thread it into any
288
345
  additional tenant-scoped `createSession` a scenario performs
346
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
347
+ until a scheduled run has actually fired, then returns its
348
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
349
+ reading `workflow_run_log_id` off the run response returns `null`
350
+ because run-workflow only schedules (see §007)
289
351
 
290
352
  Scenario cookbooks under `industry: subscription` start at
291
353
  their own `§001` with these slots already populated.
@@ -477,9 +477,11 @@ batches §009 ingested (`ra` is the regulated-appointments alias
477
477
  the run-query exposes).
478
478
 
479
479
  The SMS action's `trigger_template: 'now'` dispatches the action
480
- immediately rather than scheduling it, so the run reaches a
481
- terminal status on its own — poll `batchLogs.refresh` until it
482
- leaves `:pending`. A `:partial` status is expected and fine here:
480
+ immediately rather than deferring it, so once the run fires it
481
+ reaches a terminal status on its own — poll `batchLogs.refresh`
482
+ until it leaves `:pending`. The run itself is still scheduled:
483
+ `workflows.run` records it and returns, which is why the setup
484
+ file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
483
485
  one row passed and one was filtered.
484
486
 
485
487
  ```typescript
@@ -488,8 +490,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
488
490
  mode: 'live',
489
491
  manual_override: false,
490
492
  })
491
- ctx.runLogId = runResp.data.workflow_run_log_id!
492
- ctx.runBatchId = runResp.data.batch_id!
493
+ // run-workflow only SCHEDULES the run. The log id and batch id are
494
+ // written when it fires, so read them back via workflowRuns.get.
495
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
496
+ ctx.runLogId = fired.workflowRunLogId
497
+ ctx.runBatchId = fired.batchId!
493
498
 
494
499
  const deadline = Date.now() + 120_000
495
500
  let status: string | null = null
@@ -693,14 +698,16 @@ if (wfRow.status !== 'live') {
693
698
  }
694
699
  // Behavioural probe — a dry run against a selection no row can match:
695
700
  // the pipeline executes end-to-end, the final action call is
696
- // intercepted, and the acknowledgement carries the run-log id.
701
+ // intercepted, and the acknowledgement carries the scheduled run id.
697
702
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
698
703
  sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
699
704
  mode: 'dry_run',
700
705
  manual_override: false,
701
706
  })
702
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
703
- throw new Error('dry-run probe returned no workflow_run_log_id')
707
+ // The run-log id does not exist until the run fires — wait, do not read a null.
708
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
709
+ if (probeFired.workflowRunLogId.length === 0) {
710
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
704
711
  }
705
712
  ```
706
713
 
@@ -399,8 +399,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
399
399
  mode: 'live',
400
400
  manual_override: false,
401
401
  })
402
- ctx.runLogId = runResp.data.workflow_run_log_id!
403
- ctx.runBatchId = runResp.data.batch_id!
402
+ // run-workflow only SCHEDULES the run. The log id and batch id are
403
+ // written when it fires, so read them back via workflowRuns.get.
404
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
405
+ ctx.runLogId = fired.workflowRunLogId
406
+ ctx.runBatchId = fired.batchId!
404
407
 
405
408
  const deadline = Date.now() + 60_000
406
409
  let totalWels = 0
@@ -595,7 +598,7 @@ if (wfRow.status !== 'live') {
595
598
  }
596
599
  // Behavioural probe — a dry run against a selection no row can match:
597
600
  // the pipeline executes end-to-end, the final action call is
598
- // intercepted, and the acknowledgement carries the run-log id. The
601
+ // intercepted, and the acknowledgement carries the scheduled run id. The
599
602
  // clause must speak this workflow's selection dialect — the dataset
600
603
  // alias is `rle` here, the same alias the live run above uses.
601
604
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
@@ -603,8 +606,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
603
606
  mode: 'dry_run',
604
607
  manual_override: false,
605
608
  })
606
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
607
- throw new Error('dry-run probe returned no workflow_run_log_id')
609
+ // The run-log id does not exist until the run fires — wait, do not read a null.
610
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
611
+ if (probeFired.workflowRunLogId.length === 0) {
612
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
608
613
  }
609
614
  ```
610
615
 
@@ -479,8 +479,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
479
479
  mode: 'live',
480
480
  manual_override: true,
481
481
  })
482
- ctx.runLogId = runResp.data.workflow_run_log_id!
483
- ctx.runBatchId = runResp.data.batch_id!
482
+ // run-workflow only SCHEDULES the run. The log id and batch id are
483
+ // written when it fires, so read them back via workflowRuns.get.
484
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
485
+ ctx.runLogId = fired.workflowRunLogId
486
+ ctx.runBatchId = fired.batchId!
484
487
 
485
488
  const deadline = Date.now() + 240_000
486
489
  let status: string | null = null
@@ -561,7 +564,7 @@ if (wfRow.status !== 'live') {
561
564
  }
562
565
  // Behavioural probe — a dry run against a selection no row can match:
563
566
  // the pipeline executes end-to-end, the final action call is
564
- // intercepted, and the acknowledgement carries the run-log id. The
567
+ // intercepted, and the acknowledgement carries the scheduled run id. The
565
568
  // clause speaks this workflow's selection dialect: a GENERIC-TABLE
566
569
  // dataset is addressed by its own columns (no `ra.` dataset alias —
567
570
  // that alias exists only for system-dataset selections).
@@ -570,8 +573,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
570
573
  mode: 'dry_run',
571
574
  manual_override: false,
572
575
  })
573
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
574
- throw new Error('dry-run probe returned no workflow_run_log_id')
576
+ // The run-log id does not exist until the run fires — wait, do not read a null.
577
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
578
+ if (probeFired.workflowRunLogId.length === 0) {
579
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
575
580
  }
576
581
  ```
577
582
 
@@ -396,9 +396,11 @@ exactly the two batches §008 ingested (`ra` is the
396
396
  regulated-customer alias the run-query exposes).
397
397
 
398
398
  The SMS action's `trigger_template: 'now'` dispatches the action
399
- immediately rather than scheduling it, so the run reaches a
400
- terminal status on its own — poll `batchLogs.refresh` until it
401
- leaves `:pending`. A `:partial` status is expected and fine here:
399
+ immediately rather than deferring it, so once the run fires it
400
+ reaches a terminal status on its own — poll `batchLogs.refresh`
401
+ until it leaves `:pending`. The run itself is still scheduled:
402
+ `workflows.run` records it and returns, which is why the setup
403
+ file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
402
404
  one row passed and one was filtered.
403
405
 
404
406
  ```typescript
@@ -407,8 +409,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
407
409
  mode: 'live',
408
410
  manual_override: false,
409
411
  })
410
- ctx.runLogId = runResp.data.workflow_run_log_id!
411
- ctx.runBatchId = runResp.data.batch_id!
412
+ // run-workflow only SCHEDULES the run. The log id and batch id are
413
+ // written when it fires, so read them back via workflowRuns.get.
414
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
415
+ ctx.runLogId = fired.workflowRunLogId
416
+ ctx.runBatchId = fired.batchId!
412
417
 
413
418
  const deadline = Date.now() + 120_000
414
419
  let status: string | null = null
@@ -551,14 +556,16 @@ if (wfRow.status !== 'live') {
551
556
  }
552
557
  // Behavioural probe — a dry run against a selection no row can match:
553
558
  // the pipeline executes end-to-end, the final action call is
554
- // intercepted, and the acknowledgement carries the run-log id.
559
+ // intercepted, and the acknowledgement carries the scheduled run id.
555
560
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
556
561
  sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
557
562
  mode: 'dry_run',
558
563
  manual_override: false,
559
564
  })
560
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
561
- throw new Error('dry-run probe returned no workflow_run_log_id')
565
+ // The run-log id does not exist until the run fires — wait, do not read a null.
566
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
567
+ if (probeFired.workflowRunLogId.length === 0) {
568
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
562
569
  }
563
570
  ```
564
571
 
@@ -394,9 +394,11 @@ batches §008 ingested (`rpa` is the regulated-payment-account
394
394
  alias the run-query exposes).
395
395
 
396
396
  The SMS action's `trigger_template: 'now'` dispatches the action
397
- immediately rather than scheduling it, so the run reaches a
398
- terminal status on its own — poll `batchLogs.refresh` until it
399
- leaves `:pending`. A `:partial` status is expected and fine here:
397
+ immediately rather than deferring it, so once the run fires it
398
+ reaches a terminal status on its own — poll `batchLogs.refresh`
399
+ until it leaves `:pending`. The run itself is still scheduled:
400
+ `workflows.run` records it and returns, which is why the setup
401
+ file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
400
402
  one row passed and one was filtered.
401
403
 
402
404
  ```typescript
@@ -405,8 +407,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
405
407
  mode: 'live',
406
408
  manual_override: false,
407
409
  })
408
- ctx.runLogId = runResp.data.workflow_run_log_id!
409
- ctx.runBatchId = runResp.data.batch_id!
410
+ // run-workflow only SCHEDULES the run. The log id and batch id are
411
+ // written when it fires, so read them back via workflowRuns.get.
412
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
413
+ ctx.runLogId = fired.workflowRunLogId
414
+ ctx.runBatchId = fired.batchId!
410
415
 
411
416
  const deadline = Date.now() + 120_000
412
417
  let status: string | null = null
@@ -549,7 +554,7 @@ if (wfRow.status !== 'live') {
549
554
  }
550
555
  // Behavioural probe — a dry run against a selection no row can match:
551
556
  // the pipeline executes end-to-end, the final action call is
552
- // intercepted, and the acknowledgement carries the run-log id. The
557
+ // intercepted, and the acknowledgement carries the scheduled run id. The
553
558
  // clause must speak this workflow's selection dialect — the dataset
554
559
  // alias is `rpa` here, the same alias the live run above uses.
555
560
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
@@ -557,8 +562,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
557
562
  mode: 'dry_run',
558
563
  manual_override: false,
559
564
  })
560
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
561
- throw new Error('dry-run probe returned no workflow_run_log_id')
565
+ // The run-log id does not exist until the run fires — wait, do not read a null.
566
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
567
+ if (probeFired.workflowRunLogId.length === 0) {
568
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
562
569
  }
563
570
  ```
564
571
 
@@ -273,7 +273,7 @@ const ROSTER_MDM = `{% assign p = msg %}
273
273
  "legal_entity_type": "business",
274
274
  "business_name": "{{ p.business_name | json_escape }}",
275
275
  "identifiers": [
276
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}"}
276
+ {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}", "type": "digital_identifier"}
277
277
  ]
278
278
  }`
279
279
 
@@ -376,7 +376,7 @@ const AUDIENCE_MDM = `{% assign p = msg %}
376
376
  {% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
377
377
  {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
378
378
  "identifiers": [
379
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}"}
379
+ {"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}", "type": "digital_identifier"}
380
380
  ]
381
381
  }`
382
382
 
@@ -691,7 +691,10 @@ const { data: run } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.work
691
691
  mode: 'live',
692
692
  manual_override: false,
693
693
  })
694
- ctx.runLogId = run.workflow_run_log_id!
694
+ // run-workflow only SCHEDULES the run. The log id is written when it
695
+ // fires, so read it back via workflowRuns.get.
696
+ const fired = await ctx.waitForFiredRun(datalakeSlug, run.workflow_run_id)
697
+ ctx.runLogId = fired.workflowRunLogId
695
698
 
696
699
  const deadline = Date.now() + 180_000
697
700
  let byStatus: Record<string, number> = {}
@@ -845,7 +848,7 @@ const REPLY_MDM = `{% assign p = msg %}
845
848
  {
846
849
  "legal_entity_type": "individual",
847
850
  "identifiers": [
848
- {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}"}
851
+ {"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}", "type": "digital_identifier"}
849
852
  ]
850
853
  }`
851
854
 
@@ -936,7 +939,7 @@ if (wfRow.status !== 'live') {
936
939
  }
937
940
  // Behavioural probe — a dry run against a selection no row can match:
938
941
  // the pipeline executes end-to-end, the final action call is
939
- // intercepted, and the acknowledgement carries the run-log id. The
942
+ // intercepted, and the acknowledgement carries the scheduled run id. The
940
943
  // clause speaks this workflow's selection dialect: a GENERIC-TABLE
941
944
  // audience is addressed by its own columns (no `ra.` dataset alias —
942
945
  // that alias exists only for system-dataset selections).
@@ -945,8 +948,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
945
948
  mode: 'dry_run',
946
949
  manual_override: false,
947
950
  })
948
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
949
- throw new Error('dry-run probe returned no workflow_run_log_id')
951
+ // The run-log id does not exist until the run fires — wait, do not read a null.
952
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
953
+ if (probeFired.workflowRunLogId.length === 0) {
954
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
950
955
  }
951
956
  ```
952
957
 
@@ -515,8 +515,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
515
515
  mode: 'live',
516
516
  manual_override: false,
517
517
  })
518
- ctx.runLogId = runResp.data.workflow_run_log_id!
519
- ctx.runBatchId = runResp.data.batch_id!
518
+ // run-workflow only SCHEDULES the run. The log id and batch id are
519
+ // written when it fires, so read them back via workflowRuns.get.
520
+ const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
521
+ ctx.runLogId = fired.workflowRunLogId
522
+ ctx.runBatchId = fired.batchId!
520
523
 
521
524
  const deadline = Date.now() + 240_000
522
525
  let status: string | null = null
@@ -659,7 +662,7 @@ if (wfRow.status !== 'live') {
659
662
  }
660
663
  // Behavioural probe — a dry run against a selection no row can match:
661
664
  // the pipeline executes end-to-end, the final action call is
662
- // intercepted, and the acknowledgement carries the run-log id. The
665
+ // intercepted, and the acknowledgement carries the scheduled run id. The
663
666
  // clause must speak this workflow's selection dialect — the dataset
664
667
  // alias is `rcs` here, the same alias the live run above uses.
665
668
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
@@ -667,8 +670,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
667
670
  mode: 'dry_run',
668
671
  manual_override: false,
669
672
  })
670
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
671
- throw new Error('dry-run probe returned no workflow_run_log_id')
673
+ // The run-log id does not exist until the run fires — wait, do not read a null.
674
+ const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
675
+ if (probeFired.workflowRunLogId.length === 0) {
676
+ throw new Error('dry-run probe never produced a workflow_run_log_id')
672
677
  }
673
678
  ```
674
679