@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 +13 -7
- package/.agent/action_status_updaters.md +14 -14
- package/.agent/async.md +4 -3
- package/.agent/cookbook/organic-marketing.md +104 -23
- package/.agent/cookbook/payments-compliance.md +27 -8
- package/.agent/cookbook/primary-care.md +6 -6
- package/.agent/cookbook/subscription-saas.md +10 -5
- package/.agent/data_activation_clients.md +79 -33
- package/.agent/generic_tables.md +35 -10
- package/.agent/interoperability_contracts.md +4 -4
- package/.agent/templates.md +6 -1
- package/.agent/tools.md +16 -8
- package/.agent/workflows.md +60 -2
- package/dist/index.d.mts +60 -13
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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' | '
|
|
559
|
-
last_run_error why the last run
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
50
|
-
and
|
|
51
|
-
|
|
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
|
-
|
|
|
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:
|
|
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:
|
|
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
|
|
1593
|
+
## 032 — the inbound-messages table, which the app creates
|
|
1584
1594
|
|
|
1585
|
-
Replies land in `inbound_messages
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
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
|
-
§
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
404
|
-
|
|
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
|
|
494
|
-
(
|
|
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 —
|
|
643
|
-
> commits a moment after the `202`). When you just need to know
|
|
644
|
-
> *landed*,
|
|
645
|
-
> batch_id
|
|
646
|
-
>
|
|
647
|
-
> the
|
|
648
|
-
>
|
|
649
|
-
>
|
|
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:
|
|
653
|
-
>
|
|
654
|
-
>
|
|
655
|
-
>
|
|
656
|
-
> //
|
|
657
|
-
> //
|
|
658
|
-
>
|
|
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
|
|
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.
|
|
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
|
-
|
|
740
|
-
|
|
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`
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
`.createUserSearch`
|
|
814
|
-
BEFORE
|
|
815
|
-
and there's no way to refresh the materialised
|
|
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
|