@alvera-ai/platform-sdk 0.15.0 → 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.
@@ -210,6 +210,7 @@ const { data: workflow } = await api.workflows.create(
210
210
  {
211
211
  name: 'Contact-Us Triage',
212
212
  dataset_type: 'generic_table',
213
+ tags: [],
213
214
  // …other workflow fields…
214
215
  workflow_ai_agents: [
215
216
  {
@@ -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.
@@ -159,6 +159,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
159
159
  description: 'Sends a review-request SMS with a connected-app form link after a fulfilled appointment.',
160
160
  dataset_type: 'appointment',
161
161
  status: 'live',
162
+ tags: ['appointments', 'review'],
162
163
  skip_mdm_resolution: false,
163
164
  filter_config: {
164
165
  type: 'custom',
@@ -476,9 +477,11 @@ batches §009 ingested (`ra` is the regulated-appointments alias
476
477
  the run-query exposes).
477
478
 
478
479
  The SMS action's `trigger_template: 'now'` dispatches the action
479
- immediately rather than scheduling it, so the run reaches a
480
- terminal status on its own — poll `batchLogs.refresh` until it
481
- 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:
482
485
  one row passed and one was filtered.
483
486
 
484
487
  ```typescript
@@ -487,8 +490,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
487
490
  mode: 'live',
488
491
  manual_override: false,
489
492
  })
490
- ctx.runLogId = runResp.data.workflow_run_log_id!
491
- 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!
492
498
 
493
499
  const deadline = Date.now() + 120_000
494
500
  let status: string | null = null
@@ -692,14 +698,16 @@ if (wfRow.status !== 'live') {
692
698
  }
693
699
  // Behavioural probe — a dry run against a selection no row can match:
694
700
  // the pipeline executes end-to-end, the final action call is
695
- // intercepted, and the acknowledgement carries the run-log id.
701
+ // intercepted, and the acknowledgement carries the scheduled run id.
696
702
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
697
703
  sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
698
704
  mode: 'dry_run',
699
705
  manual_override: false,
700
706
  })
701
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
702
- 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')
703
711
  }
704
712
  ```
705
713
 
@@ -157,6 +157,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
157
157
  description: "Sends a happy-birthday SMS on each contact's next birthday — pure-Liquid trigger does the year-roll math.",
158
158
  dataset_type: 'legal_entity',
159
159
  status: 'live',
160
+ tags: ['lifecycle', 'birthday'],
160
161
  skip_mdm_resolution: false,
161
162
  filter_config: {
162
163
  type: 'custom',
@@ -398,8 +399,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
398
399
  mode: 'live',
399
400
  manual_override: false,
400
401
  })
401
- ctx.runLogId = runResp.data.workflow_run_log_id!
402
- 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!
403
407
 
404
408
  const deadline = Date.now() + 60_000
405
409
  let totalWels = 0
@@ -594,7 +598,7 @@ if (wfRow.status !== 'live') {
594
598
  }
595
599
  // Behavioural probe — a dry run against a selection no row can match:
596
600
  // the pipeline executes end-to-end, the final action call is
597
- // intercepted, and the acknowledgement carries the run-log id. The
601
+ // intercepted, and the acknowledgement carries the scheduled run id. The
598
602
  // clause must speak this workflow's selection dialect — the dataset
599
603
  // alias is `rle` here, the same alias the live run above uses.
600
604
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
@@ -602,8 +606,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
602
606
  mode: 'dry_run',
603
607
  manual_override: false,
604
608
  })
605
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
606
- 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')
607
613
  }
608
614
  ```
609
615
 
@@ -310,6 +310,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
310
310
  generic_table_id: genericTableId,
311
311
  skip_mdm_resolution: true,
312
312
  status: 'live',
313
+ tags: ['support', 'triage'],
313
314
  filter_config: {
314
315
  type: 'custom',
315
316
  body: 'true',
@@ -478,8 +479,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
478
479
  mode: 'live',
479
480
  manual_override: true,
480
481
  })
481
- ctx.runLogId = runResp.data.workflow_run_log_id!
482
- 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!
483
487
 
484
488
  const deadline = Date.now() + 240_000
485
489
  let status: string | null = null
@@ -560,7 +564,7 @@ if (wfRow.status !== 'live') {
560
564
  }
561
565
  // Behavioural probe — a dry run against a selection no row can match:
562
566
  // the pipeline executes end-to-end, the final action call is
563
- // intercepted, and the acknowledgement carries the run-log id. The
567
+ // intercepted, and the acknowledgement carries the scheduled run id. The
564
568
  // clause speaks this workflow's selection dialect: a GENERIC-TABLE
565
569
  // dataset is addressed by its own columns (no `ra.` dataset alias —
566
570
  // that alias exists only for system-dataset selections).
@@ -569,8 +573,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
569
573
  mode: 'dry_run',
570
574
  manual_override: false,
571
575
  })
572
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
573
- 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')
574
580
  }
575
581
  ```
576
582
 
@@ -162,6 +162,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
162
162
  description: 'Sends a payment-reminder SMS to delinquent contracted customers with a self-serve pay link.',
163
163
  dataset_type: 'customer',
164
164
  status: 'live',
165
+ tags: ['billing', 'dunning'],
165
166
  filter_config: {
166
167
  type: 'custom',
167
168
  body: FILTER_BODY,
@@ -395,9 +396,11 @@ exactly the two batches §008 ingested (`ra` is the
395
396
  regulated-customer alias the run-query exposes).
396
397
 
397
398
  The SMS action's `trigger_template: 'now'` dispatches the action
398
- immediately rather than scheduling it, so the run reaches a
399
- terminal status on its own — poll `batchLogs.refresh` until it
400
- 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:
401
404
  one row passed and one was filtered.
402
405
 
403
406
  ```typescript
@@ -406,8 +409,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
406
409
  mode: 'live',
407
410
  manual_override: false,
408
411
  })
409
- ctx.runLogId = runResp.data.workflow_run_log_id!
410
- 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!
411
417
 
412
418
  const deadline = Date.now() + 120_000
413
419
  let status: string | null = null
@@ -550,14 +556,16 @@ if (wfRow.status !== 'live') {
550
556
  }
551
557
  // Behavioural probe — a dry run against a selection no row can match:
552
558
  // the pipeline executes end-to-end, the final action call is
553
- // intercepted, and the acknowledgement carries the run-log id.
559
+ // intercepted, and the acknowledgement carries the scheduled run id.
554
560
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
555
561
  sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
556
562
  mode: 'dry_run',
557
563
  manual_override: false,
558
564
  })
559
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
560
- 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')
561
569
  }
562
570
  ```
563
571
 
@@ -160,6 +160,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
160
160
  description: 'Sends a KYC-notification SMS for newly activated payment accounts with a self-serve KYC portal link.',
161
161
  dataset_type: 'payment_account',
162
162
  status: 'live',
163
+ tags: ['compliance', 'kyc'],
163
164
  filter_config: {
164
165
  type: 'custom',
165
166
  body: FILTER_BODY,
@@ -393,9 +394,11 @@ batches §008 ingested (`rpa` is the regulated-payment-account
393
394
  alias the run-query exposes).
394
395
 
395
396
  The SMS action's `trigger_template: 'now'` dispatches the action
396
- immediately rather than scheduling it, so the run reaches a
397
- terminal status on its own — poll `batchLogs.refresh` until it
398
- 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:
399
402
  one row passed and one was filtered.
400
403
 
401
404
  ```typescript
@@ -404,8 +407,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
404
407
  mode: 'live',
405
408
  manual_override: false,
406
409
  })
407
- ctx.runLogId = runResp.data.workflow_run_log_id!
408
- 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!
409
415
 
410
416
  const deadline = Date.now() + 120_000
411
417
  let status: string | null = null
@@ -548,7 +554,7 @@ if (wfRow.status !== 'live') {
548
554
  }
549
555
  // Behavioural probe — a dry run against a selection no row can match:
550
556
  // the pipeline executes end-to-end, the final action call is
551
- // intercepted, and the acknowledgement carries the run-log id. The
557
+ // intercepted, and the acknowledgement carries the scheduled run id. The
552
558
  // clause must speak this workflow's selection dialect — the dataset
553
559
  // alias is `rpa` here, the same alias the live run above uses.
554
560
  const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
@@ -556,8 +562,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
556
562
  mode: 'dry_run',
557
563
  manual_override: false,
558
564
  })
559
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
560
- 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')
561
569
  }
562
570
  ```
563
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
 
@@ -609,6 +609,7 @@ const { data: workflow } = await api.workflows.create(tenantSlug, datalakeSlug,
609
609
  dataset_type: 'generic_table',
610
610
  generic_table_id: ctx.audienceTableId,
611
611
  status: 'live',
612
+ tags: ['marketing', 'loyalty'],
612
613
  skip_mdm_resolution: false,
613
614
  filter_config: { type: 'custom', body: CAMPAIGN_FILTER, output_schema: { type: 'boolean' } },
614
615
  decision_config: {
@@ -690,7 +691,10 @@ const { data: run } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.work
690
691
  mode: 'live',
691
692
  manual_override: false,
692
693
  })
693
- 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
694
698
 
695
699
  const deadline = Date.now() + 180_000
696
700
  let byStatus: Record<string, number> = {}
@@ -844,7 +848,7 @@ const REPLY_MDM = `{% assign p = msg %}
844
848
  {
845
849
  "legal_entity_type": "individual",
846
850
  "identifiers": [
847
- {"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"}
848
852
  ]
849
853
  }`
850
854
 
@@ -935,7 +939,7 @@ if (wfRow.status !== 'live') {
935
939
  }
936
940
  // Behavioural probe — a dry run against a selection no row can match:
937
941
  // the pipeline executes end-to-end, the final action call is
938
- // intercepted, and the acknowledgement carries the run-log id. The
942
+ // intercepted, and the acknowledgement carries the scheduled run id. The
939
943
  // clause speaks this workflow's selection dialect: a GENERIC-TABLE
940
944
  // audience is addressed by its own columns (no `ra.` dataset alias —
941
945
  // that alias exists only for system-dataset selections).
@@ -944,8 +948,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
944
948
  mode: 'dry_run',
945
949
  manual_override: false,
946
950
  })
947
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
948
- 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')
949
955
  }
950
956
  ```
951
957