@alvera-ai/platform-sdk 0.12.1 → 0.13.0-next.gf3d2a4a

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.
Files changed (55) hide show
  1. package/.agent/AGENTS.md +121 -19
  2. package/.agent/account_management.md +79 -38
  3. package/.agent/action_logs.md +144 -0
  4. package/.agent/action_status_updaters.md +237 -53
  5. package/.agent/ai_agents.md +149 -53
  6. package/.agent/ai_sandbox.md +11 -4
  7. package/.agent/async.md +16 -6
  8. package/.agent/connected_apps.md +116 -4
  9. package/.agent/cookbook/_fixtures/README.md +9 -9
  10. package/.agent/cookbook/_fixtures/{payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid → payments/_compliance_screenings_payments_compliance_screening.liquid} +1 -1
  11. package/.agent/cookbook/_fixtures/{payment_risk/_compliance_screenings_payment_risk_mdm.liquid → payments/_compliance_screenings_payments_mdm.liquid} +1 -1
  12. package/.agent/cookbook/_fixtures/{payment_risk/_payment_accounts_payment_risk_mdm.liquid → payments/_payment_accounts_payments_mdm.liquid} +1 -1
  13. package/.agent/cookbook/_fixtures/{payment_risk/_payment_accounts_payment_risk_payment_account.liquid → payments/_payment_accounts_payments_payment_account.liquid} +1 -1
  14. package/.agent/cookbook/_fixtures/{accounts_receivable/_customers_accounts_receivable_customer.liquid → subscription/_customers_subscription_customer.liquid} +1 -1
  15. package/.agent/cookbook/_fixtures/{accounts_receivable/_customers_accounts_receivable_mdm.liquid → subscription/_customers_subscription_mdm.liquid} +1 -1
  16. package/.agent/cookbook/_setup/foundation.md +24 -4
  17. package/.agent/cookbook/_setup/healthcare.md +24 -4
  18. package/.agent/cookbook/_setup/{payment_risk.md → payments.md} +51 -31
  19. package/.agent/cookbook/_setup/{accounts_receivable.md → subscription.md} +51 -31
  20. package/.agent/cookbook/action-status-updaters.md +25 -18
  21. package/.agent/cookbook/bulk-ingest.md +15 -15
  22. package/.agent/cookbook/dunning-sms-for-delinquent.md +21 -21
  23. package/.agent/cookbook/{custom-tables.md → generic-tables.md} +44 -41
  24. package/.agent/cookbook/invite-team.md +19 -13
  25. package/.agent/cookbook/kyc-notification-on-account-activation.md +26 -26
  26. package/.agent/cookbook/marketing-campaign-send.md +1003 -0
  27. package/.agent/cookbook/rest-fetch.md +9 -9
  28. package/.agent/cookbook/sanctions-screening-with-agent-review.md +22 -22
  29. package/.agent/cookbook/system-templates.md +15 -15
  30. package/.agent/cookbook/triage-prospects-by-priority.md +17 -17
  31. package/.agent/cookbook/welcome-sms-for-customers.md +21 -21
  32. package/.agent/data_activation_clients.md +177 -42
  33. package/.agent/data_sources.md +55 -37
  34. package/.agent/datalakes.md +30 -17
  35. package/.agent/debugging.md +48 -14
  36. package/.agent/errors.md +39 -17
  37. package/.agent/generic_tables.md +150 -51
  38. package/.agent/interoperability_contracts.md +114 -15
  39. package/.agent/mdm.md +171 -93
  40. package/.agent/messages.md +252 -0
  41. package/.agent/templates.md +21 -4
  42. package/.agent/tool-call-configs.md +1 -1
  43. package/.agent/tools.md +150 -60
  44. package/.agent/type_naming.md +79 -18
  45. package/.agent/workflows.md +408 -62
  46. package/LICENSE +21 -93
  47. package/README.md +2 -2
  48. package/dist/bin/platform-sdk.mjs +53 -1
  49. package/dist/bin/platform-sdk.mjs.map +1 -1
  50. package/dist/index.d.mts +582 -66
  51. package/dist/index.d.mts.map +1 -1
  52. package/dist/index.mjs +654 -105
  53. package/dist/index.mjs.map +1 -1
  54. package/package.json +10 -6
  55. /package/.agent/cookbook/_fixtures/{accounts_receivable → subscription}/stripe_customers_batch1.csv +0 -0
package/.agent/AGENTS.md CHANGED
@@ -52,6 +52,28 @@ opening it. If you find yourself emitting a claim about a kind's
52
52
  wire shape without having opened that kind's MD in the current
53
53
  session, **stop and open it now**.
54
54
 
55
+ ### You are NOT entitled to conclude "the platform cannot do X"
56
+
57
+ This corpus is your window onto the platform, not its floor plan.
58
+ **Absence from these guides is not evidence the platform lacks a
59
+ capability** — it usually just means no use case had yet forced that
60
+ capability into the docs. The platform is deliberately generic; the
61
+ same primitives back many verticals, and each new build surfaces a
62
+ fresh batch of capabilities the corpus never had a reason to spell out.
63
+
64
+ So when a build seems to need something you can't find here, the honest
65
+ output is a **problem statement to the platform team** ("I need to do
66
+ X; the corpus doesn't document a way — does one exist?"), **not a
67
+ verdict** ("the platform can't do X"). A wrong "can't" is expensive: it
68
+ sends an agent to build the wrong thing or to file a request for a
69
+ feature that already ships. Real examples that were *all* in the
70
+ platform but missing from the corpus until a single build forced them
71
+ in: SMS quiet-hours send windows, pausing a scheduled campaign by
72
+ editing an action's `runtime_filter`, scheduling on the action rather
73
+ than the run, per-recipient tracked-link minting, and per-variant
74
+ `GROUP BY decision_key` analytics. Each was momentarily mistaken for a
75
+ platform gap. Don't repeat that — escalate the question, don't rule.
76
+
55
77
  ---
56
78
 
57
79
  ## If you know MCP — vocabulary mapping
@@ -100,33 +122,91 @@ construction.
100
122
  ## SDK auth + client construction
101
123
 
102
124
  The SDK is a strict-TypeScript REST client for the Alvera
103
- platform. Two-step auth + client construction:
125
+ platform. Two-step auth + client construction — and there are **two**
126
+ session minters, not one (see `account_management.md` for the full
127
+ treatment):
104
128
 
105
129
  import {
106
- createSession,
130
+ createBootstrapSession, // tenantless / root Bearer
131
+ createSession, // tenant-scoped Bearer
107
132
  createIsolatedPlatformApi,
108
133
  type PlatformApi,
109
134
  } from '@alvera-ai/platform-sdk'
110
135
 
111
- // 1. mint a session
136
+ // 1a. tenantless / root — POST /api/v1/admin/bootstrap-session.
137
+ // Keyless by structural necessity (it mints the FIRST Bearer of an
138
+ // environment, before any tenant key exists). Returned tenant + role
139
+ // are always null. Dev / integration-test route — absent from prod.
140
+ const bootstrap = await createBootstrapSession({ baseUrl, email, password })
141
+
142
+ // 1b. tenant-scoped — the canonical app session. tenantSlug AND apiKey
143
+ // are BOTH required; sign-in 401s ("X-API-Key required to sign in to
144
+ // a tenant") without a resolvable X-API-Key.
112
145
  const session = await createSession({
113
146
  baseUrl,
114
147
  email,
115
148
  password,
116
- tenantSlug, // optional; omit for tenantless session
149
+ tenantSlug, // REQUIRED
150
+ apiKey, // REQUIRED — the tenant's publishable key
117
151
  })
118
152
 
119
153
  // 2. build a typed client
120
154
  const api: PlatformApi = createIsolatedPlatformApi({
121
155
  baseUrl,
122
156
  sessionToken: session.sessionToken,
157
+ apiKey, // rides as X-API-Key on every request
123
158
  })
124
159
 
125
- Sessions come in three scopes:
126
- - **root** Alvera root admin; user signup + confirmation
160
+ **Two credentials travel together on every request** (Firebase's
161
+ "API key + ID token" pairing, not an either/or): the session token
162
+ (`Authorization: Bearer …`) is what the platform authenticates and
163
+ authorizes as — Bearer always wins identity; the **publishable API
164
+ key** (`X-API-Key: …`) identifies the client and carries the
165
+ origin policy — a `public_api` key declares `allowed_origins`, and
166
+ a browser request's `Origin` is checked against the key that
167
+ resolves. A Bearer session *inherits the origin policy of the key
168
+ that minted it*. Where the key comes from: a tenant admin mints it
169
+ in the platform's API-keys page (Settings → API Keys), or
170
+ programmatically via `api.admin.createTenantApiKey(tenantSlug,
171
+ { name, data_access_mode })` (platform-admin-gated).
172
+
173
+ **A CORS preflight is NOT proof the key admits your origin.** A
174
+ browser `OPTIONS` preflight carries no `X-API-Key` (only the method
175
+ + `Access-Control-Request-Headers`), so the server cannot resolve a
176
+ key and answers permissively — EVERY origin gets a friendly
177
+ `access-control-allow-origin` echo. The enforced policy runs on the
178
+ actual authenticated request, against the resolved key's
179
+ `allowed_origins`. Never verify origins with a preflight — verify
180
+ with a real signed-in round-trip. Triage for a failing browser
181
+ sign-in: (1) console says the key env var is missing → your `.env`
182
+ didn't load (restart the dev server after editing it); (2) the REAL
183
+ call is blocked by CORS (no ACAO on it) → the key's
184
+ `allowed_origins` doesn't include your dev origin — mint/patch the
185
+ key, and don't trust the earlier green preflight; (3) a `401` with
186
+ NO CORS error → the request PASSED CORS; the fault is the
187
+ credentials or the key not resolving, not origins.
188
+
189
+ Sessions come in three scopes, minted by two functions:
190
+ - **root** — Alvera root admin; user signup + confirmation. Minted by
191
+ `createBootstrapSession` (a tenantless Bearer that belongs to a
192
+ platform-admin user).
127
193
  - **tenantless** — authenticated user with no tenant chosen yet;
128
- used once to create a tenant via `api.tenants.create(...)`
129
- - **tenant-scoped** — the canonical Bearer for tenant operations
194
+ used once to create a tenant via `api.tenants.create(...)`. Also
195
+ minted by `createBootstrapSession`.
196
+ - **tenant-scoped** — the canonical Bearer for tenant operations,
197
+ minted by `createSession` (**requires both `tenantSlug` and a real
198
+ tenant API key** — the login 401s with `X-API-Key required to sign
199
+ in to a tenant` otherwise).
200
+
201
+ **Build apps tenant-scoped, and only tenant-scoped.** The root and
202
+ tenantless scopes exist for the platform's own UI and for
203
+ admin/bootstrap flows (signup, confirmation, tenant creation,
204
+ test-suite setup) — they are NOT app-building surfaces; an app that
205
+ holds a root or tenantless Bearer is misdesigned. An app
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`).
130
210
 
131
211
  Consumers holding multiple concurrent clients in the same file
132
212
  (e.g. integration tests that need root + tenantless + tenant-
@@ -189,7 +269,13 @@ business outcome.
189
269
  WORKFLOWS (composition on top)
190
270
  ─────────
191
271
  workflows.md Filter + decision + action;
192
- standard + agent-driven variants.
272
+ standard + agent-driven variants;
273
+ §8 watching a run.
274
+ messages.md The messages DATASET — what went
275
+ out, delivery status, engagement.
276
+ action_logs.md The action_logs DATASET — per-
277
+ action outcomes, batch_id joins,
278
+ audience export.
193
279
  ```
194
280
 
195
281
  Axes are mostly disjoint but a few resources cross. AI Agents
@@ -217,6 +303,13 @@ wire name (snake_case):
217
303
  api.dataActivationClients data_activation_clients.md
218
304
  api.workflows workflows.md
219
305
 
306
+ Two corpus pages document DATASETS, not namespaces — read
307
+ through `api.datasets` with SINGULAR dataset types (the plural
308
+ forms are the SQL table names used by `executeSql`):
309
+
310
+ dataset type 'message' (table messages) messages.md
311
+ dataset type 'action_log' (table action_logs) action_logs.md
312
+
220
313
  ## Cookbooks (Golden Path scenarios)
221
314
 
222
315
  `.agent/cookbook/<slug>.md` files are pure-markdown scenario
@@ -357,16 +450,18 @@ The `cookbook/` directory holds two kinds of recipe, both validated by
357
450
  an outcome.
358
451
  - **Capability docs** (`<capability>.md`) — one platform capability each,
359
452
  shown as the minimal call sequence that proves it (e.g. `bulk-ingest`,
360
- `ai-agent-invoke`, `custom-tables`, `invite-team`). Read these to learn one
453
+ `ai-agent-invoke`, `generic-tables`, `invite-team`). Read these to learn one
361
454
  capability in isolation; cookbooks weave them into outcomes.
362
455
 
363
456
  ### Available cookbooks
364
457
 
365
- The nine business-cookbook scenarios. Each is anchored to a green
458
+ The ten business-cookbook scenarios. Each is anchored to a green
366
459
  end-to-end vitest scenario in the platform's integration-tests suite and is
367
460
  verified at dev time by `make validate-cookbook` at the platform-sdk repo root.
368
- The entries between the managed markers below are what `alvera-sdk-init` lifts
369
- into consumer `<cwd>/AGENTS.md` files as a managed block.
461
+ This index is mirrored into the managed block `alvera llm-export` writes to
462
+ consumer `AGENTS.md` files (`buildManagedBlock` in both the SDK and CLI
463
+ packages — kept in sync by hand; the llm-export test suites pin the shipped
464
+ copy, so a dropped entry breaks a test).
370
465
 
371
466
  <!-- BEGIN:cookbook-index -->
372
467
 
@@ -380,7 +475,7 @@ into consumer `<cwd>/AGENTS.md` files as a managed block.
380
475
  buckets (appointment / job-application / spam) via an LLM
381
476
  agent, route each to a tailored SMS action.
382
477
 
383
- **Accounts Receivable**
478
+ **Subscription**
384
479
 
385
480
  - [welcome-sms-for-customers](./cookbook/welcome-sms-for-customers.md)
386
481
  — Send a welcome SMS to newly contracted customers with a
@@ -394,7 +489,7 @@ into consumer `<cwd>/AGENTS.md` files as a managed block.
394
489
  (high / medium / low) via an LLM agent, route each band to a
395
490
  tailored SMS action.
396
491
 
397
- **Payment Risk**
492
+ **Payments**
398
493
 
399
494
  - [kyc-notification-on-account-activation](./cookbook/kyc-notification-on-account-activation.md)
400
495
  — Send a KYC-notification SMS when a payment account
@@ -413,6 +508,11 @@ into consumer `<cwd>/AGENTS.md` files as a managed block.
413
508
  — Score inbound leads into four bands
414
509
  (hot / warm / cold / spam) via an LLM agent, route each band
415
510
  to a tailored SMS action.
511
+ - [marketing-campaign-send](./cookbook/marketing-campaign-send.md)
512
+ — Send an A/B marketing campaign across SMS and email: the
513
+ suppression and reachability gates and the A/B split all live
514
+ in the workflow, each send carries a per-recipient short link,
515
+ and the reply re-attaches to the customer who sent it.
416
516
 
417
517
  <!-- END:cookbook-index -->
418
518
 
@@ -430,8 +530,8 @@ full wire shape.
430
530
  document/image and pull structured JSON out of it (`aiAgents.invoke` with
431
531
  files). The correct file-vision path — the agent reads the file; the DAC
432
532
  ingests it.
433
- - [custom-tables](./cookbook/custom-tables.md) — stand up a custom table the
434
- built-in datasets don't model; deploy → ingest → search.
533
+ - [generic-tables](./cookbook/generic-tables.md) — stand up a generic table the
534
+ built-in datasets don't model; deploy → ingest → read back via executeSql.
435
535
  - [action-status-updaters](./cookbook/action-status-updaters.md) — reconcile
436
536
  the delivery status of messages you send, on a schedule.
437
537
  - [system-templates](./cookbook/system-templates.md) — discover the platform's
@@ -447,8 +547,10 @@ full wire shape.
447
547
 
448
548
  Cross-resource utilities not documented in this corpus:
449
549
 
450
- api.auth signUp + auth helpers
451
- api.admin confirmUser + admin actions
550
+ api.admin signUp + confirmUser + createTenantApiKey
551
+ (dev / integration-test fixtures)
552
+ api.sessions verify; top-level createBootstrapSession /
553
+ createSession / revokeSession mint + revoke Bearers
452
554
  api.tenants create + tenant lifecycle
453
555
  api.datasets cross-resource search / metadata /
454
556
  user-saved searches
@@ -15,14 +15,14 @@ together:
15
15
 
16
16
  | Namespace | Methods |
17
17
  |--------------------|-----------------------------------------------|
18
- | `api.auth` | `signUp` |
19
- | `api.admin` | `confirmUser` — **dev fixture only** |
18
+ | `api.admin` | `signUp`, `confirmUser`, `createTenantApiKey` — **dev / integration-test fixtures only** |
20
19
  | `api.sessions` | `verify` |
21
20
  | `api.tenants` | `create`, `list` |
22
21
  | `api.invitations` | `create`, `list`, `accept` |
23
22
  | `api.ping` | unauthenticated health check (`GET /api/ping`) |
24
- | `createSession` | mint a Bearer (top-level helper, not a namespace) |
25
- | `revokeSession` | revoke the current Bearer (top-level helper, parallel to `createSession`) |
23
+ | `createBootstrapSession` | mint a **tenantless** Bearer (top-level helper, not a namespace) |
24
+ | `createSession` | mint a **tenant-scoped** Bearer (top-level helper, not a namespace) |
25
+ | `revokeSession` | revoke the current Bearer (top-level helper, parallel to the two minters) |
26
26
 
27
27
  Two top-level concerns sit above the surface:
28
28
 
@@ -39,36 +39,69 @@ The Bearer carries the trust-boundary coordinates and travels in
39
39
  the `Authorization: Bearer <token>` header on every subsequent
40
40
  request.
41
41
 
42
- ### Three Bearer scopes
42
+ ### Three scopes, two minters
43
43
 
44
- `createSession` mints a Bearer at one of three scopes:
44
+ Sessions come in three scopes **root**, **tenantless**, and
45
+ **tenant-scoped** — but there are only **two** functions that mint a
46
+ Bearer. `createBootstrapSession` mints the keyless tenantless Bearer
47
+ (root is a tenantless Bearer that happens to belong to a platform-admin
48
+ user); `createSession` mints the tenant-scoped Bearer.
45
49
 
46
50
  ```typescript
47
- import { createSession } from '@alvera-ai/platform-sdk'
51
+ import { createBootstrapSession, createSession } from '@alvera-ai/platform-sdk'
48
52
 
49
- // root admin (no tenant) for confirmUser, signUp on behalf of, etc.
50
- const root = await createSession({
51
- baseUrl, email: rootEmail, password: rootPassword,
53
+ // tenantlessPOST /api/v1/admin/bootstrap-session. Keyless by structural
54
+ // necessity (it mints the very first Bearer of an environment, before any
55
+ // tenant key exists). Returned tenant + role are ALWAYS null. This is an
56
+ // integration-test / dev-only route — absent from prod builds.
57
+ const tenantless = await createBootstrapSession({
58
+ baseUrl, email, password,
52
59
  })
60
+ // tenantless.tenant === null && tenantless.role === null
53
61
 
54
- // tenantlessauthenticated user who has not picked a tenant yet
55
- const tenantless = await createSession({
56
- baseUrl, email, password,
62
+ // rootthe SAME call, with a platform-admin user's credentials. The
63
+ // admin capability comes from the user, not a different function. Use it
64
+ // for admin.signUp / admin.confirmUser / admin.createTenantApiKey.
65
+ const root = await createBootstrapSession({
66
+ baseUrl, email: rootEmail, password: rootPassword,
57
67
  })
58
- // tenantless.tenant === null
59
68
 
60
- // tenant-scoped — the canonical Bearer for tenant operations
69
+ // tenant-scoped — POST /api/v1/sessions, the canonical Bearer for tenant
70
+ // operations. REQUIRES both tenantSlug AND a resolvable tenant publishable
71
+ // key (sent as X-API-Key): the server 401s with "X-API-Key required to
72
+ // sign in to a tenant" without one, and 403s a key belonging to a different
73
+ // tenant. The minted Bearer inherits the origin policy of the key that
74
+ // minted it, and that same key must accompany every subsequent request.
61
75
  const tenant = await createSession({
62
- baseUrl, email, password, tenantSlug,
76
+ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey,
63
77
  })
64
78
  // tenant.tenant.slug === tenantSlug
65
79
  // tenant.role.name === 'tenant_admin' | 'member' | ...
66
80
  ```
67
81
 
82
+ The tenant API key is minted by a tenant admin in the platform's
83
+ API-keys page (Settings → API Keys), or programmatically via the
84
+ platform-admin side door (a root Bearer):
85
+
86
+ ```typescript
87
+ const { data: minted } = await rootApi.admin.createTenantApiKey(tenantSlug, {
88
+ name: 'Bootstrap Key',
89
+ data_access_mode: 'unregulated',
90
+ })
91
+ // minted.api_key — the publishable key value (thread into createSession)
92
+ ```
93
+
68
94
  See `AGENTS.md` "Sessions come in three scopes" for when each is
69
95
  appropriate. Tenantless is a transient state used once: to create
70
96
  a tenant (creator path) or accept an invitation (invitee path).
71
97
 
98
+ **Scope discipline for app builders:** root and tenantless sessions
99
+ are platform-UI / admin-bootstrap surfaces (signup, confirmation,
100
+ tenant creation, test setup) — never ship them inside an app. An
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`).
104
+
72
105
  ### Explicit teardown — `revokeSession`
73
106
 
74
107
  The inverse of `createSession`. Revokes the current Bearer on
@@ -110,7 +143,9 @@ local server before authentication failures muddy the diagnostic.
110
143
  ### Sign up
111
144
 
112
145
  ```typescript
113
- const { data: user } = await rootApi.auth.signUp({
146
+ // signUp lives under the admin namespace (there is no `api.auth`); it is an
147
+ // integration-test fixture, reachable only from a root Bearer.
148
+ const { data: user } = await rootApi.admin.signUp({
114
149
  email: 'sarah@example.com',
115
150
  password: '...',
116
151
  first_name: 'Sarah',
@@ -180,11 +215,11 @@ to many tenants; a Bearer is scoped to exactly one.
180
215
  ```
181
216
  ┌─ creator path ─┐ ┌─ invitee path ─┐
182
217
  │ │
183
- api.auth.signUp api.auth.signUp
218
+ admin.signUp admin.signUp
184
219
  │ │
185
220
  api.admin.confirmUser* api.admin.confirmUser* (* production: email link)
186
221
  │ │
187
- createSession (no tenantSlug) createSession (no tenantSlug)
222
+ createBootstrapSession createBootstrapSession
188
223
  │ │
189
224
  api.tenants.create api.invitations.list → .accept
190
225
  │ │
@@ -203,26 +238,30 @@ role specified in their invitation.
203
238
  const { data } = await tenantlessApi.tenants.create({
204
239
  name: 'Acme Health',
205
240
  })
206
- // data.tenant.id, data.tenant.slug — server-derived
207
- // data.session_token — see "Gotchas" below
241
+ // data.id, data.slug, data.name — server-derived (flat TenantResponse)
208
242
  ```
209
243
 
210
244
  The slug is server-derived from `name` per the universal slug
211
245
  rules (see `type_naming.md` "Never pre-compute the slug
212
246
  client-side"). Use the returned slug for downstream calls.
213
247
 
214
- **Gotcha**: the response carries a `session_token` field, but this
215
- is an *incidental* token do not treat it as a freshly minted
216
- tenant-scoped Bearer. The authoritative pattern is to call
217
- `createSession({ ..., tenantSlug })` after `tenants.create`. This
218
- gives a fresh Bearer with all role + tenant claims correctly
219
- populated.
248
+ **Gotcha**: `tenants.create` returns only the tenant a flat
249
+ `TenantResponse` (`id`, `slug`, `name`, `description`), **not** a Bearer
250
+ and with no `session_token`. The caller's tenantless Bearer stays valid,
251
+ but it is still tenantless. To act on the new tenant, mint a tenant-scoped
252
+ Bearer via `createSession({ ..., tenantSlug, apiKey })` which is also
253
+ where the mandatory `X-API-Key` first enters the flow. (The tenant's
254
+ default `public_api` key is auto-minted at creation, but its plaintext is
255
+ not returned here — read it from Settings → API Keys, or mint a fresh key
256
+ via `admin.createTenantApiKey`.)
220
257
 
221
258
  ### `api.tenants.list`
222
259
 
223
260
  ```typescript
224
261
  const { data: tenants } = await api.tenants.list()
225
262
  // tenants.data: TenantResponse[] — every tenant the current Bearer can see
263
+ // takes an optional Flop query (page/page_size/order_by/filters —
264
+ // see type_naming.md "List response types")
226
265
  ```
227
266
 
228
267
  A root Bearer sees every tenant; a tenant-scoped Bearer typically
@@ -271,13 +310,13 @@ the invitation enum:
271
310
  |-------------------------|------------------------------------|--------------|
272
311
  | `'tenant_admin'` | Full tenant management | invitation `'admin'` |
273
312
  | `'member'` | Standard tenant user | invitation `'member'` |
274
- | `'tenant_api'` | Programmatic tenant access (most API keys) | provisioned, not invited |
313
+ | `'public_api'` | Lowest-privilege publishable key (Stripe `pk_` analogue); the mandatory `X-API-Key` companion | provisioned, not invited |
275
314
  | `'datalake_admin'` | Full datalake management (requires `datalake_id` on session) | console-mediated |
276
315
  | `'researcher'` | Tokenized-only datalake access | invitation `'researcher'` |
277
316
  | `'datalake_api'` | Programmatic datalake access | provisioned, not invited |
278
317
 
279
318
  ```typescript
280
- const session = await createSession({ baseUrl, email, password, tenantSlug })
319
+ const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
281
320
  // session.role.name === 'tenant_admin' if accepted from a role: 'admin' invitation
282
321
  // session.role.name === 'member' if accepted from a role: 'member' invitation
283
322
  // session.role.name === 'researcher' if accepted from a role: 'researcher' invitation
@@ -327,7 +366,7 @@ const mine = invites.data.find(i => i.tenant.slug === tenantSlug)
327
366
  const { data: membership } = await tenantlessApi.invitations.accept(mine.id)
328
367
 
329
368
  // Then mint a tenant-scoped Bearer
330
- const session = await createSession({ baseUrl, email, password, tenantSlug })
369
+ const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
331
370
  // session.role.name === 'member'
332
371
  ```
333
372
 
@@ -399,16 +438,18 @@ documented in `errors.md`. Common rejections:
399
438
 
400
439
  | `source.pointer` / shape | Cause |
401
440
  |-------------------------------------------------|----------------------------------------------|
402
- | `/email` "has already been taken" | `auth.signUp` with an existing email |
441
+ | `/email` "has already been taken" | `admin.signUp` with an existing email |
403
442
  | `/password` "is too short" | password below minimum length |
404
443
  | top-level 401 (no envelope) | Bearer missing, expired, or revoked |
405
444
  | top-level 403 (no envelope) | role lacks permission for the action |
406
445
  | `/email` "already invited" | duplicate `invitations.create` |
407
446
  | `/tenant_slug` "not found" | tenant slug doesn't exist or invisible to Bearer |
408
447
 
409
- The `tenants.create` 422 is typically `/name` uniqueness names
410
- must be unique platform-wide. Re-attempt with a different name or
411
- treat as "already exists" via `api.tenants.list` lookup.
448
+ A `tenants.create` `/name` uniqueness collision comes back as a
449
+ **409 Conflict** (not 422 uniqueness constraints route to 409; see
450
+ `errors.md`) names must be unique platform-wide. Re-attempt with a
451
+ different name or treat as "already exists" via `api.tenants.list`
452
+ lookup.
412
453
 
413
454
  ## 6. Gotchas
414
455
 
@@ -417,10 +458,10 @@ treat as "already exists" via `api.tenants.list` lookup.
417
458
  the SDK method as a test fixture; do not ship code that calls
418
459
  it from a customer-facing path.
419
460
 
420
- 2. **The `session_token` returned by `tenants.create` is
421
- incidental.** Always re-mint a fresh tenant-scoped Bearer via
422
- `createSession({ ..., tenantSlug })` after creating a tenant.
423
- This guarantees clean role+tenant claims on the Bearer.
461
+ 2. **`tenants.create` returns a tenant, not a Bearer.** The response is
462
+ a flat `TenantResponse` (`id`, `slug`, `name`, `description`) with no
463
+ `session_token`. After creating a tenant, mint a tenant-scoped Bearer
464
+ via `createSession({ ..., tenantSlug, apiKey })` to act on it.
424
465
 
425
466
  3. **Tenantless Bearer is a transient state.** Only two
426
467
  operations are useful with it: `tenants.create` (creator path)
@@ -0,0 +1,144 @@
1
+ # Action logs (datalake dataset)
2
+
3
+ One row = one action outcome for one subject. Where `messages`
4
+ answers "what went out", `action_logs` answers "what did each
5
+ action do, per subject, per run" — including the actions that did
6
+ NOT send (skipped, filtered, failed).
7
+
8
+ Like `messages`, this is a **dataset** — read it via
9
+ `api.datasets.createUserSearch` + `api.datasets.search` with
10
+ dataset type `'action_log'` — **singular**; the plural
11
+ `action_logs` is the SQL *table* name (`executeSql`) and 422s as
12
+ a dataset type. Base SQL alias: `ral`. Rows exist only for
13
+ **live** mode — dry runs never write here.
14
+
15
+ ## 1. When a row appears (the scheduled gap)
16
+
17
+ Not all at once. Two moments:
18
+
19
+ ```
20
+ at run time — failed / filtered / skipped outcomes
21
+ land immediately
22
+ at execution time — a scheduled action gets its row only
23
+ when the trigger clock fires and it
24
+ actually executes
25
+ ```
26
+
27
+ Between those two moments a scheduled send is INVISIBLE here: its
28
+ execution log exists (the platform accepted it), but no
29
+ action_log row and no message row yet. A UI that only reads the
30
+ datalake shows nothing and looks broken — read the execution logs
31
+ (`workflows.md` §6) and say "scheduled — nothing sent yet".
32
+
33
+ ## 2. Fields
34
+
35
+ ```
36
+ id, workflow_id, action_id, workflow_execution_log_id,
37
+ batch_id — the run this outcome belongs to; THE
38
+ campaign key (messages does not have it)
39
+ decision_key — which action fired
40
+ context_key — companion string key; like decision_key, it is
41
+ SQL-lane only (neither is Flop-filterable, §5)
42
+ mdm_subject_id, subject_name, session_id
43
+ action_type ('sms'|'email'|'voice'|'data_exchange'), channel,
44
+ classification
45
+ status — see §3
46
+ external_id — provider id; joins to messages.external_id
47
+ sent_at, tool_call_payload, idempotency_key, metadata,
48
+ attachments, body, sender_tool_id
49
+ ```
50
+
51
+ ## 3. Statuses
52
+
53
+ ```
54
+ pending → queued → sent → delivered
55
+ └──→ failed
56
+
57
+ skipped — the action's runtime_filter said no; nothing sent
58
+ filtered — the parent run was filtered out; nothing sent
59
+ cancelled — manually cancelled
60
+ ```
61
+
62
+ Initial status mirrors the action execution log's outcome; a
63
+ status updater with an `action_log_config` can move
64
+ `sent → delivered/failed` here too (optional — many setups only
65
+ reconcile messages).
66
+
67
+ ## 4. How everything joins
68
+
69
+ ```
70
+ run (batch_id) ──── WorkflowRunLog "how is the batch doing"
71
+
72
+ ├── WorkflowExecutionLog (per subject) "what did the pipeline decide"
73
+ │ │
74
+ │ └── ActionExecutionLog "what did each action attempt"
75
+ │ │ (live + actually executed)
76
+ │ ▼
77
+ └────────── action_logs row "the outcome, queryable"
78
+ │ external_id
79
+
80
+ messages row "the communication + delivery"
81
+ ```
82
+
83
+ Left side lives in the platform DB (read via
84
+ `api.workflows.batchLogs` / `api.workflows.workflowLogs`); the
85
+ bottom two are datalake datasets. `batch_id` ties the datalake
86
+ rows back to the run; `external_id` ties an action_log to its
87
+ message.
88
+
89
+ ## 5. Reading + audience export
90
+
91
+ ```typescript
92
+ // dataset type is SINGULAR 'action_log' (the table is plural)
93
+ const { data: search } = await api.datasets.createUserSearch(
94
+ tenantSlug, datalakeSlug, 'action_log',
95
+ { search_query: `ral.batch_id = '${batchId}'` },
96
+ )
97
+ const { data: page } = await api.datasets.search(
98
+ tenantSlug, datalakeSlug, 'action_log',
99
+ { userSearchId: search.id, dataAccessMode: 'unregulated' },
100
+ )
101
+ ```
102
+
103
+ Filterable columns (Flop, the full set): `id`, `inserted_at`,
104
+ `action_type`, `channel`, `status`, `batch_id`, `classification`,
105
+ `workflow_id`, `sent_at`, `external_id`, `mdm_subject_id`. Note
106
+ there is **no `global_search`** here (unlike the message dataset) —
107
+ `decision_key`/`context_key` are not in this set either; scope them
108
+ via the `search_query` SQL fragment above.
109
+
110
+ "Who did this campaign go to" = the query above (subject columns
111
+ + status per row — exportable as-is). For bulk export the platform
112
+ also merges each finished batch into NDJSON artifacts in cold
113
+ storage:
114
+
115
+ ```
116
+ <datalake>/workflows/<wf_slug>/<batch_id>/merged.ndjson (per-subject)
117
+ <datalake>/workflows/<wf_slug>/<batch_id>/<decision_key>/merged.ndjson (per-action payloads)
118
+ ```
119
+
120
+ The batch log row (`api.workflows.batchLogs.get`) carries the
121
+ download references once the batch completes.
122
+
123
+ ## 6. Gotchas
124
+
125
+ 1. **The scheduled gap (§1).** No action_log row ≠ nothing
126
+ happened. Check the execution logs before declaring a run
127
+ empty.
128
+
129
+ 2. **`batch_id` is a string, not a UUID** — manual runs look like
130
+ `manual:<uuid>`; DAC-triggered runs carry the ingest batch id.
131
+ Compare as strings.
132
+
133
+ 3. **One subject can have many rows for the same decision.**
134
+ Re-fires via `workflows.execute` with `manual_override: true`
135
+ (the retry path) create a NEW action_log each time. Latest row
136
+ wins for "current state"; the full set is the audit trail.
137
+
138
+ 4. **`skipped`/`filtered` rows are successes of the control
139
+ system, not failures.** Only `failed` is the retry surface
140
+ (`workflows.md` §8).
141
+
142
+ 5. **`tool_call_payload` is the rendered call** — what was
143
+ actually sent to the tool, after Liquid. When a message looks
144
+ wrong, read this before blaming the template engine.