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

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
@@ -93,7 +93,7 @@ corpus page documents the surface you already know:
93
93
  | Sampling | Dataset event triggers | `data_activation_clients.md` §6.4 logs |
94
94
  | Tools | Tool Calls (polymorphic body)| `tools.md` |
95
95
  | Roots | Datalake boundaries | `datalakes.md` |
96
- | Human-in-the-loop | Connected Apps + page tokens | `connected_apps.md` |
96
+ | Human-in-the-loop | Authenticated links | `authenticated_links.md` |
97
97
  | Sandboxed execution | Liquid + SQL fragment lanes | `ai_sandbox.md` |
98
98
 
99
99
  A few caveats on the mapping:
@@ -204,9 +204,9 @@ admin/bootstrap flows (signup, confirmation, tenant creation,
204
204
  test-suite setup) — they are NOT app-building surfaces; an app that
205
205
  holds a root or tenantless Bearer is misdesigned. An app
206
206
  authenticates against exactly one tenant with that tenant's
207
- publishable key. Likewise, connected apps are **single-tenant by
208
- construction** — one connected app per tenant, never one deployment
209
- serving many tenants (see `connected_apps.md`).
207
+ publishable key. Likewise, the host behind an action's
208
+ `link_prefix` resolves its links against one tenant, on that tenant's
209
+ publishable key (see `authenticated_links.md`).
210
210
 
211
211
  Consumers holding multiple concurrent clients in the same file
212
212
  (e.g. integration tests that need root + tenantless + tenant-
@@ -248,7 +248,6 @@ numbered steps inside the cookbook whose use case motivates it.
248
248
  consumed by other resources.
249
249
  ai_agents.md LLM-backed workers.
250
250
  action_status_updaters.md Polling reconcilers.
251
- connected_apps.md External app bridges.
252
251
  mdm.md Entity resolution + identity
253
252
  verification across domains.
254
253
  ai_sandbox.md Three-layer safety contract
@@ -279,13 +278,16 @@ numbered steps inside the cookbook whose use case motivates it.
279
278
  action_logs.md The action_logs DATASET — per-
280
279
  action outcomes, batch_id joins,
281
280
  audience export.
281
+ authenticated_links.md Every URL a message carries, as
282
+ <link_prefix>/<token>; the host
283
+ behind the prefix resolves taps
284
+ and reports the page.
282
285
  ```
283
286
 
284
287
  Axes are mostly disjoint but a few resources cross. AI Agents
285
288
  anchor INFRASTRUCTURE and appear in DATA ACTIVATION (they
286
289
  tokenize during ingestion) and WORKFLOWS (workflows invoke
287
- them). Connected Apps anchor INFRASTRUCTURE and tag into
288
- WORKFLOWS.
290
+ them).
289
291
 
290
292
  Each resource is exposed via a TypeScript namespace on the
291
293
  typed client. The SDK namespace name follows TS conventions
@@ -299,13 +301,13 @@ wire name (snake_case):
299
301
  api.tools tools.md
300
302
  api.aiAgents ai_agents.md
301
303
  api.actionStatusUpdaters action_status_updaters.md
302
- api.connectedApps connected_apps.md
303
304
  api.mdm mdm.md
304
305
  api.genericTables generic_tables.md
305
306
  api.advancedMigrations advanced_migrations.md
306
307
  api.interoperabilityContracts interoperability_contracts.md
307
308
  api.dataActivationClients data_activation_clients.md
308
309
  api.workflows workflows.md
310
+ api.authenticatedLinks authenticated_links.md
309
311
 
310
312
  Two corpus pages document DATASETS, not namespaces — read
311
313
  through `api.datasets` with SINGULAR dataset types (the plural
@@ -99,15 +99,16 @@ a tenant (creator path) or accept an invitation (invitee path).
99
99
  are platform-UI / admin-bootstrap surfaces (signup, confirmation,
100
100
  tenant creation, test setup) — never ship them inside an app. An
101
101
  app authenticates **tenant-scoped against exactly one tenant**,
102
- with that tenant's publishable key; connected apps in particular
103
- are single-tenant by construction (`connected_apps.md`).
102
+ with that tenant's publishable key; the host behind an action's
103
+ `link_prefix` in particular resolves links for one tenant
104
+ (`authenticated_links.md`).
104
105
 
105
106
  ### Key-only clients — machine-to-machine on the publishable key
106
107
 
107
- `sessionToken` is optional on the client config. A deployed
108
- connected app that authenticates purely on its tenant's publishable
109
- key constructs with just the key — no session mint, no fabricated
110
- empty token:
108
+ `sessionToken` is optional on the client config. The host behind an
109
+ action's `link_prefix`, authenticating purely on its tenant's
110
+ publishable key, constructs with just the key — no session mint, no
111
+ fabricated empty token:
111
112
 
112
113
  ```typescript
113
114
  const api = createIsolatedPlatformApi({ baseUrl, apiKey })
@@ -118,15 +119,15 @@ and reaches exactly the `public_api` (publishable-key) allowlist —
118
119
  five calls:
119
120
 
120
121
  ```
121
- api.connectedApps.resolvePage mint/read a delivered page
122
- api.connectedApps.updateMessageTracking opened_at / form_submitted_at
123
- api.dataActivationClients.ingest submit rows
124
- api.dataActivationClients.ingestFile submit a file
125
- api.mdm.verify resolve an identity
122
+ api.authenticatedLinks.exchange a tap on a link
123
+ api.authenticatedLinks.updateMessageTracking opened / form_submitted
124
+ api.dataActivationClients.ingest submit rows
125
+ api.dataActivationClients.ingestFile submit a file
126
+ api.mdm.verify resolve an identity
126
127
  ```
127
128
 
128
- (The same allowlist `connected_apps.md` documents — that guide owns
129
- the runtime-call details.) Every call outside these five returns
129
+ (`authenticated_links.md` owns the details of the two link calls.)
130
+ Every call outside these five returns
130
131
  401; that ceiling is the point, not an error to work around.
131
132
  Minting a session and passing `sessionToken` unlocks the full
132
133
  tenant-scoped API exactly as before — the two credentials are
@@ -635,9 +635,10 @@ is the part that matters:
635
635
  decision (`mode: 'live'`, `manual_override: true`) — the same
636
636
  call as any manual dispatch; there is no retry endpoint. See
637
637
  `workflows.md` §8.
638
- - **Engagement is not here either.** `opened_at` /
639
- `form_submitted_at` arrive via
640
- `connectedApps.updateMessageTracking` (magic-link seam,
641
- `connected_apps.md` §6) and never touch `status`.
638
+ - **Engagement is not here either.** `clicked` arrives via
639
+ `authenticatedLinks.exchange`, and `opened` / `form_submitted`
640
+ with their times via `authenticatedLinks.updateMessageTracking`
641
+ — both called by the host behind the action's `link_prefix`
642
+ (`authenticated_links.md`).
642
643
  - Field-by-field semantics of the rows being updated:
643
644
  `messages.md`, `action_logs.md`.
@@ -157,7 +157,7 @@ trail this trap leaves.
157
157
 
158
158
  SQL is the second consumer-author surface: WHERE clauses on
159
159
  dataset searches (`api.datasets.createUserSearch.search_query`,
160
- see `data_activation_clients.md` §6.5). Liquid renders some of
160
+ see `data_activation_clients.md` §6.6). Liquid renders some of
161
161
  these from templates; others arrive directly as raw SQL
162
162
  fragments. Either path lands at the same boundary.
163
163
 
@@ -165,7 +165,7 @@ fragments. Either path lands at the same boundary.
165
165
 
166
166
  - SELECT against the tables exposed by the dataset search aliases
167
167
  (`le` for legal entities, `m` for messages, `al` for action logs
168
- — see `data_activation_clients.md` §6.5 SQL aliases).
168
+ — see `data_activation_clients.md` §6.6 SQL aliases).
169
169
  - Filter results into the platform's `search_results`
170
170
  accumulator scoped to the materialised search id. This is the
171
171
  one write the query path performs, and the platform builds it
@@ -305,5 +305,5 @@ guarantees it captures back-stop Layer 1.
305
305
  - `account_management.md` §3 — session role vocabularies (Layer 1)
306
306
  - `interoperability_contracts.md` §2 — template_config body shape (Layer 2)
307
307
  - `workflows.md` gotcha 9 — the `| to_json` vs `| json` trap (Layer 2)
308
- - `data_activation_clients.md` §6.5 — dataset search SQL aliases (Layer 3)
308
+ - `data_activation_clients.md` §6.6 — dataset search SQL aliases (Layer 3)
309
309
  - `mdm.md` — verification surface that composes with Layer 1
@@ -0,0 +1,247 @@
1
+ # Authenticated links
2
+
3
+ Every URL in an SMS or MMS body, and every `href` in an email body, goes out
4
+ as an **authenticated link**:
5
+
6
+ ```
7
+ <link_prefix>/<token>
8
+ ```
9
+
10
+ `link_prefix` is set on the action's tool call. It is the address of a host
11
+ **you** run. The platform appends only the token; any path belongs to the
12
+ prefix. When a person taps the link, the request reaches your host, and your
13
+ host asks the platform what the link is with one call and reports what the
14
+ person did on the page with a second.
15
+
16
+ SDK namespace: `api.authenticatedLinks` — `exchange` and
17
+ `updateMessageTracking`.
18
+
19
+ ```
20
+ workflow action (sms_request · mms_request · email_request)
21
+ link_prefix = "https://go.example.com/l"
22
+ body = "Book a slot: https://booking.example.com/slots"
23
+ │ send
24
+ ▼
25
+ delivered "Book a slot: https://go.example.com/l/q3v9RkZ0aB7xYtL"
26
+ │ a person taps
27
+ ▼
28
+ your host at go.example.com/l
29
+ exchange({ token, method, user_agent, ip, country, headers })
30
+ → destination_url "https://booking.example.com/slots",
31
+ legal_entity_id, workflow, run, action, expires_at, message
32
+ send the person on to destination_url
33
+ │ the page opens · the form is submitted
34
+ ▼
35
+ updateMessageTracking({ token, status: 'opened', opened_at })
36
+ updateMessageTracking({ token, status: 'form_submitted', form_submitted_at })
37
+ ```
38
+
39
+ **Both calls need only the tenant's publishable key.** The host is a key-only
40
+ client: no session, no user.
41
+
42
+ ```typescript
43
+ import { createIsolatedPlatformApi } from '@alvera-ai/platform-sdk'
44
+
45
+ const links = createIsolatedPlatformApi({
46
+ baseUrl: 'https://api.alvera.ai',
47
+ apiKey: 'pk_live_tenant_publishable_key',
48
+ })
49
+ ```
50
+
51
+ This replaces Connected Apps and page tokens, which the platform retired:
52
+ there is no app to register, no route discovery, no `resolvePage`, and no
53
+ `{{ connected_app_form_url }}`. Write the real destination URL into the
54
+ message body; the platform turns it into an authenticated link at send.
55
+
56
+ ## 1. Wire shape
57
+
58
+ ### On the action — set when you build the workflow
59
+
60
+ | Field | Where | Shape |
61
+ |---|---|---|
62
+ | `link_prefix` | `SmsCallRequest`, `MmsCallRequest`, `EmailCallRequest` | **required** string — the address of your host |
63
+ | `link_validity_days` | the same three | optional whole number, at least 1, default **365** — how many days each link resolves after the send |
64
+ | `metadata_template` | every action type | optional Liquid template rendered to JSON once per send |
65
+
66
+ ```typescript
67
+ import type { AgenticWorkflowRequestWritable } from '@alvera-ai/platform-sdk'
68
+
69
+ type Action = NonNullable<AgenticWorkflowRequestWritable['actions']>[number]
70
+
71
+ const reviewRequest: Action['tool_call'] = {
72
+ tool_call_type: 'sms_request',
73
+ sms_type: 'transactional',
74
+ to: { type: 'custom', body: '{{ event_dataset.phone }}' },
75
+ body: {
76
+ type: 'custom',
77
+ body: 'Thanks for visiting. Tell us how it went: https://reviews.example.com/visit',
78
+ },
79
+ link_prefix: 'https://go.example.com/l',
80
+ link_validity_days: 30,
81
+ }
82
+ ```
83
+
84
+ ### `exchange` — a tap on the link
85
+
86
+ ```typescript
87
+ import type { AuthenticatedLinkExchangeRequest } from '@alvera-ai/platform-sdk'
88
+
89
+ const tap: AuthenticatedLinkExchangeRequest = {
90
+ token: 'q3v9RkZ0aB7xYtL', // required: from <link_prefix>/<token>
91
+ method: 'GET', // the tap's HTTP method
92
+ user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
93
+ ip: '203.0.113.7',
94
+ country: 'US',
95
+ headers: { 'accept-language': 'en-US' },
96
+ }
97
+ ```
98
+
99
+ ### `updateMessageTracking` — what happened on the page
100
+
101
+ ```typescript
102
+ import type { AuthenticatedLinkUpdateMessageTrackingRequest } from '@alvera-ai/platform-sdk'
103
+
104
+ const submitted: AuthenticatedLinkUpdateMessageTrackingRequest = {
105
+ token: 'q3v9RkZ0aB7xYtL', // required: the same token
106
+ status: 'form_submitted', // typically 'opened' or 'form_submitted'
107
+ form_submitted_at: '2026-10-04T10:00:00Z',
108
+ }
109
+ ```
110
+
111
+ ### The answer — `AuthenticatedLinkResponse`, from both calls
112
+
113
+ | Field | Meaning |
114
+ |---|---|
115
+ | `destination_url` | the URL as written in the message |
116
+ | `legal_entity_id` | the recipient's legal entity, when MDM resolved one — else `null` |
117
+ | `message_id` | the message the link went out in |
118
+ | `workflow_id` | the workflow that sent it |
119
+ | `workflow_execution_log_id` | the workflow run that sent it |
120
+ | `action_id` | the action that sent it |
121
+ | `expires_at` | when the link stops resolving |
122
+ | `link_clicked_at` | when a person first clicked this link — `null` until then |
123
+ | `message` | the message, a `MessageResponse` |
124
+
125
+ `message.metadata` holds the recipient's `to` merged with what the action's
126
+ `metadata_template` rendered, as stored at send. The message carries each
127
+ engagement status with its own time: `read` (`read_at`), `opened`
128
+ (`opened_at`), `clicked` (`clicked_at`) and `form_submitted`
129
+ (`form_submitted_at`).
130
+
131
+ ## 2. Rules the type cannot encode
132
+
133
+ ### `link_prefix` is an absolute URL with nothing after the path
134
+
135
+ An absolute `http` or `https` URL. A path is allowed —
136
+ `https://go.example.com/l` — but no query, no fragment and **no trailing
137
+ slash**: the platform appends `/<token>` itself.
138
+
139
+ ### A person's tap moves the message to `clicked`; a machine's does not
140
+
141
+ `exchange` answers every tap, but only a person's moves anything. The first
142
+ click stamps the link's `link_clicked_at` and the message's `clicked_at`;
143
+ later taps keep both. The platform judges a tap from what your host forwards,
144
+ and these look automated and move nothing:
145
+
146
+ - a `HEAD` request;
147
+ - a prefetch header — `sec-purpose`, `purpose`, `x-purpose`, `x-moz`;
148
+ - an empty user agent, or one naming a bot, a link preview or a mail scanner.
149
+
150
+ Every tap is recorded with the details you forwarded and the signal that
151
+ flagged it, if one did. Headers are recorded as client headers — never cookies
152
+ or credentials.
153
+
154
+ ### The message status only moves forward
155
+
156
+ `updateMessageTracking` applies its `status` through the message's order
157
+ guard: reporting `opened` after `form_submitted` leaves the message at
158
+ `form_submitted`. A time you leave empty keeps the message's value. The
159
+ `status` field is a plain string on the type; one a message cannot have is
160
+ refused with `422`.
161
+
162
+ ### A link answers until it expires
163
+
164
+ Each link resolves for `link_validity_days` after the send, then answers
165
+ `410`. The response's `expires_at` says when.
166
+
167
+ ### `metadata_template` renders once, at send
168
+
169
+ It renders to JSON once per send, on every action type, and is stored on the
170
+ message. A template that does not render **fails the send**. You read the
171
+ result back from `message.metadata` — not from a field on the link.
172
+
173
+ ## 3. Field ownership
174
+
175
+ | Field | Written by |
176
+ |---|---|
177
+ | `link_prefix`, `link_validity_days`, `metadata_template` | you, on the action |
178
+ | `destination_url` | you, as the URL written in the body |
179
+ | the token, `expires_at`, `message_id`, `workflow_id`, `workflow_execution_log_id`, `action_id` | the platform, at send |
180
+ | `legal_entity_id` | the platform, when MDM resolves the recipient |
181
+ | `link_clicked_at`, `message.clicked_at` | the platform, on a person's first tap |
182
+ | `message.opened_at`, `message.form_submitted_at` | your host, through `updateMessageTracking` |
183
+
184
+ ## 4. Error envelopes
185
+
186
+ Both calls throw on a refusal, and the thrown value carries the HTTP status
187
+ as `_httpStatus` (see `errors.md`). They refuse in ways a host must tell
188
+ apart:
189
+
190
+ | Status | `exchange` | `updateMessageTracking` | Meaning |
191
+ |---|---|---|---|
192
+ | `404` | yes | yes | a token the platform never minted |
193
+ | `410` | yes | yes | the link is past `expires_at` |
194
+ | `503` | yes | yes | the datalake cannot be reached right now |
195
+ | `422` | — | yes | a status the message cannot have |
196
+ | `401` | yes | yes | no publishable key, or one that does not resolve |
197
+
198
+ ```typescript
199
+ async function resolveTap(token: string, userAgent: string): Promise<string> {
200
+ try {
201
+ const { data } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
202
+ token,
203
+ method: 'GET',
204
+ user_agent: userAgent,
205
+ })
206
+ return data.destination_url
207
+ } catch (err) {
208
+ const status = (err as { _httpStatus?: number })._httpStatus
209
+ if (status === 410) return 'https://go.example.com/expired'
210
+ if (status === 404) return 'https://go.example.com/not-found'
211
+ if (status === 503) return 'https://go.example.com/try-again'
212
+ throw err
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## 5. Lifecycle
218
+
219
+ 1. **Build.** Give every SMS, MMS and email action a `link_prefix`, and a
220
+ `metadata_template` when your page needs more than the recipient.
221
+ 2. **Send.** Each URL in the body becomes `<link_prefix>/<token>`, valid for
222
+ `link_validity_days`.
223
+ 3. **Tap.** Your host calls `exchange` with the token and the tap's request
224
+ details, and sends the person on to `destination_url`. A person's tap
225
+ moves the message to `clicked`.
226
+ 4. **Page.** Your host reports `opened` when its page opens and
227
+ `form_submitted` when the form is done, each with its time.
228
+ 5. **Expiry.** After `expires_at` both calls answer `410`.
229
+
230
+ ## 6. Gotchas
231
+
232
+ - **Forward what the tap said about itself.** Only the token is required, but
233
+ a host that forwards nothing else gives the platform nothing to judge a
234
+ person by. Pass the method, user agent and headers of the request that
235
+ reached you, not those of your own server-side call.
236
+ - **Link previews are taps too.** Messaging apps fetch a link to draw its
237
+ preview; those are recorded and do not count as clicks.
238
+ - **Put the path in the prefix.** The platform appends only the token, so
239
+ `https://go.example.com/l` gives `https://go.example.com/l/<token>`. A
240
+ trailing slash is refused.
241
+ - **`metadata` lives on the message.** Read `data.message.metadata`; the link
242
+ itself has no metadata field.
243
+ - **Coming from Connected Apps.** `connected_app_id`, `connected_app_route`
244
+ and `connected_app_metadata_template` are gone from actions;
245
+ `link_metadata_template` is now `metadata_template`; the body carries the
246
+ destination URL itself instead of `{{ connected_app_form_url }}`; and
247
+ `updateMessageTracking` is keyed by the link's token, not a page token.
@@ -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