@alvera-ai/platform-sdk 0.21.0 → 0.21.1-next.g4e4bff9

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.
package/.agent/AGENTS.md CHANGED
@@ -327,7 +327,10 @@ from the filesystem at
327
327
  is no SDK function and no CLI verb that returns cookbook bytes
328
328
  at runtime.
329
329
 
330
- Each cookbook structure:
330
+ Each cookbook structure — front matter, then these top-level `#`
331
+ sections, each exactly once and in this order. The validator refuses a
332
+ cookbook that is missing one, repeats one, reorders them, leaves one
333
+ empty, or adds a top-level section that is not on this list:
331
334
 
332
335
  - **Front matter** — `title`, `summary`, `use_case`, `slug`,
333
336
  `vitest_source` (list of integration-test files the snippets
@@ -349,14 +352,17 @@ Each cookbook structure:
349
352
  carrying one fenced TypeScript block lifted from the anchor or
350
353
  ancillary vitest. State threads forward across subsections via
351
354
  plain JavaScript variables in the validator's generated
352
- `describe` closure.
353
- - **Branches** — alternate paths the anchor vitest covers (a
354
- filter rejection, a transport failure, an agent-classification
355
- fallback).
356
- - **Rollback** — teardown order for the resources the cookbook
357
- provisioned.
355
+ `describe` closure. A numbered step anywhere else in the file
356
+ is refused.
358
357
  - **Outcome** — author's prose summary of what the scenario
359
358
  produces.
359
+ - **Gotchas** — one numbered list of the mistakes the walk exists
360
+ to keep you from making. Each item opens with a bold one-sentence
361
+ rule, says why it bites, and names the step that proves it as
362
+ `§NNN`. The validator refuses an item that names no step, or a
363
+ step the cookbook does not have — and every step runs against a
364
+ live platform — so a gotcha cannot outlive the behaviour it warns
365
+ about.
360
366
  - **See also** — links to relevant per-resource reference MDs
361
367
  and to the anchor + ancillary vitest files.
362
368
 
@@ -555,24 +555,24 @@ flag:
555
555
 
556
556
  ```
557
557
  last_run_at when the poll worker last made progress
558
- last_run_status 'ok' | 'partial' | 'error'
559
- last_run_error why the last run failed, or why a partial run was truncated
558
+ last_run_status 'ok' | 'error'
559
+ last_run_error why the last run stopped; null when it read its whole window
560
560
  last_run_events_found events fetched so far in the current run
561
561
  status 'active' | 'cycle_detected'
562
562
  ```
563
563
 
564
- **A poll run has three outcomes, and only `'ok'` means it reconciled its
565
- whole time window:**
566
-
567
- - `'ok'` — the run completed and read its entire window. This is the only
568
- value that proves the reconciliation is complete.
569
- - `'partial'` — the run completed but its provider fetch was **truncated**:
570
- the window was not fully read, and the events it missed are the **newest**
571
- ones. `last_run_error` carries the detail (it is **not** null on a
572
- `partial` run). A client that treats `last_run_status !== 'error'` as
573
- healthy silently accepts this incomplete reconciliation — check
574
- `last_run_status === 'ok'` instead.
575
- - `'error'` — the run failed; `last_run_error` says why.
564
+ **A poll run has two outcomes:**
565
+
566
+ - `'ok'` — the run read its entire window. The reconciliation is
567
+ complete.
568
+ - `'error'` — the run stopped; `last_run_error` says why.
569
+
570
+ **There is no partial run.** The poller drains the window as a chain of
571
+ jobs, one page per job, carrying the provider's next-page token into the
572
+ next job, and there is no cap on how many pages it reads — so a run
573
+ either reads every page of its window or stops with an error. A CloudWatch
574
+ poll that is handed the same next-page token twice stops with an error
575
+ naming that page rather than reading it again.
576
576
 
577
577
  `last_run_events_found` is the event count for the current run; because
578
578
  `refresh` is asynchronous (below), a read taken before the run finishes can
package/.agent/async.md CHANGED
@@ -46,9 +46,10 @@ resource-specific. Common examples:
46
46
  **A data-activation-client run is NOT on this list — it is not a
47
47
  `waitUntilReady` status-poll target.** `.ingest` / `.ingestFile` /
48
48
  `.runManually` return a `202` + a `batch_id` (not a saved row and not
49
- a pollable status enum); progress is tracked by diffing the DAC's log
50
- and dataset rows by that `batch_id`, not by polling a `success`/`error`
51
- status. See `data_activation_clients.md` §6.
49
+ a pollable status enum); progress is tracked through the run record
50
+ and the DAC's log rows for that `batch_id` — a dataset row carries no
51
+ `batch_id` of its own, it points at its log row — not by polling a
52
+ `success`/`error` status. See `data_activation_clients.md` §6.
52
53
 
53
54
  The Datalake row is the odd one: its top-level `status` enum is
54
55
  `new | processing | ready` only — there is no terminal failure
@@ -38,7 +38,7 @@ is one call away from here.
38
38
  | Tenant + raw datalake | The marketing team's own world |
39
39
  | SMS tool, email tool, LLM tool | Shared by every scenario below |
40
40
  | Score Lead AI agent | Bands inbound leads |
41
- | Three generic tables | Leads, the roster, and the audience |
41
+ | Four generic tables | Leads, the roster, the audience, the replies |
42
42
  | Three workflows | Band fan-out, birthday greeting, campaign |
43
43
  | Authenticated links | The greeting's link and the campaign's link |
44
44
  | Action status updater | Delivery outcomes, reconciled on a cron |
@@ -1255,6 +1255,11 @@ await ctx.waitForBatches(ctx.leadFormDacSlug, [ctx.batchBirthday, ctx.batchNoDob
1255
1255
  `manual_override: false` so the filter genuinely evaluates rather than
1256
1256
  being bypassed.
1257
1257
 
1258
+ The `sql_where_clause` picks the two contacts by the batches that wrote
1259
+ them, and that goes **through the log row**: a legal entity carries no
1260
+ `batch_id` of its own, it points at the `data_activation_logs` row of the
1261
+ ingest that wrote it through `alvera_data_activation_log_id`.
1262
+
1258
1263
  **This run never reaches a terminal status, and waiting for one would hang
1259
1264
  until the deadline.** The birthday row's action is scheduled for a date in
1260
1265
  the future, so its execution log sits at `:executing` for the life of the
@@ -1263,7 +1268,9 @@ execution log, which is the moment the next step can read them.
1263
1268
 
1264
1269
  ```typescript
1265
1270
  const bdRunResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.birthdayWorkflowSlug, {
1266
- sql_where_clause: `le.batch_id IN ('${ctx.batchBirthday}', '${ctx.batchNoDob}')`,
1271
+ sql_where_clause:
1272
+ `le.alvera_data_activation_log_id IN (SELECT id FROM data_activation_logs` +
1273
+ ` WHERE batch_id IN ('${ctx.batchBirthday}', '${ctx.batchNoDob}'))`,
1267
1274
  mode: 'live',
1268
1275
  manual_override: false,
1269
1276
  })
@@ -1319,11 +1326,14 @@ if ((bdByStatus.filtered ?? 0) !== 1) {
1319
1326
  ## 027 — find the contact the greeting is for
1320
1327
 
1321
1328
  `workflows.execute` is addressed by dataset id, so the birthday contact's
1322
- legal-entity id has to be resolved first. Search scoped to its own batch.
1329
+ legal-entity id has to be resolved first. Search scoped to its own batch —
1330
+ through the log row again, since the row has no `batch_id` column.
1323
1331
 
1324
1332
  ```typescript
1325
1333
  const { data: bdSearchDef } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'legal_entity', {
1326
- search_query: `le.batch_id = '${ctx.batchBirthday}'`,
1334
+ search_query:
1335
+ `le.alvera_data_activation_log_id IN` +
1336
+ ` (SELECT id FROM data_activation_logs WHERE batch_id = '${ctx.batchBirthday}')`,
1327
1337
  })
1328
1338
  if (bdSearchDef.status !== 'completed') {
1329
1339
  throw new Error(`legal_entity user-search status=${bdSearchDef.status} error=${bdSearchDef.error_message ?? '(none)'}`)
@@ -1580,27 +1590,44 @@ await ctx.deployGenericTable(ctx.rosterTableName)
1580
1590
  await ctx.deployGenericTable(ctx.audienceTableName)
1581
1591
  ```
1582
1592
 
1583
- ## 032 — the inbound-messages table, which you must not create
1593
+ ## 032 — the inbound-messages table, which the app creates
1584
1594
 
1585
- Replies land in `inbound_messages`. It is a **system** table: it ships with
1586
- every datalake, its `type` is `'system'` rather than `'custom'`, and nobody
1587
- creates it. You find it by name.
1595
+ Replies land in `inbound_messages`, and the app defines that table like any
1596
+ other generic table. The platform ships no inbound table: a lake starts with
1597
+ none, and an app that receives replies declares its own — with the columns
1598
+ its replies carry, nothing reserved about the name.
1588
1599
 
1589
- Trying to create it is the mistake this step exists to prevent — the name is
1590
- reserved and the physical table is already there, so the attempt fails in a
1591
- way that reads like a permissions problem.
1600
+ `message_id` is the unique column, which makes a re-ingest of the same reply
1601
+ an upsert (§041 relies on it). `handle` and `body` are the sender's contact
1602
+ and words, so both are `tokenize`. `potential_duplicate` is the flag a human
1603
+ reviews when a handle maps to more than one customer.
1592
1604
 
1593
1605
  ```typescript
1594
- const { data: allTables } = await api.genericTables.list(tenantSlug, datalakeSlug)
1595
- const inbound = (allTables.data ?? []).find((t: { name?: string }) => t.name === 'inbound_messages')
1596
- if (!inbound) {
1597
- throw new Error('inbound_messages must ship with every datalake')
1598
- }
1599
- if (inbound.type !== 'system') {
1600
- throw new Error(`inbound_messages must be a system table, got type=${inbound.type}`)
1601
- }
1606
+ const inbound = await ctx.ensure(
1607
+ 'Inbound Messages table',
1608
+ async () => {
1609
+ const { data } = await api.genericTables.list(tenantSlug, datalakeSlug)
1610
+ return (data.data ?? []).find((t: { title?: string }) => t.title === 'Inbound Messages')
1611
+ },
1612
+ async () => {
1613
+ const { data } = await api.genericTables.create(tenantSlug, datalakeSlug, {
1614
+ title: 'Inbound Messages',
1615
+ description: 'Inbound customer messages — campaign replies re-ingested through the reply client.',
1616
+ columns: [
1617
+ { name: 'message_id', title: 'Message ID', type: 'string', description: 'Vendor-supplied unique inbound-message id', is_unique: true, privacy_requirement: 'none' },
1618
+ { name: 'handle', title: 'Handle', type: 'string', description: 'Sender phone/email the message arrived from', is_unique: false, privacy_requirement: 'tokenize', semantic_kind: 'string' },
1619
+ { name: 'channel', title: 'Channel', type: 'string', description: 'Channel the message arrived on: sms | email', is_unique: false, privacy_requirement: 'none' },
1620
+ { name: 'body', title: 'Body', type: 'string', description: 'Free-text message body', is_unique: false, privacy_requirement: 'tokenize', semantic_kind: 'text' },
1621
+ { name: 'potential_duplicate', title: 'Potential Duplicate', type: 'boolean', description: 'Set when the sender handle maps to more than one LegalEntity — flagged for human review', is_unique: false, privacy_requirement: 'none' },
1622
+ ],
1623
+ })
1624
+ return data
1625
+ },
1626
+ )
1602
1627
  ctx.inboundTableId = inbound.id!
1603
1628
  ctx.inboundTableName = inbound.name!
1629
+
1630
+ await ctx.deployGenericTable(ctx.inboundTableName)
1604
1631
  ```
1605
1632
 
1606
1633
  ## 033 — the roster contracts, on two clients
@@ -2393,7 +2420,7 @@ if (!campaignTracked.message.opened_at) {
2393
2420
  ## 041 — the reply comes back, and attaches to whoever sent it
2394
2421
 
2395
2422
  A reply arrives on the same handle the campaign sent to. It ingests into the
2396
- **system** inbound table through a client whose `mdm_input_config` keys on
2423
+ inbound table §032 created through a client whose `mdm_input_config` keys on
2397
2424
  the same `(uri, value)` namespace §034 wrote — so the platform resolves the
2398
2425
  handle straight back to the customer and stamps their subject on the row.
2399
2426
 
@@ -3077,12 +3104,66 @@ if (afterSubjects.size !== 1 || !afterSubjects.has(twoIdSubject)) {
3077
3104
 
3078
3105
  # Outcome
3079
3106
 
3080
- The marketing tenant exists with one raw datalake, an SMS tool, an LLM
3081
- tool, a lead-submissions table, and an agent-driven workflow that bands
3082
- every inbound lead and fans out to exactly one of four SMS actions.
3107
+ After all forty-seven steps run green, on one tenant and **one raw
3108
+ datalake**:
3109
+
3110
+ - **Every inbound lead is banded.** Three leads land in the Lead Submissions
3111
+ table through the client the platform provisioned with it, and the Score
3112
+ Lead agent's band steers each one to exactly one of four SMS actions
3113
+ (§014–§018).
3114
+ - **A birthday greeting is scheduled a year out.** Two contacts arrive through
3115
+ the lead-form client; the Happy Birthday workflow's pure-Liquid trigger
3116
+ schedules a greeting for the one with a birthday and filters the one
3117
+ without. Fast-forwarded, the greeting carries an authenticated link whose
3118
+ exchange names that contact, and the tap, the page and the form are tracked
3119
+ on the message (§020–§030).
3120
+ - **A campaign goes out A/B on two channels.** One business and four
3121
+ recipients land in the roster and audience tables through contract pairs on
3122
+ separate clients, identities first. The campaign workflow's gates decide who
3123
+ is contacted at all, and its split sends each of them one variant on both
3124
+ SMS and email, every message carrying its own link to the booking page
3125
+ (§031–§040).
3126
+ - **A reply attaches to whoever sent it.** It ingests into the app's own
3127
+ inbound table and resolves back to the recipient's subject without creating
3128
+ a new one (§032, §041).
3129
+ - **Delivery is reconciled, not assumed.** An action status updater turns
3130
+ provider delivery events into updates on a schedule, its stored body is
3131
+ read back against its checksum, and a poller whose request could never move
3132
+ to the next page is refused at create (§042–§044).
3133
+ - **The lake answers in plain language.** Text-to-SQL proposes a query
3134
+ without running it; the read-only executor runs one and returns the same
3135
+ page as CSV (§045–§046).
3136
+ - **One person carries two identifiers.** A second ingest that names only the
3137
+ email leaves the phone slot untouched on the same subject (§047).
3083
3138
 
3084
3139
  Running this walk again reuses all of it.
3085
3140
 
3141
+ # Gotchas
3142
+
3143
+ 1. **`link_prefix` is required on every SMS and email call, even one whose
3144
+ body carries no link of its own.** Any URL in the body — including one a
3145
+ lead typed into the form — goes out as `<link_prefix>/<token>`. Leave the
3146
+ prefix off and the workflow is refused at create with a 422 on
3147
+ `/actions/<n>/tool_call/link_prefix` — §012, §038.
3148
+ 2. **`workflows.run` only schedules a run.** The ids a scenario needs are
3149
+ written later, when the run fires, so poll until `workflow_run_log_id` is
3150
+ a string, not until `status` leaves `'scheduled'`. And a run whose action
3151
+ is scheduled for a future date never reaches a terminal status at all:
3152
+ wait for every row's execution log instead — §007, §025.
3153
+ 3. **Gate on `dataset_updated`, never on `rows_ingested`.** They answer
3154
+ different questions — received versus written — and a batch whose row is
3155
+ refused reports `rows_ingested: 1, dataset_updated: 0, status: 'partial'`.
3156
+ A gate on `rows_ingested` calls that green, and the failure surfaces three
3157
+ steps later as a workflow that seems broken — §007, §015.
3158
+ 4. **On a repeat run every action is skipped, and that is correct.** The
3159
+ idempotency key that already fired will not fire again, so read the
3160
+ `message` row the lake keeps, not the execution log of the run you just
3161
+ made — on run two that log carries no body at all — §017, §029.
3162
+ 5. **`skip_mdm_resolution: false` is set explicitly, and must be.** A
3163
+ generic-table row is never its own subject. With resolution on, the
3164
+ message and every link minted with it name the stamped subject as their
3165
+ `legal_entity_id`; skip it and the subject is nil — §038.
3166
+
3086
3167
  # See also
3087
3168
 
3088
3169
  - `datalakes.md` — the create body, and what a derived pair would add
@@ -2131,7 +2131,7 @@ truncate a table with an inbound foreign key, regardless of whether the
2131
2131
  referencing tables hold any rows, so repairing a parent empties its whole
2132
2132
  dependent closure, transitively, in a single statement — and all of them are
2133
2133
  re-copied by the same run. `legal_entities` is the target here on purpose:
2134
- §029's contract pair resolved every screened subject through it, and
2134
+ §019's contract pair resolved every screened subject through it, and
2135
2135
  `legal_entity_identifications` points at it, so `emptied` genuinely names more
2136
2136
  than one table. Any surface offering this button should read `resync_empties`
2137
2137
  and say the count before it is pressed.
@@ -2208,15 +2208,34 @@ pairs split across four Data Activation Clients, and two agentic workflows
2208
2208
  that between them screen, decide, notify and track — plus the copy's own
2209
2209
  arrival state, read back per table and repaired.
2210
2210
 
2211
- Two things are worth carrying out of this walk. **The pair must not run
2212
- concurrently** (§020) — one writer per subject, or the unique index that
2213
- guards against duplicate entities refuses your row on the only run that
2214
- matters, the first. And **assert end states, not transitions** (§024) — an
2215
- idempotency key that has already fired will not fire again, which is the
2216
- product working, not the walk failing.
2217
-
2218
2211
  Running this walk again reuses all of it.
2219
2212
 
2213
+ # Gotchas
2214
+
2215
+ 1. **The pair must not run concurrently.** One writer per subject, or the
2216
+ unique index that guards against duplicate entities refuses your row on
2217
+ the only run that matters, the first — §020.
2218
+ 2. **Assert end states, not transitions.** An idempotency key that has
2219
+ already fired will not fire again, which is the product working, not the
2220
+ walk failing — §024.
2221
+ 3. **Gate on `dataset_updated`, never on `rows_ingested`.** They answer
2222
+ different questions — received versus written — and a batch whose row is
2223
+ refused reports `rows_ingested: 1, dataset_updated: 0, status: 'partial'`.
2224
+ A gate on `rows_ingested` calls that green, and the failure surfaces three
2225
+ steps later as a workflow that seems broken — §008, §022.
2226
+ 4. **The system-template catalog is paginated, and page 1 is not the
2227
+ catalog.** The template you want is as likely to be on page 3; reading
2228
+ page 1 and concluding a template does not exist is the trap — §014.
2229
+ 5. **The identifier shape is where the contract pair goes wrong, and there
2230
+ are three of them.** A contract's `mdm_input_config` takes `uri` / `type`
2231
+ / `value`; a LegalEntity identification takes `id_type` / `id_number` /
2232
+ `uri`; `POST /mdm/verify` takes `system` / `id_type` / `value` — §019.
2233
+ 6. **A repair empties more than the table you name, and its `202` means
2234
+ started.** Postgres will not truncate a table with an inbound foreign key,
2235
+ so repairing a parent empties its whole dependent closure; there is no
2236
+ completion callback, so poll until every emptied table reads `caught_up`
2237
+ again — §039.
2238
+
2220
2239
  # See also
2221
2240
 
2222
2241
  - `datalakes.md` — the create body, and what the derived pair is for
@@ -2194,30 +2194,30 @@ After all thirty-one steps run green, on one tenant and **three datalakes**:
2194
2194
  read back, one table emptied and copied again, and every table the repair
2195
2195
  took with it waited back to `caught_up`.
2196
2196
 
2197
- What this surface teaches that the raw cookbooks cannot:
2197
+ # Gotchas
2198
2198
 
2199
2199
  1. **A masked jsonb column is `jsonb` in raw and `text` in both copies, and
2200
2200
  the types are supposed to differ.** Logical replication ships a text
2201
2201
  rendering and feeds it to the subscriber's input function, so
2202
2202
  `jsonb → text` always works and `jsonb → text[]` never does. The
2203
2203
  invariant is scalar-ness, not type parity — and `is_array: true`
2204
- describes the JSON document, not the Postgres column.
2204
+ describes the JSON document, not the Postgres column — §016.
2205
2205
  2. **The tier follows what the prompt can reach.** The triage agent here runs
2206
2206
  `tokenized` because it reads patient messages; the equivalent agent in
2207
2207
  `subscription-saas` runs `raw` because its prompt carries an account
2208
- number and nothing else. Neither is a house style.
2208
+ number and nothing else. Neither is a house style — §021.
2209
2209
  3. **The two copies are different from each other on purpose.** `tok_<hash>`
2210
2210
  is a stable join key for a machine; `xxxx-<tail>` is an unjoinable
2211
- placeholder for a person.
2211
+ placeholder for a person — §029.
2212
2212
  4. **A key the contract does not read is not an error.** It renders nothing,
2213
2213
  the column lands `null`, the batch reports green, and the failure appears
2214
2214
  two steps later as an action rejecting an empty recipient. Assert columns
2215
- on the row.
2215
+ on the row — §015.
2216
2216
  5. **"The copy exists" and "the copy is complete" are different questions.**
2217
2217
  `finished_copy` has not finished — read `caught_up`, not the state's name.
2218
2218
  And a repair is not scoped to the table you name: Postgres will not
2219
2219
  truncate a table anything references, so the dependent closure goes with
2220
- it. Both are why `emptied` is returned rather than assumed.
2220
+ it. Both are why `emptied` is returned rather than assumed — §030, §031.
2221
2221
 
2222
2222
  # See also
2223
2223
 
@@ -1931,7 +1931,10 @@ ctx.bulkBatchId = await ctx.ingestFileAndWait(ctx.dataDacSlug, 4)
1931
1931
 
1932
1932
  Both halves, scoped to the batch the worker allocated. The table side is read
1933
1933
  through read-only SQL; the identity side through the dataset search, where
1934
- `le` is the legal-entity alias the query compiler expects.
1934
+ `le` is the legal-entity alias the query compiler expects. A legal entity
1935
+ carries no `batch_id` of its own — it points at the `data_activation_logs`
1936
+ row of the ingest that wrote it — so the batch scope goes through that log
1937
+ row.
1935
1938
 
1936
1939
  The stamp check is the one that matters, and it is the same check §014 ran on
1937
1940
  the inline rows. Whether a row arrived in a JSON body or in a CSV, it is the
@@ -1966,7 +1969,9 @@ if (unstamped.length > 0) {
1966
1969
  }
1967
1970
 
1968
1971
  const { data: search } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'legal_entity', {
1969
- search_query: `le.batch_id = '${ctx.bulkBatchId}'`,
1972
+ search_query:
1973
+ `le.alvera_data_activation_log_id IN` +
1974
+ ` (SELECT id FROM data_activation_logs WHERE batch_id = '${ctx.bulkBatchId}')`,
1970
1975
  })
1971
1976
  if (search.status !== 'completed') {
1972
1977
  throw new Error(`legal_entity user-search did not compile: ${search.status}`)
@@ -2405,7 +2410,7 @@ After all forty-one steps run green, on one tenant and **one raw datalake**:
2405
2410
  - **A second person** is on the tenant with a `member` role, invited and
2406
2411
  accepted entirely through the SDK.
2407
2412
 
2408
- Four things this walk teaches that are easy to learn the expensive way:
2413
+ # Gotchas
2409
2414
 
2410
2415
  1. **Never bind a contract pair to one activation client.** The fan-out
2411
2416
  staggers by row, so a pair on one client resolves the same subject twice
@@ -2413,10 +2418,10 @@ Four things this walk teaches that are easy to learn the expensive way:
2413
2418
  sequence them — §012, and again at §030 and §033.
2414
2419
  2. **Gate on `dataset_updated`, not `rows_ingested`.** Received is not
2415
2420
  written. A refused row reports the first and not the second, and a walk
2416
- gated on the wrong one goes green over an empty table.
2421
+ gated on the wrong one goes green over an empty table — §007, §013.
2417
2422
  3. **Search the idempotency key, never the workflow id.** The key is built
2418
2423
  from a stable business id and names the same message forever; a workflow
2419
- id changes the moment the resource is recreated.
2424
+ id changes the moment the resource is recreated — §019, §023.
2420
2425
  4. **Assert the end state, not the transition.** An idempotency key fires
2421
2426
  once ever, so every assertion here accepts both the first run's shape and
2422
2427
  the repeat run's — §027 spells that out, and §040 defers its proof to
@@ -389,7 +389,7 @@ const { data } = await api.dataActivationClients.ingest(
389
389
  tenantSlug, datalakeSlug, activationClientSlug,
390
390
  { data: rowAsJson }, // a single row, shaped per the CSV/JSON the contracts expect
391
391
  )
392
- // data.batch_id — UUID; the row's batch identifier (use to find log rows)
392
+ // data.batch_id — UUID; the run's batch identifier (use to find the run's log rows)
393
393
  // data.key — storage key for the row's archived ndjson
394
394
  // data.jobs_count — number of downstream jobs the platform enqueued
395
395
  ```
@@ -400,8 +400,11 @@ been written, poll the logs (§6.4) or use the verification
400
400
  pattern (§6.6).
401
401
 
402
402
  The `batch_id` is the most important field on the response —
403
- every downstream log row and dataset row carries the same
404
- `batch_id`, so it's the join key for run-specific verification.
403
+ every log row the run writes carries it, and every dataset row the
404
+ run writes points at one of those log rows through
405
+ `alvera_data_activation_log_id`. A dataset row carries no
406
+ `batch_id` of its own, so the batch reaches the rows through the
407
+ log rows: that is the join for run-specific verification.
405
408
 
406
409
  ### 6.2 Bulk file ingestion — `.ingestFile`
407
410
 
@@ -490,8 +493,9 @@ batch_id UUID — the run's batch identifier
490
493
  dataset_table string — the target dataset/table name
491
494
  rows_ingested number — count of top-level rows the contract emitted
492
495
  (cascade rows carry 0)
493
- dataset_updated number — count of dataset rows whose batch_id this run wrote
494
- (load-bearing: see Gotcha 7)
496
+ dataset_updated number — count of dataset rows that point at this log row
497
+ (each row the run writes carries its log row's id in
498
+ alvera_data_activation_log_id; load-bearing: see Gotcha 7)
495
499
  output_files { object_key, mode }[] — merged ndjson archive refs, one per
496
500
  bucket; mode is 'raw' | 'tokenized' | 'redacted'
497
501
  (empty until the post-batch merge worker commits).
@@ -639,29 +643,32 @@ spends its full timeout and then reports nothing about the cause.
639
643
 
640
644
  ### 6.6 Verifying ingestion: the two-step dataset search
641
645
 
642
- > **Simplest landing check — `executeSql`.** Ingestion is async (the row
643
- > commits a moment after the `202`). When you just need to know a row
644
- > *landed*, poll `datalakes.executeSql` with a read-only `SELECT … WHERE
645
- > batch_id = '…'`: **each call is a fresh read, so there is no stale
646
- > snapshot to recover from** — unlike a `createUserSearch` created before
647
- > the row commits, which captures nothing and never updates. Gate on it,
648
- > then run the two-step `createUserSearch` below **once** (race-free) only
649
- > if you need the dataset's alias-join + tokenization semantics.
646
+ > **Simplest landing check — the run's own log rows.** Ingestion is async
647
+ > (the row commits a moment after the `202`). When you just need to know
648
+ > rows *landed*, read the run's log rows (§6.4) filtered by its
649
+ > `batch_id`. Every dataset row the run writes points at the log row for
650
+ > its table, and that log row's `dataset_updated` counts it — so the log
651
+ > rows answer the question with no query on the dataset table and no
652
+ > table name to know. A dataset row carries no `batch_id` of its own to
653
+ > filter on. **Each call is a fresh read, so there is no stale snapshot to
654
+ > recover from** — unlike a `createUserSearch` created before the row
655
+ > commits, which captures nothing and never updates. Gate on it, then run
656
+ > the two-step `createUserSearch` below **once** (race-free) only if you
657
+ > need the dataset's alias-join + tokenization semantics.
650
658
  >
651
659
  > ```typescript
652
- > const { data: probe } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
653
- > sql: `SELECT 1 FROM ${tableName} WHERE batch_id = '${batchId}' LIMIT 1`,
654
- > mode: 'raw',
655
- > })
656
- > // `data` is an array-of-arrays (a `string` only for { format: 'csv' });
657
- > // a non-empty page proves the batch landed. The table is named the same
658
- > // in every lake — `mode` selects which copy answers, not which name.
659
- > const landed = typeof probe !== 'string' && probe.data.length > 0
660
+ > const { data: logs } = await api.dataActivationClients.logs.list(
661
+ > tenantSlug, datalakeSlug, activationClientSlug,
662
+ > { filters: [{ field: 'batch_id', op: '==', value: batchId }] },
663
+ > )
664
+ > // One log row per dataset table the run wrote into; each counts the rows
665
+ > // that point at it. A sum above zero proves the batch landed.
666
+ > const landed = (logs.data ?? []).reduce((n, log) => n + (log.dataset_updated ?? 0), 0) > 0
660
667
  > ```
661
668
  >
662
669
  > `createUserSearch` is the heavier, workflow-oriented surface — reach for
663
- > it when you need its alias joins (`ri.value`, tokenized read-back), not
664
- > for a plain "did it land" check.
670
+ > it when you need the rows themselves: its alias joins, tokenized
671
+ > read-back. Not for a plain "did it land" check.
665
672
 
666
673
  The canonical way to **prove a row landed in the datalake** is
667
674
  to query the dataset directly. This is a two-step pattern:
@@ -683,7 +690,8 @@ const { data: search } = await api.datasets.createUserSearch(
683
690
  'patient', // dataset name
684
691
  {
685
692
  search_query: `ri.value = '${externalPatientId}'` // SQL WHERE-clause fragment
686
- + ` AND le.batch_id = '${batchId}'`,
693
+ + ` AND le.alvera_data_activation_log_id IN`
694
+ + ` (SELECT id FROM data_activation_logs WHERE batch_id = '${batchId}')`,
687
695
  },
688
696
  )
689
697
  // search.id — UUID of the materialised result set
@@ -736,8 +744,46 @@ a patient or an appointment is now a generic table you declare, plus
736
744
  the legal entity its rows resolve to**; there is no dataset of that
737
745
  name to search. The four cookbooks all take this shape.
738
746
 
739
- The `<alias>.batch_id` predicate scopes a search to one run, which is
740
- the usual reason to reach for this surface at all.
747
+ Scoping a search to one run is the usual reason to reach for this
748
+ surface at all, and it goes **through the log row**. A row carries no
749
+ `batch_id`, `client_id`, `user_id` or `source_uri` of its own: it
750
+ carries `alvera_data_activation_log_id`, the `data_activation_logs` row
751
+ of the ingest that wrote it, and the batch lives there. So the
752
+ predicate is
753
+
754
+ ```
755
+ <alias>.alvera_data_activation_log_id IN
756
+ (SELECT id FROM data_activation_logs WHERE batch_id = '<batch_id>')
757
+ ```
758
+
759
+ The search runs with read access across the whole lake, so
760
+ `data_activation_logs` is readable from inside it. The pointer is null
761
+ on a row nothing ingested (a row an app wrote itself), so such a row
762
+ never matches a batch predicate.
763
+
764
+ #### Where a row came from, coming back
765
+
766
+ A searched row carries the pointer **and** the log row it points at,
767
+ loaded. Measured on a live run — one legal entity, one generic-table row
768
+ — the platform columns that come back are:
769
+
770
+ ```
771
+ id string
772
+ inserted_at / updated_at string
773
+ checksum string
774
+ checksum_changed? boolean (the key has the question mark)
775
+ legal_entity_id string (generic-table rows)
776
+ alvera_data_activation_log_id string (null on a row no ingest wrote)
777
+ alvera_data_activation_log object — the log row itself:
778
+ id, batch_id, client_id, user_id, source_uri, dataset_table,
779
+ rows_ingested, dataset_updated, status, error, input_files,
780
+ output_files, inserted_at, updated_at
781
+ ```
782
+
783
+ So "which batch wrote this row?" is `row.alvera_data_activation_log.batch_id`,
784
+ and "where did it come from?" is `row.alvera_data_activation_log.source_uri` —
785
+ there is no top-level `batch_id`, `client_id`, `user_id` or `source_uri` on
786
+ the row unless the table declared a column of that name itself.
741
787
 
742
788
  #### What a masked column looks like coming back
743
789
 
@@ -807,13 +853,13 @@ screen. Pick the mode by which of those two readers you are serving.
807
853
  pattern in §6.6) before treating the row as ingested.
808
854
 
809
855
  7. **`dataset_updated > 0` is the load-bearing freshness
810
- gate.** `rows_ingested` flips as soon as the per-row job
811
- completes, but the dataset row's `batch_id` column is
812
- updated by a downstream trigger that can lag. Filtering a
813
- `.createUserSearch` by `<base_alias>.batch_id = '<this>'`
814
- BEFORE that trigger fires returns an empty result set,
815
- and there's no way to refresh the materialised result —
816
- you'd have to POST a new search. Poll for
856
+ gate.** `rows_ingested` counts rows the contract emitted —
857
+ received, not persisted. `dataset_updated` counts the dataset
858
+ rows that point at the log row, so it moves only when a row
859
+ has been written. A `.createUserSearch` scoped to the batch
860
+ (§6.6) issued BEFORE the rows are written returns an empty
861
+ result set, and there's no way to refresh the materialised
862
+ result — you'd have to POST a new search. Poll for
817
863
  `dataset_updated > 0` before issuing the search.
818
864
 
819
865
  8. **`output_files` empty ≠ batch failed.** A log row with