@alvera-ai/platform-sdk 0.16.2 → 0.17.0

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
@@ -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
 
@@ -36,19 +36,28 @@ alias: `rm`.
36
36
  ## 1. The status lifecycle — and who writes each hop
37
37
 
38
38
  ```
39
- pending → queued → sent → delivered
40
- └──→ failed (+ failure_reason)
39
+ pending → accepted → queued → sending → sent → delivered
40
+ └──→ failed (+ failure_reason)
41
41
 
42
- also: invalidated (idempotency rejected a duplicate)
42
+ also: duplicate (provider id already held by another send —
43
+ the send HAPPENED; terminal, never retried)
44
+ invalidated (idempotency key collision — the fire was
45
+ rejected, nothing went out)
43
46
  received (inbound message, direction: 'inbound')
44
47
  clicked (link in the message was clicked)
45
48
  ```
46
49
 
50
+ `accepted` (the provider took the send request) and `sending`
51
+ (dispatching to the carrier) are intermediate hops between `pending`
52
+ and `sent`; the order above is the order the contract declares them.
53
+ Treat any of them as "in flight, outcome unknown" — see **Status is
54
+ eventually honest** below.
55
+
47
56
  Three different writers touch a message row. Know which is which:
48
57
 
49
58
  | Writer | Writes | When |
50
59
  |---|---|---|
51
- | the action executor | the row itself; `pending`→`sent`, `external_id`, `sent_at` | 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`) |
60
+ | 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 |
52
61
  | the action status updater | `delivered` / `failed`, `delivered_at`, `failure_reason` | later, when its poll runs (see `action_status_updaters.md` §7) |
53
62
  | `connectedApps.updateMessageTracking` | `opened_at`, `form_submitted_at` — **never `status`** | when the recipient opens/submits the magic-link page |
54
63
 
@@ -63,6 +72,34 @@ Two consequences:
63
72
  delivered nor failed yet. A UI should show that in-between state,
64
73
  not guess.
65
74
 
75
+ ### `failed` vs `duplicate` — never retry the second
76
+
77
+ These are **not** interchangeable, and the difference decides whether
78
+ a row is safe to re-send:
79
+
80
+ | | `failed` | `duplicate` |
81
+ |---|---|---|
82
+ | provider id | none was ever returned | one came back, but another send through the same tool already holds it |
83
+ | did anything go out? | no | **yes — the send happened** |
84
+ | retryable? | yes | **no** — never retried, never re-sent |
85
+
86
+ So a "retry everything that didn't reach `delivered`" sweep is wrong:
87
+ it must select on `failed`, never on "not delivered". Re-sending a
88
+ `duplicate` sends a second real message to a recipient who already
89
+ got one.
90
+
91
+ One consequence worth internalising: **the send path never records a
92
+ message that has an `external_id` as `failed`.** Once a provider id
93
+ comes back, the row stays `pending` until the delivery poller
94
+ resolves it. `failed` therefore means "nothing left the building",
95
+ not "we don't know yet".
96
+
97
+ `invalidated` is a third, separate thing — an idempotency-key
98
+ collision rejected the fire before it went out (see `idempotency_key`
99
+ in §2). Do not read the word "duplicate" in that context as the
100
+ `duplicate` status: `invalidated` means nothing was sent,
101
+ `duplicate` means something was.
102
+
66
103
  ## 2. Fields
67
104
 
68
105
  Identity + routing:
@@ -98,8 +135,10 @@ Provenance (which workflow produced this):
98
135
  ```
99
136
  workflow_id, action_id, decision_key, context_key,
100
137
  idempotency_key — unique per (mdm_subject_id,
101
- idempotency_key); a duplicate fire
102
- lands as status 'invalidated'
138
+ idempotency_key); a repeat fire on the
139
+ same key lands as status 'invalidated'
140
+ (nothing sent) — NOT status 'duplicate',
141
+ which means a send did go out
103
142
  sender_tool_id
104
143
  ```
105
144
 
@@ -0,0 +1,192 @@
1
+ # Mock Services
2
+
3
+ <!-- MDOC !-->
4
+
5
+ How the platform reaches mocked third-party APIs, on a laptop and from deployed
6
+ environments. Two mock surfaces exist and they answer different questions:
7
+ **WireMock** stands in for third-party SaaS APIs, **LocalStack** stands in for AWS.
8
+
9
+ ## The two surfaces
10
+
11
+ **WireMock** (`wiremock/wiremock:3.10.0`) serves the stub corpus in
12
+ `wm_mappings/`, with response bodies in `wm_response_files/`. It covers the
13
+ third-party APIs the platform integrates with — Athenahealth, Twilio, Mailgun,
14
+ Stripe, SendGrid, HubSpot, QuickBooks, Airtable, Atomic FI, the LLM providers,
15
+ and a small set of AWS endpoints LocalStack cannot serve.
16
+
17
+ **LocalStack** serves AWS itself: `s3,lambda,sns,ssm,sqs,sts,logs,secretsmanager,cloudformation`
18
+ (`local-dependencies.yml`). See [Datalakes](datalakes.md) for how datalake
19
+ provisioning uses it.
20
+
21
+ The division is not arbitrary. LocalStack Community **cannot** serve
22
+ `pinpoint-sms-voice-v2` (501 — Ultimate tier only), so End User Messaging points
23
+ at WireMock for its send, its S3 media staging, and its STS credential probe. A
24
+ tool has one `endpoint_url`, so all three land on the same host.
25
+
26
+ ## The corpus
27
+
28
+ Counted on `release/0.23.0` — 16 files, 98 stubs. The feature mix is what makes
29
+ WireMock itself load-bearing rather than incidental:
30
+
31
+ | Feature | Uses | What it does |
32
+ |---|---:|---|
33
+ | `urlPathPattern` | 80 | Regex path matching |
34
+ | `jsonBody` / `body` / `base64Body` | 85 | Inline response bodies |
35
+ | `bodyFileName` | 21 | Bodies served from `wm_response_files/` |
36
+ | `bodyPatterns` | 11 | Request-body matching |
37
+ | `queryParameters` | 9 | Query matching |
38
+ | `scenarioName` + `newScenarioState` | 7 | **Stateful scenarios — a state machine across requests** |
39
+ | `--global-response-templating` | on | Handlebars templating in responses |
40
+
41
+ The last two rows are why the corpus is served by the real WireMock image
42
+ everywhere rather than by a bespoke matcher. Reimplementing stateful scenarios
43
+ and a templating engine means re-earning fidelity, and a gap in a mock surfaces
44
+ as a test passing against a fiction rather than as a failure.
45
+
46
+ ### Two gotchas that cost real time
47
+
48
+ **WireMock rejects unknown keys on a stub mapping.** A stray `_comment` field
49
+ makes it refuse to load the *whole file* and 404 everything in it, while
50
+ `json.load` and any JSON linter pass it happily. Put prose in the free-form
51
+ `metadata` object instead.
52
+
53
+ **WireMock loads mappings at boot.** After editing `wm_mappings/`, reload the
54
+ local container without restarting it:
55
+
56
+ ```bash
57
+ curl -X POST http://localhost:8080/__admin/mappings/reset
58
+ ```
59
+
60
+ That is the first thing to try when a stub 404s locally. It is not available on
61
+ the hosted instance — see below.
62
+
63
+ ## Running locally
64
+
65
+ `make run-backing-services` starts both. WireMock listens on `:8080`, LocalStack
66
+ on `:4566`. Tool bodies reach them by setting `endpoint_url` — blank means real
67
+ AWS, any value replaces host, port and scheme.
68
+
69
+ LocalStack's S3 state does **not** survive a container restart. Recreate a bucket
70
+ after one:
71
+
72
+ ```bash
73
+ AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 \
74
+ aws s3 mb s3://healthcare-lake-regulated --endpoint-url http://localhost:4566
75
+ ```
76
+
77
+ ## The hosted mock — `wiremock.alvera.ai`
78
+
79
+ Deployed environments used to need their own always-on WireMock, which is the
80
+ wrong billing shape for a service that is idle almost all of the time. The corpus
81
+ now runs on Cloudflare Containers behind one hostname, billed only while awake.
82
+
83
+ ```mermaid
84
+ flowchart TD
85
+ C["Consumers<br/>platform dev · UAT · integration-tests · CI"]
86
+ C -->|"https://wiremock.alvera.ai"| W
87
+
88
+ subgraph CF["Cloudflare"]
89
+ F["WAF custom rule<br/>/__admin → Block"]
90
+ W["Worker + Durable Object<br/>container binding"]
91
+ K["Container — basic<br/>wiremock/wiremock:3.10.0<br/>/mappings ◀ wm_mappings/<br/>/__files ◀ wm_response_files/"]
92
+ F -.->|"blocks before the Worker runs"| W
93
+ W -->|fetch| K
94
+ end
95
+ ```
96
+
97
+ **It lives in this repo**, at `mock-edge/`. Deploy from a normal checkout:
98
+
99
+ ```bash
100
+ cd mock-edge && wrangler deploy
101
+ ```
102
+
103
+ The single line that makes this work without a vendored copy of the corpus:
104
+
105
+ ```jsonc
106
+ "image_build_context": ".."
107
+ ```
108
+
109
+ That sets the Docker build context to the repo root, so the Dockerfile copies
110
+ `wm_mappings/` and `wm_response_files/` from where they already live. Without it
111
+ the context is `mock-edge/` and the corpus would have to be duplicated there —
112
+ two directories that look identical until they quietly aren't.
113
+
114
+ ### There is no authentication
115
+
116
+ The hostname is public and deliberately so. The corpus is synthetic fixture data,
117
+ the mock authenticates nobody, and a gate in front of it caused more problems
118
+ than it solved:
119
+
120
+ - Nineteen of the 98 stubs match on the `Authorization` header, because they
121
+ assert the *tool* authenticated to the API being mocked. Cloudflare Access
122
+ consumes `Authorization` for its own credential, so gating on that header made
123
+ those nineteen stubs stop matching.
124
+ - Any gate means a credential per consumer, which means minting, distributing,
125
+ rotating and revoking — for a service whose contents are fixtures.
126
+
127
+ The accepted risk is bot traffic waking the container. Workers include 10 million
128
+ requests a month before any charge, so the exposure is container time, not
129
+ request volume — and it is small enough to deal with if it ever shows up.
130
+
131
+ ### The admin API is blocked
132
+
133
+ WireMock's admin API has no authentication of its own: anything that can reach
134
+ `/__admin` can read the request journal, reset scenarios, or reprogram stubs.
135
+ Publicly hosted, that surfaces as CI failing for no visible reason. A WAF custom
136
+ rule on the zone refuses it:
137
+
138
+ ```
139
+ (http.host eq "wiremock.alvera.ai" and http.request.uri.path contains "/__admin") → Block
140
+ ```
141
+
142
+ Scoped by `http.host` so it cannot affect the other services on `alvera.ai`.
143
+ Cloudflare normalises the URL before evaluating rules, so `/%5f%5fadmin` and
144
+ `//__admin` are blocked too — verified, not assumed.
145
+
146
+ > #### The block is safe *because* the corpus is baked {: .warning}
147
+ >
148
+ > Stubs live in the image. Nothing registers them at runtime, so nothing needs
149
+ > the admin API in production. **If that ever changes — if an app wants to
150
+ > register stubs at runtime — this rule must be reconsidered, not worked
151
+ > around.** It would also need `max_instances: 1` and a fixed container name,
152
+ > because registrations do not survive an instance sleeping or a request landing
153
+ > on a different instance.
154
+
155
+ ### Adding or changing a stub
156
+
157
+ Edit `wm_mappings/` (or `wm_response_files/`), then redeploy. There is no hot
158
+ reload on the hosted instance — the image is the state, which makes the hosted
159
+ mock immutable per deploy and reproducible for CI.
160
+
161
+ ### Verifying the hosted mock matches local
162
+
163
+ The failure worth catching is the two environments quietly disagreeing. Compare
164
+ them by hashing responses rather than asserting content, so the check does not
165
+ break when someone edits a stub — only when local and edge diverge:
166
+
167
+ ```bash
168
+ for base in http://localhost:8080 https://wiremock.alvera.ai; do
169
+ curl -s -X POST -H 'content-type: application/json' -d '{"contents":[]}' \
170
+ "$base/gemini/v1beta/models/gemini-2.0-flash:generateContent" | shasum -a 256
171
+ done
172
+ ```
173
+
174
+ The gemini stubs are the sharpest test: they use a seven-state scenario, so three
175
+ identical POSTs return three *different* bodies in a fixed order. Matching hashes
176
+ across three successive calls proves both environments walked the same state
177
+ machine — something no stateless matcher could fake.
178
+
179
+ ## LocalStack at the edge
180
+
181
+ Not done, and deliberately scoped apart. LocalStack is also just an image and
182
+ rides the same mechanism, but Containers **sleep by design** and LocalStack's S3
183
+ state already does not survive a restart. What is an occasional annoyance locally
184
+ becomes routine when instances sleep between runs. Anything depending on objects
185
+ persisting must move to R2, which speaks S3 natively — migration work, not a flag
186
+ flip.
187
+
188
+ ## Related
189
+
190
+ - [Tools](tools.md) — tool bodies, `endpoint_url`, and REST API auth methods
191
+ - [Datalakes](datalakes.md) — how datalake provisioning uses LocalStack
192
+ - [Connected Apps](connected_apps.md) — the third-party integrations the corpus stands in for
package/.agent/tools.md CHANGED
@@ -257,11 +257,12 @@ Each branch has its own required-list. Common patterns:
257
257
  OR (iam_role)
258
258
  OR (assume_role_arn +
259
259
  assume_role_external_id);
260
- optional endpoint_url (overrides the End
261
- User Messaging API endpoint) and
262
- media_endpoint_url (overrides the S3
263
- media-staging endpoint; blank S3
264
- derived from the tool's region)
260
+ optional endpoint_url (ONE override,
261
+ covering everything the tool reaches:
262
+ the SendMediaMessage send, S3 media
263
+ staging, and the save-time credential
264
+ probe; blank each service resolves to
265
+ its own regional AWS host)
265
266
 
266
267
  sql_database body db_type, db_host, db_port, db_name,
267
268
  auth_method, db_username,
@@ -283,6 +284,45 @@ The full per-branch shape lives in
283
284
  `packages/sdk/src/generated/types.gen.ts` under the matching
284
285
  `Tool<Type>BodyWritable` types.
285
286
 
287
+ ### Authoring an End User Messaging tool locally — every auth method works
288
+
289
+ `endpoint_url` is one override for the whole tool. Point it at a local
290
+ AWS-compatible stub and the send, the media staging, and the
291
+ save-time credential probe all follow it; leave it blank and each
292
+ service resolves to its own regional AWS host. There is no second
293
+ endpoint field — `media_bucket` names a bucket, not a host.
294
+
295
+ All three auth methods can be authored locally **and** against real
296
+ AWS. They do not all reach the same host, and are not meant to:
297
+
298
+ ```
299
+ auth_method endpoint_url set (local) endpoint_url blank
300
+ ───────────────────────────────────────────────────────────────────
301
+ access_key everything → your stub everything → AWS
302
+ iam_role everything → your stub, but everything → AWS
303
+ the credential itself still
304
+ comes from the platform's
305
+ ambient chain
306
+ assume_role send + staging → your stub; everything → AWS
307
+ the STS AssumeRole leg is
308
+ NOT tool-steered (below)
309
+ ```
310
+
311
+ **Why `assume_role` deliberately splits.** That STS request is signed
312
+ with the **platform's own** ambient credentials — your role trusts a
313
+ platform identity, and the tool contributes only `assume_role_arn`
314
+ (the role in YOUR account) and `assume_role_external_id`. Letting a
315
+ tool field steer it would send a live SigV4 signature made with the
316
+ platform task role to a host the tool's author picked. So the STS
317
+ endpoint is a per-deployment constant, resolved by the platform, and
318
+ `assume_role` is the one auth method where the tool does not choose
319
+ it. This is by design — not a gap, and not a reason to prefer another
320
+ auth method when authoring locally.
321
+
322
+ If a local `assume_role` save is reaching real STS when you expected a
323
+ stub, that is the platform deployment's own STS configuration, not the
324
+ tool body. Nothing you can put in `endpoint_url` changes it.
325
+
286
326
  ### `assume_role` needs the platform's ARN — read it from `GET /api/ping`
287
327
 
288
328
  The `assume_role` auth method (on `sns` and `end_user_messaging`
package/dist/index.d.mts CHANGED
@@ -991,7 +991,7 @@ type ResolvePageResponse = {
991
991
  /**
992
992
  * Delivery status
993
993
  */
994
- status: 'pending' | 'queued' | 'sent' | 'delivered' | 'read' | 'opened' | 'clicked' | 'form_submitted' | 'failed' | 'invalidated' | 'customer_rejected' | 'dry_run' | 'received';
994
+ status: 'pending' | 'accepted' | 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'opened' | 'clicked' | 'form_submitted' | 'failed' | 'invalidated' | 'duplicate' | 'customer_rejected' | 'dry_run' | 'received';
995
995
  /**
996
996
  * Human-readable detail behind the current status, rendered from the delivery provider's event (e.g. the mailgun event name, or a bounce reason on failure)
997
997
  */
@@ -1943,7 +1943,7 @@ type UpdatePageResponse = {
1943
1943
  /**
1944
1944
  * Message status after applying the update through the monotonic guard
1945
1945
  */
1946
- status?: 'pending' | 'queued' | 'sent' | 'delivered' | 'read' | 'opened' | 'clicked' | 'form_submitted' | 'failed' | 'invalidated' | 'customer_rejected' | 'dry_run' | 'received';
1946
+ status?: 'pending' | 'accepted' | 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'opened' | 'clicked' | 'form_submitted' | 'failed' | 'invalidated' | 'duplicate' | 'customer_rejected' | 'dry_run' | 'received';
1947
1947
  };
1948
1948
  };
1949
1949
  /**
@@ -2322,10 +2322,6 @@ type EndUserMessagingResponse = {
2322
2322
  * S3 bucket (same region as the sending number) where author media is re-staged for SendMediaMessage
2323
2323
  */
2324
2324
  media_bucket: string;
2325
- /**
2326
- * Custom S3 endpoint URL for media staging (e.g. http://localhost:4566 for LocalStack); leave blank for AWS S3 in the tool's region
2327
- */
2328
- media_endpoint_url?: string | null;
2329
2325
  /**
2330
2326
  * Origination phone number or identity in E.164 format (e.g., +15551234567); must be MMS-capable
2331
2327
  */
@@ -4845,10 +4841,6 @@ type EndUserMessagingRequestWritable = {
4845
4841
  * S3 bucket (same region as the sending number) where author media is re-staged for SendMediaMessage
4846
4842
  */
4847
4843
  media_bucket: string;
4848
- /**
4849
- * Custom S3 endpoint URL for media staging (e.g. http://localhost:4566 for LocalStack); leave blank for AWS S3 in the tool's region
4850
- */
4851
- media_endpoint_url?: string | null;
4852
4844
  /**
4853
4845
  * Origination phone number or identity in E.164 format (e.g., +15551234567); must be MMS-capable
4854
4846
  */