@alvera-ai/platform-sdk 0.16.3 → 0.17.0-next.g3186053

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 (46) hide show
  1. package/.agent/AGENTS.md +7 -0
  2. package/.agent/cookbook/_setup/access-control.md +341 -0
  3. package/.agent/cookbook/_setup/agent-provider-matrix.md +341 -0
  4. package/.agent/cookbook/_setup/{foundation.md → organic-marketing.md} +28 -46
  5. package/.agent/cookbook/_setup/payments.md +28 -45
  6. package/.agent/cookbook/_setup/{healthcare.md → primary-care-feedback.md} +28 -46
  7. package/.agent/cookbook/_setup/resource-catalogue.md +341 -0
  8. package/.agent/cookbook/_setup/{subscription.md → subscription-saas.md} +27 -44
  9. package/.agent/cookbook/_setup/workflow-authoring.md +341 -0
  10. package/.agent/cookbook/{invite-team.md → access-control/invite-team.md} +1 -1
  11. package/.agent/cookbook/{ai-agent-invoke.md → agent-provider-matrix/ai-agent-invoke.md} +5 -5
  12. package/.agent/cookbook/{birthday-greeting-sms-trigger.md → organic-marketing/birthday-greeting-sms-trigger.md} +4 -4
  13. package/.agent/cookbook/{marketing-campaign-send.md → organic-marketing/marketing-campaign-send.md} +4 -4
  14. package/.agent/cookbook/{score-leads-with-llm-categorization.md → organic-marketing/score-leads-with-llm-categorization.md} +4 -4
  15. package/.agent/cookbook/{talk-to-data.md → organic-marketing/talk-to-data.md} +6 -6
  16. package/.agent/cookbook/{kyc-notification-on-account-activation.md → payments/kyc-notification-on-account-activation.md} +2 -2
  17. package/.agent/cookbook/{sanctions-screening-with-agent-review.md → payments/sanctions-screening-with-agent-review.md} +4 -4
  18. package/.agent/cookbook/{appointment-review-sms-workflow.md → primary-care-feedback/appointment-review-sms-workflow.md} +4 -4
  19. package/.agent/cookbook/{contact-us-triage-with-llm.md → primary-care-feedback/contact-us-triage-with-llm.md} +3 -3
  20. package/.agent/cookbook/{generic-tables.md → resource-catalogue/generic-tables.md} +4 -4
  21. package/.agent/cookbook/{system-templates.md → resource-catalogue/system-templates.md} +10 -6
  22. package/.agent/cookbook/{bulk-ingest.md → subscription-saas/bulk-ingest.md} +3 -3
  23. package/.agent/cookbook/{dunning-sms-for-delinquent.md → subscription-saas/dunning-sms-for-delinquent.md} +3 -3
  24. package/.agent/cookbook/{rest-fetch.md → subscription-saas/rest-fetch.md} +2 -2
  25. package/.agent/cookbook/{triage-prospects-by-priority.md → subscription-saas/triage-prospects-by-priority.md} +3 -3
  26. package/.agent/cookbook/{welcome-sms-for-customers.md → subscription-saas/welcome-sms-for-customers.md} +3 -3
  27. package/.agent/cookbook/{action-status-updaters.md → workflow-authoring/action-status-updaters.md} +1 -1
  28. package/.agent/cookbook/{paginated-restapi-poller.md → workflow-authoring/paginated-restapi-poller.md} +1 -1
  29. package/.agent/messages.md +45 -6
  30. package/.agent/mock-services.md +192 -0
  31. package/dist/index.d.mts +396 -360
  32. package/dist/index.d.mts.map +1 -1
  33. package/dist/index.mjs +302 -31
  34. package/dist/index.mjs.map +1 -1
  35. package/package.json +1 -1
  36. /package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +0 -0
  37. /package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_legal_entity.liquid +0 -0
  38. /package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +0 -0
  39. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/_cahps_appointments_healthcare_appointment.liquid +0 -0
  40. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/_cahps_appointments_healthcare_mdm.liquid +0 -0
  41. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/_cahps_appointments_healthcare_patient.liquid +0 -0
  42. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  43. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  44. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  45. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_mdm.liquid +0 -0
  46. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
package/.agent/AGENTS.md CHANGED
@@ -576,6 +576,13 @@ of restating:
576
576
  | Async + readiness | `async.md` |
577
577
  | Debugging (HTTP interceptor + safe redaction) | `debugging.md` |
578
578
  | Type naming + server-derived fields | `type_naming.md` |
579
+ | Local mock surfaces (WireMock / LocalStack) | `mock-services.md` |
580
+
581
+ `mock-services.md` answers the local-development questions the other
582
+ pages assume away: how the platform reaches mocked third-party APIs on
583
+ a laptop, the `endpoint_url` override on the sender tools, and why End
584
+ User Messaging points at WireMock rather than LocalStack. It is fetched
585
+ from the platform-metadata channel, not authored here.
579
586
 
580
587
  ## Just getting started?
581
588
 
@@ -0,0 +1,341 @@
1
+ ---
2
+ title: Access control bootstrap setup
3
+ summary: Auth as root, sign up + confirm a fresh admin user, create a tenant + datalake. Shared by every access-control cookbook.
4
+ use_case: access-control
5
+ slug: access-control
6
+ vitest_source:
7
+ - integration-tests/tests/foundation/bootstrap.test.ts
8
+ status: green
9
+ ---
10
+
11
+ # Problem
12
+
13
+ Every foundation scenario cookbook needs the same starting point:
14
+ a fresh tenant on the platform with a foundation-domain datalake
15
+ attached, accessed by an industry-admin user. Authoring this
16
+ prelude inline inside every scenario cookbook would duplicate the
17
+ auth + tenant + datalake chain across two cookbooks (and four more
18
+ across the other industries). The validator auto-discovers this
19
+ file from each cookbook's `industry: foundation` front-matter and
20
+ inlines its numbered steps ahead of the scenario's own, so the
21
+ generated bun:test spec is hermetic without the cookbook author
22
+ having to copy-paste this prelude.
23
+
24
+ # Composition
25
+
26
+ | Resource provisioned | Owner |
27
+ |-------------------------------|-------------|
28
+ | Root session | setup |
29
+ | Industry-admin user (`sarah`) | setup |
30
+ | Foundation tenant | setup |
31
+ | Foundation datalake | setup |
32
+
33
+ # Walkthrough
34
+
35
+ ## 001 — root admin signs in and provisions a fresh industry-admin user
36
+
37
+ Authenticate as the platform's root admin (`admin@dev.local` /
38
+ `devpassword` in local dev) via the tenantless bootstrap login —
39
+ keyless by structural necessity (no tenant exists yet to scope a
40
+ key to), and a dev/test-only surface. Then sign up a per-run industry-admin
41
+ user (`sarah`) under a unique email derived from `runSuffix`, and
42
+ confirm them so they can sign in. Both the signup and the confirm
43
+ endpoints require root scope.
44
+
45
+ ```typescript
46
+ ctx.rootSession = await createBootstrapSession({
47
+ baseUrl: process.env.ALVERA_BASE_URL!,
48
+ email: process.env.ALVERA_ROOT_EMAIL!,
49
+ password: process.env.ALVERA_ROOT_PASSWORD!,
50
+ })
51
+ ctx.rootApi = createIsolatedPlatformApi({
52
+ baseUrl: process.env.ALVERA_BASE_URL!,
53
+ sessionToken: ctx.rootSession.sessionToken,
54
+ apiKey: '',
55
+ })
56
+
57
+ ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
58
+ ctx.sarahPassword = 'CookbookPass1!'
59
+
60
+ const signUpResp = await ctx.rootApi.admin.signUp({
61
+ email: ctx.sarahEmail,
62
+ password: ctx.sarahPassword,
63
+ first_name: 'Cookbook',
64
+ last_name: 'Sarah',
65
+ })
66
+ ctx.sarahUserId = signUpResp.data.id
67
+ await ctx.rootApi.admin.confirmUser(ctx.sarahUserId)
68
+ ```
69
+
70
+ ## 002 — sarah signs in (tenantless) and creates the foundation tenant
71
+
72
+ Sarah signs in for the first time without a tenant scope (no
73
+ tenant exists yet for her), then immediately creates a fresh
74
+ foundation tenant. The tenant's server-derived `slug` is captured
75
+ into the closure-scoped `tenantSlug` slot for downstream steps.
76
+
77
+ ```typescript
78
+ ctx.sarahTenantlessSession = await createBootstrapSession({
79
+ baseUrl: process.env.ALVERA_BASE_URL!,
80
+ email: ctx.sarahEmail,
81
+ password: ctx.sarahPassword,
82
+ })
83
+ ctx.sarahTenantlessApi = createIsolatedPlatformApi({
84
+ baseUrl: process.env.ALVERA_BASE_URL!,
85
+ sessionToken: ctx.sarahTenantlessSession.sessionToken,
86
+ apiKey: '',
87
+ })
88
+
89
+ const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
90
+ name: `Cookbook Access control ${runSuffix}`,
91
+ })
92
+ tenantSlug = tenantResp.data.slug!
93
+ ```
94
+
95
+ ## 003 — sarah signs in tenant-scoped — the canonical client
96
+
97
+ Re-mint sarah's session with the new tenant slug. This bearer is
98
+ the canonical tenant-scoped client every subsequent step uses; it
99
+ is assigned to the closure-scoped `api` slot so the scenario
100
+ cookbook's steps inherit it via inlining.
101
+
102
+ ```typescript
103
+ // A tenant-scoped login requires X-API-Key — mint a public_api key for
104
+ // the fresh tenant via the platform-admin side door (root Bearer). In the
105
+ // web console this is Settings -> API Keys; any of the tenant's keys
106
+ // satisfies the login gate, and the minted Bearer inherits this key's
107
+ // origin policy.
108
+ const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
109
+ name: `Cookbook Bootstrap Key ${runSuffix}`,
110
+ data_access_mode: 'tokenized',
111
+ })
112
+ ctx.tenantApiKey = mintedKey.api_key
113
+
114
+ const sarahTenantSession = await createSession({
115
+ baseUrl: process.env.ALVERA_BASE_URL!,
116
+ email: ctx.sarahEmail,
117
+ password: ctx.sarahPassword,
118
+ tenantSlug,
119
+ apiKey: ctx.tenantApiKey,
120
+ })
121
+ api = createIsolatedPlatformApi({
122
+ baseUrl: process.env.ALVERA_BASE_URL!,
123
+ sessionToken: sarahTenantSession.sessionToken,
124
+ apiKey: ctx.tenantApiKey,
125
+ })
126
+ ```
127
+
128
+ ## 004 — sarah creates the datalake
129
+
130
+ Provision a datalake on the new tenant. The DB schema is scoped to the cookbook run via `runSuffix` so parallel
131
+ cookbook runs do not collide on schema names. Local-dev defaults
132
+ (`postgres` on `localhost:5432`, `alvera_dev_foundation`) match
133
+ the seeded `dev.exs` setup; LocalStack S3 (`localhost:4566`)
134
+ serves the lake bucket (`alvera-platform-dev`, scoped by `base_path`). The datalake's
135
+ server-derived `slug` is captured into `datalakeSlug`.
136
+
137
+ ```typescript
138
+ const DB_HOST = 'localhost'
139
+ const DB_PORT = 5432
140
+ const DB_USER = 'postgres'
141
+ const DB_PASS = 'postgres'
142
+ const DB_NAME = 'alvera_dev_foundation'
143
+ const DB_SCHEMA = `cookbook_${runSuffix}`
144
+
145
+ const S3 = {
146
+ cloud_storage_type: 'aws' as const,
147
+ region: 'us-east-1',
148
+ access_key_id: 'test',
149
+ secret_access_key: 'test',
150
+ endpoint: 'http://localhost:4566',
151
+ }
152
+
153
+ const datalakeResp = await api.datalakes.create(tenantSlug, {
154
+ name: `Cookbook Access control Datalake ${runSuffix}`,
155
+ description: 'Datalake provisioned by cookbook doctest for access-control.',
156
+ timezone: 'America/New_York',
157
+ pool_size: 5,
158
+
159
+ db_writer_host: DB_HOST,
160
+ db_writer_port: DB_PORT,
161
+ db_writer_name: DB_NAME,
162
+ db_writer_schema: DB_SCHEMA,
163
+ db_writer_auth_method: 'password',
164
+ db_writer_user: DB_USER,
165
+ db_writer_pass: DB_PASS,
166
+ db_writer_enable_ssl: false,
167
+ db_reader_host: DB_HOST,
168
+ db_reader_port: DB_PORT,
169
+ db_reader_name: DB_NAME,
170
+ db_reader_schema: DB_SCHEMA,
171
+ db_reader_auth_method: 'password',
172
+ db_reader_user: DB_USER,
173
+ db_reader_pass: DB_PASS,
174
+ db_reader_enable_ssl: false,
175
+
176
+ // One bucket, scoped per run by `base_path` — the same shape the dev
177
+ // platform's own datalakes use. The old paired
178
+ // `<domain>-lake-{un,}regulated` buckets died with the two-tier lake.
179
+ cloud_storage: { ...S3, bucket: 'alvera-platform-dev', base_path: `cookbook/${runSuffix}` },
180
+ })
181
+ datalakeSlug = datalakeResp.data.slug!
182
+ // `tools.create` and a few other endpoints accept the datalake by
183
+ // UUID in the request body rather than only the slug in the URL,
184
+ // so keep it on `ctx` for downstream steps that need it.
185
+ ctx.datalakeId = datalakeResp.data.id!
186
+ ```
187
+
188
+ ## 005 — enqueue the datalake migrations
189
+
190
+ `datalakes.create` persists the datalake row in `:new` status but
191
+ does not itself run the schema migrations. Migration is a
192
+ separately-triggered async job so the operator controls when the
193
+ (potentially slow) per-industry DDL runs. `datalakes.migrate`
194
+ enqueues the `DatalakeMigrationWorker` Oban job and returns
195
+ immediately with `status: 'enqueued'` plus the Oban `job_id`. The
196
+ poll in §006 then waits for that worker to finish — without this
197
+ call the datalake would sit at `:new` forever.
198
+
199
+ ```typescript
200
+ const migrateResp = await api.datalakes.migrate(tenantSlug, datalakeSlug)
201
+ if (migrateResp.data.status !== 'enqueued') {
202
+ throw new Error(
203
+ `datalake migration not enqueued (status: ${migrateResp.data.status})`,
204
+ )
205
+ }
206
+ ```
207
+
208
+ ## 006 — poll the datalake until status is ready
209
+
210
+ Datalake migrations are async — the §005 migrate call enqueued a
211
+ `DatalakeMigrationWorker` Oban job that runs the per-industry
212
+ schema migrations, deploys PostgREST roles, and provisions the
213
+ service-account bot user. Downstream resource creates (tools,
214
+ workflows) only need `datalakeId` to be persisted (which it is
215
+ the moment §004 returns), but any scenario step that touches
216
+ the regulated/unregulated DBs or the dataset infrastructure
217
+ requires `status: 'ready'`. Polling here makes every foundation
218
+ scenario cookbook deterministic regardless of how long the cold
219
+ migration takes on a given host; the loop exits the moment the
220
+ datalake reaches `:ready`, so the 5-minute cap is paid only in
221
+ failure mode.
222
+
223
+ ```typescript
224
+ const READY_TIMEOUT_MS = 5 * 60_000
225
+ const READY_POLL_MS = 5_000
226
+ const deadline = Date.now() + READY_TIMEOUT_MS
227
+ let datalakeStatus: string | undefined
228
+ while (Date.now() < deadline) {
229
+ const { data } = await api.datalakes.get(tenantSlug, ctx.datalakeId)
230
+ datalakeStatus = data.status
231
+ if (datalakeStatus === 'ready') break
232
+ await new Promise((r) => setTimeout(r, READY_POLL_MS))
233
+ }
234
+ if (datalakeStatus !== 'ready') {
235
+ throw new Error(
236
+ `datalake did not reach :ready within ${READY_TIMEOUT_MS}ms (last status: ${datalakeStatus})`,
237
+ )
238
+ }
239
+ ```
240
+
241
+ ## 007 — a reusable "wait until the run has fired" helper
242
+
243
+ `workflows.run` only **schedules** a run. It records it and returns
244
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
245
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
246
+ later, when the run actually fires, and are read back from
247
+ `workflowRuns.get`.
248
+
249
+ Two traps live in that gap, so every foundation scenario shares one
250
+ helper rather than re-deriving it:
251
+
252
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
253
+ leaves `'scheduled'`.** Those are different moments: the run reaches
254
+ `processing` first and writes the log id a beat later. A predicate on
255
+ status alone releases you to read a `null`, and because
256
+ `typeof null === 'object'` the symptom is a baffling *"expected
257
+ string, got object"* rather than an obvious nil.
258
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
259
+ the deadline.** A scenario blocked on a run that will never fire
260
+ should say why on the first read, not thirty seconds later behind a
261
+ generic timeout.
262
+
263
+ The helper is stored on the shared `ctx` bag rather than declared as a
264
+ plain function because each numbered step compiles into its own `it()`
265
+ block — a bare `function` here would not be in scope for the steps that
266
+ call it.
267
+
268
+ ```typescript
269
+ const FIRED_TIMEOUT_MS = 60_000
270
+ const FIRED_POLL_MS = 1_000
271
+
272
+ ctx.waitForFiredRun = async (
273
+ runDatalakeSlug: string,
274
+ runId: string,
275
+ timeoutMs: number = FIRED_TIMEOUT_MS,
276
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
277
+ const deadline = Date.now() + timeoutMs
278
+ let lastStatus: string | undefined
279
+ while (Date.now() < deadline) {
280
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
281
+ lastStatus = data.status
282
+ if (data.status === 'failed') {
283
+ throw new Error(
284
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
285
+ )
286
+ }
287
+ if (typeof data.workflow_run_log_id === 'string') {
288
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
289
+ }
290
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
291
+ }
292
+ throw new Error(
293
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
294
+ )
295
+ }
296
+ ```
297
+
298
+ # Rollback
299
+
300
+ The cookbook doctest harness does not currently tear down the
301
+ created tenant / datalake / user — each run mints fresh names via
302
+ `runSuffix` so reruns do not collide, and the seeded local DB is
303
+ cheap to reset (`mix ecto.reset` on the platform). When the
304
+ validator graduates to continuous integration at Milestone 18
305
+ (running against the mockoon-backed mock env at `ALVERA_ENV=mock`),
306
+ no real state is created at all.
307
+
308
+ # Outcome
309
+
310
+ After this setup runs, the closure-scoped slots are populated as:
311
+
312
+ - `api` — a tenant-scoped `PlatformApi` client authenticated as
313
+ the industry-admin user
314
+ - `tenantSlug` — server-derived slug of the fresh foundation
315
+ tenant
316
+ - `datalakeSlug` — server-derived slug of the fresh foundation
317
+ datalake
318
+ - `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
319
+ credentials (kept on `ctx` because no scenario cookbook needs
320
+ to re-authenticate by default)
321
+ - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
322
+ setup via `admin.createTenantApiKey`); thread it into any
323
+ additional tenant-scoped `createSession` a scenario performs
324
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
325
+ until a scheduled run has actually fired, then returns its
326
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
327
+ reading `workflow_run_log_id` off the run response returns `null`
328
+ because run-workflow only schedules (see §007)
329
+
330
+ Scenario cookbooks under `industry: foundation` start at their own
331
+ `§001` with these slots already populated.
332
+
333
+ # See also
334
+
335
+ - `.agent/datalakes.md` — datalake create body shape (regulated +
336
+ unregulated tier configuration)
337
+ - `.agent/AGENTS.md` § SDK auth + client construction — root vs
338
+ tenantless vs tenant-scoped session scopes
339
+ - `integration-tests/tests/foundation/bootstrap.test.ts` — the
340
+ green test these snippets are lifted from (the §7b sibling
341
+ datalake creation is out of scope for this lean cookbook setup)
@@ -0,0 +1,341 @@
1
+ ---
2
+ title: Agent provider matrix bootstrap setup
3
+ summary: Auth as root, sign up + confirm a fresh admin user, create a tenant + datalake. Shared by every agent-provider-matrix cookbook.
4
+ use_case: agent-provider-matrix
5
+ slug: agent-provider-matrix
6
+ vitest_source:
7
+ - integration-tests/tests/foundation/bootstrap.test.ts
8
+ status: green
9
+ ---
10
+
11
+ # Problem
12
+
13
+ Every foundation scenario cookbook needs the same starting point:
14
+ a fresh tenant on the platform with a foundation-domain datalake
15
+ attached, accessed by an industry-admin user. Authoring this
16
+ prelude inline inside every scenario cookbook would duplicate the
17
+ auth + tenant + datalake chain across two cookbooks (and four more
18
+ across the other industries). The validator auto-discovers this
19
+ file from each cookbook's `industry: foundation` front-matter and
20
+ inlines its numbered steps ahead of the scenario's own, so the
21
+ generated bun:test spec is hermetic without the cookbook author
22
+ having to copy-paste this prelude.
23
+
24
+ # Composition
25
+
26
+ | Resource provisioned | Owner |
27
+ |-------------------------------|-------------|
28
+ | Root session | setup |
29
+ | Industry-admin user (`sarah`) | setup |
30
+ | Foundation tenant | setup |
31
+ | Foundation datalake | setup |
32
+
33
+ # Walkthrough
34
+
35
+ ## 001 — root admin signs in and provisions a fresh industry-admin user
36
+
37
+ Authenticate as the platform's root admin (`admin@dev.local` /
38
+ `devpassword` in local dev) via the tenantless bootstrap login —
39
+ keyless by structural necessity (no tenant exists yet to scope a
40
+ key to), and a dev/test-only surface. Then sign up a per-run industry-admin
41
+ user (`sarah`) under a unique email derived from `runSuffix`, and
42
+ confirm them so they can sign in. Both the signup and the confirm
43
+ endpoints require root scope.
44
+
45
+ ```typescript
46
+ ctx.rootSession = await createBootstrapSession({
47
+ baseUrl: process.env.ALVERA_BASE_URL!,
48
+ email: process.env.ALVERA_ROOT_EMAIL!,
49
+ password: process.env.ALVERA_ROOT_PASSWORD!,
50
+ })
51
+ ctx.rootApi = createIsolatedPlatformApi({
52
+ baseUrl: process.env.ALVERA_BASE_URL!,
53
+ sessionToken: ctx.rootSession.sessionToken,
54
+ apiKey: '',
55
+ })
56
+
57
+ ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
58
+ ctx.sarahPassword = 'CookbookPass1!'
59
+
60
+ const signUpResp = await ctx.rootApi.admin.signUp({
61
+ email: ctx.sarahEmail,
62
+ password: ctx.sarahPassword,
63
+ first_name: 'Cookbook',
64
+ last_name: 'Sarah',
65
+ })
66
+ ctx.sarahUserId = signUpResp.data.id
67
+ await ctx.rootApi.admin.confirmUser(ctx.sarahUserId)
68
+ ```
69
+
70
+ ## 002 — sarah signs in (tenantless) and creates the foundation tenant
71
+
72
+ Sarah signs in for the first time without a tenant scope (no
73
+ tenant exists yet for her), then immediately creates a fresh
74
+ foundation tenant. The tenant's server-derived `slug` is captured
75
+ into the closure-scoped `tenantSlug` slot for downstream steps.
76
+
77
+ ```typescript
78
+ ctx.sarahTenantlessSession = await createBootstrapSession({
79
+ baseUrl: process.env.ALVERA_BASE_URL!,
80
+ email: ctx.sarahEmail,
81
+ password: ctx.sarahPassword,
82
+ })
83
+ ctx.sarahTenantlessApi = createIsolatedPlatformApi({
84
+ baseUrl: process.env.ALVERA_BASE_URL!,
85
+ sessionToken: ctx.sarahTenantlessSession.sessionToken,
86
+ apiKey: '',
87
+ })
88
+
89
+ const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
90
+ name: `Cookbook Agent provider matrix ${runSuffix}`,
91
+ })
92
+ tenantSlug = tenantResp.data.slug!
93
+ ```
94
+
95
+ ## 003 — sarah signs in tenant-scoped — the canonical client
96
+
97
+ Re-mint sarah's session with the new tenant slug. This bearer is
98
+ the canonical tenant-scoped client every subsequent step uses; it
99
+ is assigned to the closure-scoped `api` slot so the scenario
100
+ cookbook's steps inherit it via inlining.
101
+
102
+ ```typescript
103
+ // A tenant-scoped login requires X-API-Key — mint a public_api key for
104
+ // the fresh tenant via the platform-admin side door (root Bearer). In the
105
+ // web console this is Settings -> API Keys; any of the tenant's keys
106
+ // satisfies the login gate, and the minted Bearer inherits this key's
107
+ // origin policy.
108
+ const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
109
+ name: `Cookbook Bootstrap Key ${runSuffix}`,
110
+ data_access_mode: 'tokenized',
111
+ })
112
+ ctx.tenantApiKey = mintedKey.api_key
113
+
114
+ const sarahTenantSession = await createSession({
115
+ baseUrl: process.env.ALVERA_BASE_URL!,
116
+ email: ctx.sarahEmail,
117
+ password: ctx.sarahPassword,
118
+ tenantSlug,
119
+ apiKey: ctx.tenantApiKey,
120
+ })
121
+ api = createIsolatedPlatformApi({
122
+ baseUrl: process.env.ALVERA_BASE_URL!,
123
+ sessionToken: sarahTenantSession.sessionToken,
124
+ apiKey: ctx.tenantApiKey,
125
+ })
126
+ ```
127
+
128
+ ## 004 — sarah creates the datalake
129
+
130
+ Provision a datalake on the new tenant. The DB schema is scoped to the cookbook run via `runSuffix` so parallel
131
+ cookbook runs do not collide on schema names. Local-dev defaults
132
+ (`postgres` on `localhost:5432`, `alvera_dev_foundation`) match
133
+ the seeded `dev.exs` setup; LocalStack S3 (`localhost:4566`)
134
+ serves the lake bucket (`alvera-platform-dev`, scoped by `base_path`). The datalake's
135
+ server-derived `slug` is captured into `datalakeSlug`.
136
+
137
+ ```typescript
138
+ const DB_HOST = 'localhost'
139
+ const DB_PORT = 5432
140
+ const DB_USER = 'postgres'
141
+ const DB_PASS = 'postgres'
142
+ const DB_NAME = 'alvera_dev_foundation'
143
+ const DB_SCHEMA = `cookbook_${runSuffix}`
144
+
145
+ const S3 = {
146
+ cloud_storage_type: 'aws' as const,
147
+ region: 'us-east-1',
148
+ access_key_id: 'test',
149
+ secret_access_key: 'test',
150
+ endpoint: 'http://localhost:4566',
151
+ }
152
+
153
+ const datalakeResp = await api.datalakes.create(tenantSlug, {
154
+ name: `Cookbook Agent provider matrix Datalake ${runSuffix}`,
155
+ description: 'Datalake provisioned by cookbook doctest for agent-provider-matrix.',
156
+ timezone: 'America/New_York',
157
+ pool_size: 5,
158
+
159
+ db_writer_host: DB_HOST,
160
+ db_writer_port: DB_PORT,
161
+ db_writer_name: DB_NAME,
162
+ db_writer_schema: DB_SCHEMA,
163
+ db_writer_auth_method: 'password',
164
+ db_writer_user: DB_USER,
165
+ db_writer_pass: DB_PASS,
166
+ db_writer_enable_ssl: false,
167
+ db_reader_host: DB_HOST,
168
+ db_reader_port: DB_PORT,
169
+ db_reader_name: DB_NAME,
170
+ db_reader_schema: DB_SCHEMA,
171
+ db_reader_auth_method: 'password',
172
+ db_reader_user: DB_USER,
173
+ db_reader_pass: DB_PASS,
174
+ db_reader_enable_ssl: false,
175
+
176
+ // One bucket, scoped per run by `base_path` — the same shape the dev
177
+ // platform's own datalakes use. The old paired
178
+ // `<domain>-lake-{un,}regulated` buckets died with the two-tier lake.
179
+ cloud_storage: { ...S3, bucket: 'alvera-platform-dev', base_path: `cookbook/${runSuffix}` },
180
+ })
181
+ datalakeSlug = datalakeResp.data.slug!
182
+ // `tools.create` and a few other endpoints accept the datalake by
183
+ // UUID in the request body rather than only the slug in the URL,
184
+ // so keep it on `ctx` for downstream steps that need it.
185
+ ctx.datalakeId = datalakeResp.data.id!
186
+ ```
187
+
188
+ ## 005 — enqueue the datalake migrations
189
+
190
+ `datalakes.create` persists the datalake row in `:new` status but
191
+ does not itself run the schema migrations. Migration is a
192
+ separately-triggered async job so the operator controls when the
193
+ (potentially slow) per-industry DDL runs. `datalakes.migrate`
194
+ enqueues the `DatalakeMigrationWorker` Oban job and returns
195
+ immediately with `status: 'enqueued'` plus the Oban `job_id`. The
196
+ poll in §006 then waits for that worker to finish — without this
197
+ call the datalake would sit at `:new` forever.
198
+
199
+ ```typescript
200
+ const migrateResp = await api.datalakes.migrate(tenantSlug, datalakeSlug)
201
+ if (migrateResp.data.status !== 'enqueued') {
202
+ throw new Error(
203
+ `datalake migration not enqueued (status: ${migrateResp.data.status})`,
204
+ )
205
+ }
206
+ ```
207
+
208
+ ## 006 — poll the datalake until status is ready
209
+
210
+ Datalake migrations are async — the §005 migrate call enqueued a
211
+ `DatalakeMigrationWorker` Oban job that runs the per-industry
212
+ schema migrations, deploys PostgREST roles, and provisions the
213
+ service-account bot user. Downstream resource creates (tools,
214
+ workflows) only need `datalakeId` to be persisted (which it is
215
+ the moment §004 returns), but any scenario step that touches
216
+ the regulated/unregulated DBs or the dataset infrastructure
217
+ requires `status: 'ready'`. Polling here makes every foundation
218
+ scenario cookbook deterministic regardless of how long the cold
219
+ migration takes on a given host; the loop exits the moment the
220
+ datalake reaches `:ready`, so the 5-minute cap is paid only in
221
+ failure mode.
222
+
223
+ ```typescript
224
+ const READY_TIMEOUT_MS = 5 * 60_000
225
+ const READY_POLL_MS = 5_000
226
+ const deadline = Date.now() + READY_TIMEOUT_MS
227
+ let datalakeStatus: string | undefined
228
+ while (Date.now() < deadline) {
229
+ const { data } = await api.datalakes.get(tenantSlug, ctx.datalakeId)
230
+ datalakeStatus = data.status
231
+ if (datalakeStatus === 'ready') break
232
+ await new Promise((r) => setTimeout(r, READY_POLL_MS))
233
+ }
234
+ if (datalakeStatus !== 'ready') {
235
+ throw new Error(
236
+ `datalake did not reach :ready within ${READY_TIMEOUT_MS}ms (last status: ${datalakeStatus})`,
237
+ )
238
+ }
239
+ ```
240
+
241
+ ## 007 — a reusable "wait until the run has fired" helper
242
+
243
+ `workflows.run` only **schedules** a run. It records it and returns
244
+ immediately with `workflow_run_id` / `status` / `scheduled_at`; the
245
+ `workflow_run_log_id` and `batch_id` a scenario needs are written
246
+ later, when the run actually fires, and are read back from
247
+ `workflowRuns.get`.
248
+
249
+ Two traps live in that gap, so every foundation scenario shares one
250
+ helper rather than re-deriving it:
251
+
252
+ - **Poll until `workflow_run_log_id` is a string — NOT until `status`
253
+ leaves `'scheduled'`.** Those are different moments: the run reaches
254
+ `processing` first and writes the log id a beat later. A predicate on
255
+ status alone releases you to read a `null`, and because
256
+ `typeof null === 'object'` the symptom is a baffling *"expected
257
+ string, got object"* rather than an obvious nil.
258
+ - **Raise on `failed` carrying `failure_reason` rather than polling to
259
+ the deadline.** A scenario blocked on a run that will never fire
260
+ should say why on the first read, not thirty seconds later behind a
261
+ generic timeout.
262
+
263
+ The helper is stored on the shared `ctx` bag rather than declared as a
264
+ plain function because each numbered step compiles into its own `it()`
265
+ block — a bare `function` here would not be in scope for the steps that
266
+ call it.
267
+
268
+ ```typescript
269
+ const FIRED_TIMEOUT_MS = 60_000
270
+ const FIRED_POLL_MS = 1_000
271
+
272
+ ctx.waitForFiredRun = async (
273
+ runDatalakeSlug: string,
274
+ runId: string,
275
+ timeoutMs: number = FIRED_TIMEOUT_MS,
276
+ ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
277
+ const deadline = Date.now() + timeoutMs
278
+ let lastStatus: string | undefined
279
+ while (Date.now() < deadline) {
280
+ const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
281
+ lastStatus = data.status
282
+ if (data.status === 'failed') {
283
+ throw new Error(
284
+ `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
285
+ )
286
+ }
287
+ if (typeof data.workflow_run_log_id === 'string') {
288
+ return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
289
+ }
290
+ await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
291
+ }
292
+ throw new Error(
293
+ `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
294
+ )
295
+ }
296
+ ```
297
+
298
+ # Rollback
299
+
300
+ The cookbook doctest harness does not currently tear down the
301
+ created tenant / datalake / user — each run mints fresh names via
302
+ `runSuffix` so reruns do not collide, and the seeded local DB is
303
+ cheap to reset (`mix ecto.reset` on the platform). When the
304
+ validator graduates to continuous integration at Milestone 18
305
+ (running against the mockoon-backed mock env at `ALVERA_ENV=mock`),
306
+ no real state is created at all.
307
+
308
+ # Outcome
309
+
310
+ After this setup runs, the closure-scoped slots are populated as:
311
+
312
+ - `api` — a tenant-scoped `PlatformApi` client authenticated as
313
+ the industry-admin user
314
+ - `tenantSlug` — server-derived slug of the fresh foundation
315
+ tenant
316
+ - `datalakeSlug` — server-derived slug of the fresh foundation
317
+ datalake
318
+ - `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
319
+ credentials (kept on `ctx` because no scenario cookbook needs
320
+ to re-authenticate by default)
321
+ - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
322
+ setup via `admin.createTenantApiKey`); thread it into any
323
+ additional tenant-scoped `createSession` a scenario performs
324
+ - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
325
+ until a scheduled run has actually fired, then returns its
326
+ `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
327
+ reading `workflow_run_log_id` off the run response returns `null`
328
+ because run-workflow only schedules (see §007)
329
+
330
+ Scenario cookbooks under `industry: foundation` start at their own
331
+ `§001` with these slots already populated.
332
+
333
+ # See also
334
+
335
+ - `.agent/datalakes.md` — datalake create body shape (regulated +
336
+ unregulated tier configuration)
337
+ - `.agent/AGENTS.md` § SDK auth + client construction — root vs
338
+ tenantless vs tenant-scoped session scopes
339
+ - `integration-tests/tests/foundation/bootstrap.test.ts` — the
340
+ green test these snippets are lifted from (the §7b sibling
341
+ datalake creation is out of scope for this lean cookbook setup)