@alvera-ai/platform-sdk 0.19.2 → 0.20.0-next.gcd2f231

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.
@@ -54,7 +54,7 @@ shows.
54
54
  | SMS tool, LLM tool | Shared by every workflow below |
55
55
  | Contract pair, on two clients | Identity and table row, deliberately split|
56
56
  | Three workflows | Welcome, dunning, priority triage |
57
- | Two connected apps | The portal link, and the pay link |
57
+ | Authenticated links | The portal link, and the pay link |
58
58
  | Manual-upload, bulk and REST paths| Three ways into the same table |
59
59
  | An invitation | A second person on the tenant |
60
60
 
@@ -897,38 +897,30 @@ if (unstamped.length > 0) {
897
897
  }
898
898
  ```
899
899
 
900
- ## 015 — the billing-portal connected app
900
+ ## 015 — the billing portal, and the host in front of it
901
901
 
902
- The welcome SMS carries a deep link to the self-serve billing portal. A
903
- connected app is a thin registration of that page's URL and hosting mode; the
904
- page itself lives outside the platform (`mode: 'self_hosted'`). Capture the
905
- server-derived slug — resolving the link in §019 is addressed by it.
902
+ The welcome SMS carries a link to the self-serve billing portal, which lives
903
+ outside the platform. Two addresses are in play, and neither is registered
904
+ with the platform — there is nothing to create here.
905
+
906
+ The **portal page** is the URL written into the SMS body, exactly as the
907
+ customer should end up seeing it. The **link prefix** is the host you run in
908
+ front of it: at send the platform swaps every URL in the body for
909
+ `<link_prefix>/<token>`, a link minted for this one message, and a tap on it
910
+ reaches that host first. The host asks the platform what the link is, then
911
+ sends the customer on to the portal — §019 does exactly that.
912
+
913
+ A path is allowed in the prefix; a query, a fragment or a trailing slash is
914
+ not, because the platform appends `/<token>` itself.
906
915
 
907
916
  ```typescript
908
- const portalApp = await ctx.ensure(
909
- 'billing-portal connected app',
910
- async () => {
911
- const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug, { page_size: 100 })
912
- return ctx.firstNamed(data.data, 'Cookbook Billing Portal')
913
- },
914
- async () => {
915
- const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
916
- name: 'Cookbook Billing Portal',
917
- description: 'Self-serve billing portal linked from the outbound welcome SMS.',
918
- mode: 'self_hosted',
919
- urls: [{ url: 'https://billing.example.local', is_primary: true, label: 'production' }],
920
- })
921
- return data
922
- },
923
- )
924
- connectedAppId = portalApp.id!
925
- ctx.portalAppId = portalApp.id!
926
- ctx.portalAppSlug = portalApp.slug!
917
+ ctx.billingPortalUrl = 'https://billing.example.local/portal/welcome'
918
+ ctx.billingLinkPrefix = 'https://go.billing.example.local/l'
927
919
  ```
928
920
 
929
921
  ## 016 — the Welcome SMS workflow
930
922
 
931
- Standard shape — a filter, a decision, one action — with three things worth
923
+ Standard shape — a filter, a decision, one action — with four things worth
932
924
  reading closely.
933
925
 
934
926
  `dataset_type: 'generic_table'` with `generic_table_id` makes §009's table
@@ -937,10 +929,15 @@ filter reads `event_dataset.phone` directly.
937
929
 
938
930
  `skip_mdm_resolution: false` is set explicitly, and must be. A generic-table
939
931
  row is never its own subject — the subject is the legal entity §011's pair
940
- stamped onto it. With resolution on, the platform loads that subject and
941
- mints a page token against it, which is what makes
942
- `{{ connected_app_form_url }}` a *per-customer* link. Skip it and the token
943
- is never minted and the link renders empty.
932
+ stamped onto it. With resolution on, the platform loads that subject, and
933
+ the message — and the link minted with it — names *this customer* as its
934
+ `legal_entity_id`. Skip it and the link still resolves, but it cannot say
935
+ whose portal session it opens.
936
+
937
+ The body carries §015's portal URL itself, and the action's `link_prefix`
938
+ turns it into this message's own link at send — `link_prefix` is required
939
+ on every SMS call. `metadata_template` renders once per send and rides on
940
+ the message, so the host gets the customer number back with the link.
944
941
 
945
942
  The `context_datasets` entry queries the `message` dataset for a welcome
946
943
  already sent to this legal entity in the last six months — the platform's
@@ -997,9 +994,7 @@ const welcomeWorkflow = await ctx.ensure(
997
994
  position: 0,
998
995
  trigger_template: 'now',
999
996
  idempotency_template: `{{ event_dataset.customer_number }}-${WELCOME_KEY}`,
1000
- connected_app_id: ctx.portalAppId,
1001
- connected_app_route: '/portal/welcome',
1002
- connected_app_metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
997
+ metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
1003
998
  tool_call: {
1004
999
  tool_call_type: 'sms_request',
1005
1000
  to: { type: 'custom', body: '{{ event_dataset.phone }}' },
@@ -1007,9 +1002,10 @@ const welcomeWorkflow = await ctx.ensure(
1007
1002
  type: 'custom',
1008
1003
  body:
1009
1004
  'Hi {{ event_dataset.name }}, welcome to Alvera Billing. ' +
1010
- 'Self-serve billing portal: {{ connected_app_form_url }}',
1005
+ `Self-serve billing portal: ${ctx.billingPortalUrl}`,
1011
1006
  },
1012
1007
  sms_type: 'transactional',
1008
+ link_prefix: ctx.billingLinkPrefix,
1013
1009
  },
1014
1010
  },
1015
1011
  ],
@@ -1130,11 +1126,24 @@ workflow resource is: recreate it against a lake that still holds its rows
1130
1126
  and the id changes while the key does not — the action then correctly refuses
1131
1127
  to fire, and a search for the new id finds nothing at all.
1132
1128
 
1133
- Then resolve the `/t/<token>` the platform baked into the body when it minted
1134
- the page token, and post the tracking a connected-app frontend would post
1135
- when the customer opens the portal. That closes the loop: the message went
1136
- out, the link in it resolves to this customer's page, and the open was
1137
- recorded.
1129
+ The stored body is the body as sent, so the portal URL is not in it: in its
1130
+ place stand the link prefix and a fifteen-character token. Finding the portal
1131
+ URL itself would mean no link was minted.
1132
+
1133
+ Then play the host behind the prefix. Hand the token to
1134
+ `authenticatedLinks.exchange` with what the tap's request said about itself —
1135
+ a phone's browser, because the platform judges from those details whether a
1136
+ person tapped, and only a person's tap stamps `link_clicked_at` and the
1137
+ message's `clicked_at`. The answer names the customer and carries the
1138
+ customer number on `message.metadata`. Both calls need only the tenant's
1139
+ publishable key.
1140
+
1141
+ Then the portal reports what the customer did, with the same token. The
1142
+ message status only moves forward, and `opened` ranks below `clicked`, so
1143
+ reporting `opened` after the tap leaves the status at `clicked` while
1144
+ `opened_at` still lands; `form_submitted` ranks above both, so the message
1145
+ ends there. That closes the loop: the message went out, the link in it leads
1146
+ to this customer's page, and the open and the submission were recorded.
1138
1147
 
1139
1148
  ```typescript
1140
1149
  const { data: search } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
@@ -1153,7 +1162,7 @@ while (Date.now() < msgDeadline && !welcomeBody) {
1153
1162
  })
1154
1163
  welcomeBody = ((data.data ?? []) as Array<Record<string, unknown>>)
1155
1164
  .map((m) => String(m.body ?? ''))
1156
- .find((body) => body.includes('/t/') && body.includes('welcome to Alvera Billing'))
1165
+ .find((body) => body.includes('welcome to Alvera Billing'))
1157
1166
  if (!welcomeBody) await new Promise((r) => setTimeout(r, 2_000))
1158
1167
  }
1159
1168
  if (!welcomeBody) {
@@ -1162,60 +1171,67 @@ if (!welcomeBody) {
1162
1171
  'the action never fired, or it fired under a different idempotency key',
1163
1172
  )
1164
1173
  }
1165
- const tokenMatch = welcomeBody.match(/\/t\/([A-Za-z0-9_-]+)/)
1166
- if (!tokenMatch) throw new Error(`no /t/<token> in the rendered body: ${welcomeBody}`)
1174
+ if (welcomeBody.includes(ctx.billingPortalUrl)) {
1175
+ throw new Error(`the portal URL went out as written, so no link was minted: ${welcomeBody}`)
1176
+ }
1177
+ const welcomeLinkStart = `${ctx.billingLinkPrefix}/`
1178
+ const welcomeToken = welcomeBody.split(welcomeLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
1179
+ if (!welcomeToken) throw new Error(`no ${welcomeLinkStart}<token> in the welcome body: ${welcomeBody}`)
1167
1180
 
1168
- const { data: resolved } = await api.connectedApps.resolvePage(
1169
- tenantSlug, datalakeSlug, ctx.portalAppSlug,
1170
- { short_path: tokenMatch[1]!, user_agent: 'cookbook-doctest/subscription-saas' },
1171
- )
1172
- if (resolved.route_path !== '/portal/welcome') {
1173
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
1181
+ const { data: tap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
1182
+ token: welcomeToken,
1183
+ method: 'GET',
1184
+ user_agent:
1185
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
1186
+ '(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
1187
+ headers: { 'accept-language': 'en-US' },
1188
+ })
1189
+ if (tap.destination_url !== ctx.billingPortalUrl) {
1190
+ throw new Error(`the welcome link should lead to ${ctx.billingPortalUrl}, got ${tap.destination_url}`)
1191
+ }
1192
+ if (!tap.legal_entity_id) {
1193
+ throw new Error('the welcome link names no customer — MDM resolution did not reach the message')
1174
1194
  }
1175
- if (!(resolved.message?.body ?? '').includes('welcome to Alvera Billing')) {
1176
- throw new Error('the resolved page carries the wrong message')
1195
+ if (tap.message.metadata?.customer_number !== ctx.reachNumber) {
1196
+ throw new Error(`metadata_template did not reach the message: ${JSON.stringify(tap.message.metadata)}`)
1177
1197
  }
1198
+ if (!tap.link_clicked_at || !tap.message.clicked_at) {
1199
+ throw new Error("a person's tap should stamp link_clicked_at and the message's clicked_at")
1200
+ }
1201
+ ctx.reachCustomerId = tap.legal_entity_id
1178
1202
 
1179
1203
  const now = new Date().toISOString()
1180
- const { data: tracked } = await api.connectedApps.updateMessageTracking(
1181
- tenantSlug, datalakeSlug, ctx.portalAppSlug,
1182
- { short_path: tokenMatch[1]!, opened_at: now, form_submitted_at: now },
1183
- )
1184
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
1204
+ await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1205
+ token: welcomeToken,
1206
+ status: 'opened',
1207
+ opened_at: now,
1208
+ })
1209
+ const { data: tracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1210
+ token: welcomeToken,
1211
+ status: 'form_submitted',
1212
+ form_submitted_at: now,
1213
+ })
1214
+ if (!tracked.message.opened_at || !tracked.message.form_submitted_at) {
1185
1215
  throw new Error('message tracking did not persist opened_at + form_submitted_at')
1186
1216
  }
1217
+ if (tracked.message.status !== 'form_submitted') {
1218
+ throw new Error(`the message should end at form_submitted, got ${tracked.message.status}`)
1219
+ }
1187
1220
  ```
1188
1221
 
1189
- ## 020 — the pay-portal connected app
1222
+ ## 020 — the payment page, behind the same host
1190
1223
 
1191
1224
  The dunning SMS carries a different link to a different page: not the
1192
- welcome portal but the one where an overdue invoice gets paid. A second
1193
- connected app, registered the same way.
1225
+ welcome portal but the one where an overdue invoice gets paid.
1194
1226
 
1195
- Two apps rather than two routes on one app is a deliberate choice worth
1196
- naming. A connected app is the unit a page token is minted against, so
1197
- separating them means a token issued for a payment page cannot be replayed
1198
- against the welcome page, and revoking one does not take the other down.
1227
+ **One host answers both.** The link prefix is only where taps arrive; where a
1228
+ link leads is fixed at send, from the URL written into its own message, and
1229
+ comes back from the exchange. So the payment page reuses §015's prefix, and a
1230
+ link minted for the payment page can never be made to open the welcome page
1231
+ — there is no route on the link for anyone to change.
1199
1232
 
1200
1233
  ```typescript
1201
- const payApp = await ctx.ensure(
1202
- 'pay-portal connected app',
1203
- async () => {
1204
- const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug, { page_size: 100 })
1205
- return ctx.firstNamed(data.data, 'Cookbook Payment Portal')
1206
- },
1207
- async () => {
1208
- const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
1209
- name: 'Cookbook Payment Portal',
1210
- description: 'Self-serve payment page linked from the outbound dunning SMS.',
1211
- mode: 'self_hosted',
1212
- urls: [{ url: 'https://pay.example.local', is_primary: true, label: 'production' }],
1213
- })
1214
- return data
1215
- },
1216
- )
1217
- ctx.payAppId = payApp.id!
1218
- ctx.payAppSlug = payApp.slug!
1234
+ ctx.paymentPageUrl = 'https://pay.example.local/portal/pay'
1219
1235
  ```
1220
1236
 
1221
1237
  ## 021 — the Dunning SMS workflow: two gates, not one
@@ -1282,9 +1298,7 @@ const dunningWorkflow = await ctx.ensure(
1282
1298
  position: 0,
1283
1299
  trigger_template: 'now',
1284
1300
  idempotency_template: `{{ event_dataset.customer_number }}-${DUNNING_KEY}`,
1285
- connected_app_id: ctx.payAppId,
1286
- connected_app_route: '/portal/pay',
1287
- connected_app_metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
1301
+ metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
1288
1302
  tool_call: {
1289
1303
  tool_call_type: 'sms_request',
1290
1304
  to: { type: 'custom', body: '{{ event_dataset.phone }}' },
@@ -1293,9 +1307,10 @@ const dunningWorkflow = await ctx.ensure(
1293
1307
  body:
1294
1308
  'Hi {{ event_dataset.name }}, an invoice on account ' +
1295
1309
  '{{ event_dataset.customer_number }} needs your attention. ' +
1296
- 'Pay now: {{ connected_app_form_url }}',
1310
+ `Pay now: ${ctx.paymentPageUrl}`,
1297
1311
  },
1298
1312
  sms_type: 'transactional',
1313
+ link_prefix: ctx.billingLinkPrefix,
1299
1314
  },
1300
1315
  },
1301
1316
  ],
@@ -1372,12 +1387,12 @@ if ((byStatus.filtered ?? 0) !== 1) {
1372
1387
  }
1373
1388
  ```
1374
1389
 
1375
- ## 023 — the reminder went out, and it is a different message on a different app
1390
+ ## 023 — the reminder went out, and it is a different message to a different page
1376
1391
 
1377
1392
  Same read-back idiom as §019, and worth doing twice for one reason: it proves
1378
1393
  the two workflows are genuinely independent. The same customer received two
1379
- messages, under two idempotency keys, carrying two links that resolve against
1380
- two connected apps and two routes.
1394
+ messages, under two idempotency keys, carrying two links with two different
1395
+ tokens that lead to two different pages — and both name the same customer.
1381
1396
 
1382
1397
  The key here is `<customer number>-send_dunning_sms`, and it names a row that
1383
1398
  persists in the lake long after the run that made it. That is what a
@@ -1400,7 +1415,7 @@ while (Date.now() < msgDeadline && !dunningBody) {
1400
1415
  })
1401
1416
  dunningBody = ((data.data ?? []) as Array<Record<string, unknown>>)
1402
1417
  .map((m) => String(m.body ?? ''))
1403
- .find((body) => body.includes('/t/') && body.includes('needs your attention'))
1418
+ .find((body) => body.includes('needs your attention'))
1404
1419
  if (!dunningBody) await new Promise((r) => setTimeout(r, 2_000))
1405
1420
  }
1406
1421
  if (!dunningBody) {
@@ -1410,15 +1425,25 @@ if (!dunningBody.includes(ctx.reachNumber)) {
1410
1425
  throw new Error(`the dunning body does not name the overdue account: ${dunningBody}`)
1411
1426
  }
1412
1427
 
1413
- const token = dunningBody.match(/\/t\/([A-Za-z0-9_-]+)/)
1414
- if (!token) throw new Error(`no /t/<token> in the dunning body: ${dunningBody}`)
1428
+ if (dunningBody.includes(ctx.paymentPageUrl)) {
1429
+ throw new Error(`the payment page went out as written, so no link was minted: ${dunningBody}`)
1430
+ }
1431
+ const dunningToken = dunningBody.split(`${ctx.billingLinkPrefix}/`)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
1432
+ if (!dunningToken) throw new Error(`no ${ctx.billingLinkPrefix}/<token> in the dunning body: ${dunningBody}`)
1415
1433
 
1416
- const { data: resolved } = await api.connectedApps.resolvePage(
1417
- tenantSlug, datalakeSlug, ctx.payAppSlug,
1418
- { short_path: token[1]!, user_agent: 'cookbook-doctest/subscription-saas' },
1419
- )
1420
- if (resolved.route_path !== '/portal/pay') {
1421
- throw new Error(`the dunning link resolved to the wrong route: ${resolved.route_path}`)
1434
+ const { data: tap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
1435
+ token: dunningToken,
1436
+ method: 'GET',
1437
+ user_agent:
1438
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
1439
+ '(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
1440
+ headers: { 'accept-language': 'en-US' },
1441
+ })
1442
+ if (tap.destination_url !== ctx.paymentPageUrl) {
1443
+ throw new Error(`the dunning link should lead to ${ctx.paymentPageUrl}, got ${tap.destination_url}`)
1444
+ }
1445
+ if (tap.legal_entity_id !== ctx.reachCustomerId) {
1446
+ throw new Error(`both links should name the same customer (${ctx.reachCustomerId}), got ${tap.legal_entity_id}`)
1422
1447
  }
1423
1448
  ```
1424
1449
 
@@ -1596,6 +1621,11 @@ band. Whichever band comes back picks one and leaves the other two
1596
1621
  hyphens, and Liquid's dot parser would read
1597
1622
  `additional_context.cookbook-priority-triage-agent` as a subtraction.
1598
1623
 
1624
+ `link_prefix` is required on every SMS call, these three included, though
1625
+ their bodies carry no URL — a body with none is sent unchanged. Leave it off
1626
+ and the workflow is refused at create with a 422 on
1627
+ `/actions/<n>/tool_call/link_prefix`.
1628
+
1599
1629
  ```typescript
1600
1630
  const BANDS = ['priority_high', 'priority_medium', 'priority_low'] as const
1601
1631
 
@@ -1646,6 +1676,7 @@ const triageWorkflow = await ctx.ensure(
1646
1676
  body: `[${band}] {{ event_dataset.name }}, a note about account {{ event_dataset.customer_number }}.`,
1647
1677
  },
1648
1678
  sms_type: 'transactional' as const,
1679
+ link_prefix: ctx.billingLinkPrefix,
1649
1680
  },
1650
1681
  })),
1651
1682
  workflow_ai_agents: [
@@ -2369,8 +2400,8 @@ After all forty-one steps run green, on one tenant and **one raw datalake**:
2369
2400
  reachability *and* KYC, and the triage workflow hands the question to a
2370
2401
  model and fans out on its answer.
2371
2402
  - **Three outbound messages** are readable from the `message` dataset, each
2372
- under its own idempotency key, two carrying deep links that resolve against
2373
- two separate connected apps on two separate routes.
2403
+ under its own idempotency key, two carrying authenticated links — one host,
2404
+ two tokens, two pages, one customer named by both.
2374
2405
  - **A second person** is on the tenant with a `member` role, invited and
2375
2406
  accepted entirely through the SDK.
2376
2407
 
@@ -2401,4 +2432,4 @@ Four things this walk teaches that are easy to learn the expensive way:
2401
2432
  and `workflows.run`
2402
2433
  - `../data_activation_clients.md` — data source → tool → contract → client
2403
2434
  - `../interoperability_contracts.md` — `template_config` vs `mdm_input_config`
2404
- - `../connected_apps.md` — registration, `resolvePage`, message tracking
2435
+ - `../authenticated_links.md` — `link_prefix`, `exchange`, message tracking
@@ -377,7 +377,7 @@ Three run shapes exist, selected by the bound tool's
377
377
  All three produce **log rows** observable through
378
378
  `api.dataActivationClients.logs.*` (§6.4), and the canonical way
379
379
  to **verify** that ingestion worked is the two-step
380
- dataset-search pattern in §6.5.
380
+ dataset-search pattern in §6.6.
381
381
 
382
382
  ### 6.1 Inline JSON ingestion — `.ingest`
383
383
 
@@ -397,7 +397,7 @@ const { data } = await api.dataActivationClients.ingest(
397
397
  The response returns **immediately after enqueueing**, not
398
398
  after ingestion completes. To know when the row has actually
399
399
  been written, poll the logs (§6.4) or use the verification
400
- pattern (§6.5).
400
+ pattern (§6.6).
401
401
 
402
402
  The `batch_id` is the most important field on the response —
403
403
  every downstream log row and dataset row carries the same
@@ -534,7 +534,110 @@ merge step has committed the ndjson archive. Use
534
534
  `output_files.length > 0` as the "this batch's rows are fully
535
535
  durable" signal — not just the presence of the log row.
536
536
 
537
- ### 6.5 Verifying ingestion: the two-step dataset search
537
+ ### 6.5 Runs subresource — `.batches.list` / `.batches.get`
538
+
539
+ A **run** is one record; the log rows of §6.4 are one record per
540
+ dataset table that run wrote into. Read the run to answer *"is this
541
+ over, and how did it end"*; read the logs for the per-table detail,
542
+ filtered on the same `batch_id`.
543
+
544
+ Before this record existed, the only way to ask whether a run had
545
+ finished was to count log rows and guess — which cannot tell a run
546
+ still going from one that produced nothing and never will. A run
547
+ that was skipped or that died looked exactly like a slow one until
548
+ the caller's timeout expired.
549
+
550
+ ```
551
+ batch_id UUID — the run identifier, the same one `.ingest`,
552
+ `.ingestFile` and `.runManually` hand back, and the
553
+ one stamped on every per-table log row of this run
554
+ client_id UUID — the owning activation client
555
+ closed_at ISO timestamp | null — when the run was attempted and
556
+ exhausted. Null while it is still going. It does NOT
557
+ mean rows landed and it does NOT mean the archive was
558
+ merged — read `status` for the outcome, and only once
559
+ this is set.
560
+ status 'processing' | 'succeeded' | 'partial' | 'skipped' | 'failed'
561
+ rows_total number — source rows enqueued for this run. The run's own
562
+ count; each per-table log keeps its own.
563
+ inserted_at ISO timestamp — when the run started
564
+ ```
565
+
566
+ **`status` means nothing until `closed_at` is set.** A run in flight
567
+ always reads `processing`. Once it is closed:
568
+
569
+ | status | what happened |
570
+ |---|---|
571
+ | `succeeded` | it ran and nothing was refused |
572
+ | `partial` | it ran and at least one row was refused on the way in; the rows that landed are still there |
573
+ | `skipped` | it ran and there was nothing to do — empty file, every row filtered out, or the client has no contracts |
574
+ | `failed` | it died before writing anything |
575
+
576
+ ```typescript
577
+ // Paginated list of runs for an activation client. Takes the full Flop
578
+ // query (page / page_size / order_by / order_directions / filters —
579
+ // type_naming.md); without it, page 1 only.
580
+ const { data } = await api.dataActivationClients.batches.list(
581
+ tenantSlug, datalakeSlug, activationClientSlug,
582
+ { page: 1, page_size: 50, order_by: ['inserted_at'], order_directions: ['desc'] },
583
+ )
584
+ // data.data — array of run records; data.meta — pagination
585
+
586
+ // Fetch one run by its batch_id.
587
+ const { data: run } = await api.dataActivationClients.batches.get(
588
+ tenantSlug, datalakeSlug, activationClientSlug, batchId,
589
+ )
590
+ ```
591
+
592
+ > **The fourth argument is the `batch_id`, not the record's `id`.**
593
+ > A run record carries both, and both are UUIDs. The route looks the
594
+ > run up by its `batch_id` — the identifier the ingest handed you — so
595
+ > passing the record's own `id` is a **404**, not a type error.
596
+
597
+ #### Waiting on a run
598
+
599
+ This is what the run record is for. Poll until `closed_at` is set,
600
+ then branch on `status` — never poll `status` alone, because a run
601
+ in flight reads `processing` forever and tells you nothing:
602
+
603
+ ```typescript
604
+ const { data: ingest } = await api.dataActivationClients.ingest(
605
+ tenantSlug, datalakeSlug, activationClientSlug, { data: row },
606
+ )
607
+
608
+ const deadline = Date.now() + 120_000
609
+ let run
610
+ for (;;) {
611
+ const { data } = await api.dataActivationClients.batches.get(
612
+ tenantSlug, datalakeSlug, activationClientSlug, ingest.batch_id,
613
+ )
614
+ if (data.closed_at != null) { run = data; break }
615
+ if (Date.now() > deadline) throw new Error(`run ${ingest.batch_id} never closed`)
616
+ await new Promise((r) => setTimeout(r, 1_000))
617
+ }
618
+
619
+ if (run.status === 'failed') {
620
+ throw new Error(`run ${ingest.batch_id} failed before writing anything`)
621
+ }
622
+ if (run.status === 'skipped') {
623
+ throw new Error(`run ${ingest.batch_id} had nothing to do — check the row filter and the client's contracts`)
624
+ }
625
+ // 'succeeded' or 'partial' — the run is over and rows were written.
626
+ // For a partial, read the log rows (§6.4) for which table refused what.
627
+ ```
628
+
629
+ A run that failed or was skipped **ends the wait immediately, carrying
630
+ why**. That is the whole difference from counting rows: a blind poll
631
+ spends its full timeout and then reports nothing about the cause.
632
+
633
+ > **`closed_at` is not the same as "my row is queryable".** It says the
634
+ > run is over. It does not say the archive was merged (that is
635
+ > `output_files` on the log rows, §6.4), and for one specific row it
636
+ > does not replace the landing check in §6.6 — it makes that check a
637
+ > single read after the run is known finished, instead of a guess
638
+ > against a deadline.
639
+
640
+ ### 6.6 Verifying ingestion: the two-step dataset search
538
641
 
539
642
  > **Simplest landing check — `executeSql`.** Ingestion is async (the row
540
643
  > commits a moment after the `202`). When you just need to know a row
@@ -701,7 +804,7 @@ screen. Pick the mode by which of those two readers you are serving.
701
804
  response carries `batch_id` + `jobs_count`, signalling that
702
805
  downstream jobs have been enqueued — not that they've
703
806
  completed. Poll logs (or use the search verification
704
- pattern in §6.5) before treating the row as ingested.
807
+ pattern in §6.6) before treating the row as ingested.
705
808
 
706
809
  7. **`dataset_updated > 0` is the load-bearing freshness
707
810
  gate.** `rows_ingested` flips as soon as the per-row job
@@ -110,7 +110,7 @@ The platform does NOT expose a query API over the archive itself —
110
110
  cold reads happen externally so the platform's processes aren't
111
111
  responsible for analytical workloads. Live data lives in the
112
112
  datalake's Postgres halves (queryable via the dataset search
113
- two-step pattern in `data_activation_clients.md` §6.5); historical
113
+ two-step pattern in `data_activation_clients.md` §6.6); historical
114
114
  data lives in the cloud-storage buckets configured here.
115
115
 
116
116
  ### Canonical create body
@@ -299,7 +299,7 @@ in the datalake and there is nothing to search afterwards. The
299
299
  contract's `resource_type` destination) runs only when the DAC
300
300
  worker ingests a row — i.e. on `dataActivationClients.ingest`, not
301
301
  on `.run`. To prove a row *landed*, ingest and then query by
302
- `batch_id` (see `data_activation_clients.md` §6.5).
302
+ `batch_id` (see `data_activation_clients.md` §6.6).
303
303
 
304
304
  ```typescript
305
305
  const { data: result } = await api.interoperabilityContracts.run(
package/.agent/mdm.md CHANGED
@@ -26,10 +26,10 @@ master records.
26
26
  ## 1. Wire shape — `.verify`
27
27
 
28
28
  Verify that a person on the other end of a workflow action or a
29
- connected-app form submission matches an existing subject in the
29
+ form behind an authenticated link matches an existing subject in the
30
30
  datalake. Used at action time (workflows confirming an SMS
31
- recipient) and at runtime (connected apps confirming the form
32
- submitter before posting tracking updates).
31
+ recipient) and at runtime (the host behind `link_prefix` confirming
32
+ the form submitter before posting tracking updates).
33
33
 
34
34
  Every MDM subject is ultimately a **person** or a **company**,
35
35
  and the request type is that ontology's identity vocabulary. You
@@ -339,7 +339,7 @@ SDK surface:
339
339
 
340
340
  Today there is no `.list`, `.get`, or `.metadata` on the MDM
341
341
  namespace — subjects themselves are queried through the dataset
342
- search surface (see `data_activation_clients.md` §6.5), not
342
+ search surface (see `data_activation_clients.md` §6.6), not
343
343
  through `api.mdm`.
344
344
 
345
345
  ## 6. Gotchas
@@ -44,7 +44,12 @@ alias: `m`.
44
44
  invalidated (idempotency key collision — the fire was
45
45
  rejected, nothing went out)
46
46
  received (inbound message, direction: 'inbound')
47
- clicked (link in the message was clicked)
47
+
48
+ engagement, each with its own time:
49
+ read read_at
50
+ opened opened_at the link host's page opened
51
+ clicked clicked_at a person first tapped a link
52
+ form_submitted form_submitted_at the link host's form was submitted
48
53
  ```
49
54
 
50
55
  `accepted` (the provider took the send request) and `sending`
@@ -53,13 +58,14 @@ and `sent`; the order above is the order the contract declares them.
53
58
  Treat any of them as "in flight, outcome unknown" — see **Status is
54
59
  eventually honest** below.
55
60
 
56
- Three different writers touch a message row. Know which is which:
61
+ Four different writers touch a message row. Know which is which:
57
62
 
58
63
  | Writer | Writes | When |
59
64
  |---|---|---|
60
65
  | the action executor | the row itself; `pending`→`sent`, `external_id`, `sent_at`; also `duplicate` | at execution (writes the row at `pending`; advances to `sent` + sets `external_id` only on a live tool call — a dry-run stops at `pending`). Lands `duplicate` when the provider id it got back is already held by another send through the same tool |
61
66
  | the action status updater | `delivered` / `failed`, `delivered_at`, `failure_reason` | later, when its poll runs (see `action_status_updaters.md` §7) |
62
- | `connectedApps.updateMessageTracking` | `opened_at`, `form_submitted_at` — **never `status`** | when the recipient opens/submits the magic-link page |
67
+ | `authenticatedLinks.exchange` | `clicked`, `clicked_at` (the first person's tap only) | when the host behind `link_prefix` forwards a tap; automated taps move nothing |
68
+ | `authenticatedLinks.updateMessageTracking` | `opened` / `form_submitted`, `opened_at`, `form_submitted_at` | when the host reports its page opened or its form submitted; status only moves forward |
63
69
 
64
70
  Two consequences:
65
71
 
@@ -95,21 +95,21 @@ flowchart TD
95
95
  end
96
96
  ```
97
97
 
98
- **It lives in this repo**, at `cloudflare-workers/mock/`. Deploy from a normal checkout:
98
+ **It lives in this repo**, at `cloudflare/workers/mock/`. Deploy from a normal checkout:
99
99
 
100
100
  ```bash
101
- cd cloudflare-workers/mock && wrangler deploy
101
+ cd cloudflare/workers/mock && wrangler deploy
102
102
  ```
103
103
 
104
104
  The single line that makes this work without a vendored copy of the corpus:
105
105
 
106
106
  ```jsonc
107
- "image_build_context": ".."
107
+ "image_build_context": "../../.."
108
108
  ```
109
109
 
110
110
  That sets the Docker build context to the repo root, so the Dockerfile copies
111
111
  `wm_mappings/` and `wm_response_files/` from where they already live. Without it
112
- the context is `cloudflare-workers/mock/` and the corpus would have to be duplicated there —
112
+ the context is `cloudflare/workers/mock/` and the corpus would have to be duplicated there —
113
113
  two directories that look identical until they quietly aren't.
114
114
 
115
115
  ### There is no authentication
@@ -190,4 +190,3 @@ flip.
190
190
 
191
191
  - [Tools](tools.md) — tool bodies, `endpoint_url`, and REST API auth methods
192
192
  - [Datalakes](datalakes.md) — how datalake provisioning uses MiniStack
193
- - [Connected Apps](connected_apps.md) — the third-party integrations the corpus stands in for
@@ -12,7 +12,7 @@ everything:
12
12
  downstream references — never pre-compute a server-derived field yourself.
13
13
 
14
14
  Non-CRUD methods are out of scope for this page — RESTful sub-actions
15
- (`.syncRoutes`, `.resolvePage`, `.testInvocation`), artifact issuers
15
+ (`.testInvocation`, `.refresh`), artifact issuers
16
16
  (`.createUploadLink`, `.createDownloadLink`), and async triggers (`.run`,
17
17
  `.execute`, `.ingest`) each document their own semantics in their resource's MD.
18
18
 
@@ -53,9 +53,9 @@ A small set of methods accept a narrow partial body instead of replacing the
53
53
  whole resource. Each is named here and documents its partial-body contract in
54
54
  its TypeScript method's JSDoc and its per-resource MD §5 (Lifecycle):
55
55
 
56
- | SDK method | Resource MD |
57
- |---------------------------------------------|----------------------|
58
- | `api.connectedApps.updateMessageTracking` | `connected_apps.md` |
56
+ | SDK method | Resource MD |
57
+ |--------------------------------------------------|---------------------------|
58
+ | `api.authenticatedLinks.updateMessageTracking` | `authenticated_links.md` |
59
59
 
60
60
  For every other method, the default holds: `update()` replaces the whole
61
61
  resource.