@alvera-ai/platform-sdk 0.21.3 → 0.21.4

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
@@ -320,8 +320,8 @@ forms are the SQL table names used by `executeSql`):
320
320
 
321
321
  `.agent/cookbook/<slug>.md` files are pure-markdown scenario
322
322
  stories — one business outcome each, told as a numbered API-call
323
- sequence drawn from a green end-to-end vitest scenario in the
324
- platform's integration-tests suite. Agents read these directly
323
+ sequence that the cookbook validator runs, step by step, against a
324
+ live platform. Agents read these directly
325
325
  from the filesystem at
326
326
  `node_modules/@alvera-ai/platform-sdk/.agent/cookbook/`; there
327
327
  is no SDK function and no CLI verb that returns cookbook bytes
@@ -333,24 +333,21 @@ cookbook that is missing one, repeats one, reorders them, leaves one
333
333
  empty, or adds a top-level section that is not on this list:
334
334
 
335
335
  - **Front matter** — `title`, `summary`, `use_case`, `slug`,
336
- `vitest_source` (list of integration-test files the snippets
337
- are lifted from, anchor file first), `status`. `use_case` is one
336
+ `status`. `use_case` is one
338
337
  of the four surfaces — `organic-marketing`,
339
338
  `subscription-saas`, `payments-compliance`,
340
339
  `primary-care-feedback` — and there is exactly one cookbook per use
341
340
  case. Three are at `cookbook/<use-case>.md`; the fourth is at
342
- `cookbook/primary-care.md`, because `use_case` tracks the integration
343
- suite it is lifted from (`integration-tests/tests/primary-care-feedback/`)
344
- and the filename does not. Read `use_case` from the front matter rather
345
- than deriving it from the filename.
341
+ `cookbook/primary-care.md`, because `use_case` keeps the use case's
342
+ full name and the filename does not. Read `use_case` from the front
343
+ matter rather than deriving it from the filename.
346
344
  - **Problem** — the business-outcome statement in domain terms,
347
- sourced from the anchor vitest's behaviour (not from
345
+ sourced from what the walk's steps actually do (not from
348
346
  customer-narrative documentation).
349
347
  - **Composition** — table of resources provisioned, mapped to
350
348
  the audience skill that owns each (setup / build / compose).
351
349
  - **Walkthrough** — numbered `## NNN — <step>` subsections, each
352
- carrying one fenced TypeScript block lifted from the anchor or
353
- ancillary vitest. State threads forward across subsections via
350
+ carrying one fenced TypeScript block. State threads forward across subsections via
354
351
  plain JavaScript variables in the validator's generated
355
352
  `describe` closure. A numbered step anywhere else in the file
356
353
  is refused.
@@ -363,8 +360,7 @@ empty, or adds a top-level section that is not on this list:
363
360
  step the cookbook does not have — and every step runs against a
364
361
  live platform — so a gotcha cannot outlive the behaviour it warns
365
362
  about.
366
- - **See also** — links to relevant per-resource reference MDs
367
- and to the anchor + ancillary vitest files.
363
+ - **See also** — links to relevant per-resource reference MDs.
368
364
 
369
365
  ### Every cookbook is self-reliant, and repeats what it needs
370
366
 
@@ -403,17 +399,14 @@ map inbound rows into the platform's upsert shapes. Inlining
403
399
  those templates verbatim inside every cookbook markdown
404
400
  bloats each recipe to several hundred lines of Liquid noise
405
401
  that obscures the cookbook's lesson. The corpus ships them
406
- as **vendored fixtures** instead — same pattern the platform
407
- repo's own integration tests use.
402
+ as **vendored fixtures** instead.
408
403
 
409
404
  - Fixtures live under
410
405
  `.agent/cookbook/_fixtures/<industry>/`. The leading
411
406
  underscore marks the directory as fragment infrastructure
412
407
  (not a cookbook); the validator's discovery walk skips it.
413
408
  Each `<industry>/` subdirectory carries a `README.md`
414
- listing every template + which cookbook consumes it + the
415
- production source-of-truth path it was vendored from
416
- (`platform/priv/liquid_templates/…`).
409
+ listing every template and which cookbook consumes it.
417
410
  - Cookbook code loads fixtures via the
418
411
  `COOKBOOK_FIXTURES_DIR` environment variable:
419
412
  ```typescript
package/.agent/async.md CHANGED
@@ -99,10 +99,10 @@ retry should:
99
99
  that names the uniqueness key (usually `/name` or `/slug`).
100
100
  3. Treat as "already created" and fetch the existing row.
101
101
 
102
- The integration test suite formalizes this as the
103
- "idempotent retry path" pattern in places where local state
104
- may have diverged from server state (e.g. a prior run wrote
105
- to the server but failed to capture the response locally).
102
+ This is the "idempotent retry path": use it wherever local
103
+ state may have diverged from server state (e.g. a prior run
104
+ wrote to the server but failed to capture the response
105
+ locally).
106
106
 
107
107
  ## Polling cost
108
108
 
@@ -52,15 +52,11 @@ the package.
52
52
 
53
53
  ## Provenance
54
54
 
55
- Templates are copied verbatim from
56
- [platform/priv/liquid_templates/](https://github.com/alvera-ai/platform/tree/develop/priv/liquid_templates),
57
- the production source-of-truth. The same vendoring chain
58
- already exists in
59
- `platform/integration-tests/tests/<industry>/fixtures/templates/`;
60
- this directory extends that chain into the SDK package so the
61
- cookbook corpus is self-contained at npm-install time.
62
-
63
- When a production template changes, re-copy the file here and
55
+ Each template is a verbatim copy of the template the platform
56
+ ships for that feed, vendored here so the cookbook corpus is
57
+ self-contained at npm-install time.
58
+
59
+ When the platform's template changes, re-copy the file here and
64
60
  re-run `make validate-cookbook` to confirm the cookbook still
65
61
  passes against the new template shape.
66
62
 
@@ -68,21 +64,21 @@ passes against the new template shape.
68
64
 
69
65
  ### `foundation/`
70
66
 
71
- | File | Consumed by cookbook(s) | Source-of-truth |
72
- |---|---|---|
73
- | `_lead_submissions_foundation_legal_entity.liquid` | `birthday-greeting-sms-trigger`, `score-leads-with-llm-categorization` | `priv/liquid_templates/data_activation/interoperability/foundation/airflow/_lead_submissions_foundation_legal_entity.liquid` |
74
- | `_lead_submissions_foundation_generic_table.liquid` | `score-leads-with-llm-categorization` | `priv/liquid_templates/data_activation/interoperability/foundation/airflow/_lead_submissions_foundation_generic_table.liquid` |
75
- | `_lead_submissions_foundation_mdm.liquid` | `score-leads-with-llm-categorization` | `priv/liquid_templates/data_activation/interoperability/foundation/airflow/_lead_submissions_foundation_mdm.liquid` |
67
+ | File | Consumed by cookbook(s) |
68
+ |---|---|
69
+ | `_lead_submissions_foundation_legal_entity.liquid` | `birthday-greeting-sms-trigger`, `score-leads-with-llm-categorization` |
70
+ | `_lead_submissions_foundation_generic_table.liquid` | `score-leads-with-llm-categorization` |
71
+ | `_lead_submissions_foundation_mdm.liquid` | `score-leads-with-llm-categorization` |
76
72
 
77
73
  ### `healthcare/`
78
74
 
79
- | File | Consumed by cookbook(s) | Source-of-truth |
80
- |---|---|---|
81
- | `_cahps_appointments_healthcare_patient.liquid` | `appointment-review-sms-workflow` | `priv/liquid_templates/data_activation/interoperability/healthcare/fhir4/athena/_cahps_appointments_healthcare_patient.liquid` |
82
- | `_cahps_appointments_healthcare_appointment.liquid` | `appointment-review-sms-workflow` | `priv/liquid_templates/data_activation/interoperability/healthcare/fhir4/athena/_cahps_appointments_healthcare_appointment.liquid` |
83
- | `_cahps_appointments_healthcare_mdm.liquid` | `appointment-review-sms-workflow` | `priv/liquid_templates/data_activation/interoperability/healthcare/fhir4/athena/_cahps_appointments_healthcare_mdm.liquid` |
84
- | `sample_two_page.pdf` (data) | `ai-agent-invoke` | `integration-tests/tests/healthcare/fixtures/documents/sample_two_page.pdf` |
85
- | `memorandum-of-association-01.png` (data) | `ai-agent-invoke` | `integration-tests/tests/healthcare/fixtures/documents/memorandum-of-association-01.png` |
75
+ | File | Consumed by cookbook(s) |
76
+ |---|---|
77
+ | `_cahps_appointments_healthcare_patient.liquid` | `appointment-review-sms-workflow` |
78
+ | `_cahps_appointments_healthcare_appointment.liquid` | `appointment-review-sms-workflow` |
79
+ | `_cahps_appointments_healthcare_mdm.liquid` | `appointment-review-sms-workflow` |
80
+ | `sample_two_page.pdf` (data) | `ai-agent-invoke` |
81
+ | `memorandum-of-association-01.png` (data) | `ai-agent-invoke` |
86
82
 
87
83
  The `contact-us-triage-with-llm` cookbook ingests into a generic
88
84
  table via the auto-provisioned identity contract, so it loads no
@@ -90,17 +86,17 @@ vendored template.
90
86
 
91
87
  ### `subscription/`
92
88
 
93
- | File | Consumed by cookbook(s) | Source-of-truth |
94
- |---|---|---|
95
- | `_customers_subscription_customer.liquid` | `welcome-sms-for-customers`, `dunning-sms-for-delinquent`, `bulk-ingest`, `rest-fetch` | `priv/liquid_templates/data_activation/interoperability/subscription/stripe/_customers_subscription_customer.liquid` |
96
- | `_customers_subscription_mdm.liquid` | `welcome-sms-for-customers`, `dunning-sms-for-delinquent`, `bulk-ingest`, `rest-fetch` | `priv/liquid_templates/data_activation/interoperability/subscription/stripe/_customers_subscription_mdm.liquid` |
97
- | `stripe_customers_batch1.csv` (data) | `bulk-ingest` | `integration-tests/tests/subscription/fixtures/csv/stripe_customers_batch1.csv` |
89
+ | File | Consumed by cookbook(s) |
90
+ |---|---|
91
+ | `_customers_subscription_customer.liquid` | `welcome-sms-for-customers`, `dunning-sms-for-delinquent`, `bulk-ingest`, `rest-fetch` |
92
+ | `_customers_subscription_mdm.liquid` | `welcome-sms-for-customers`, `dunning-sms-for-delinquent`, `bulk-ingest`, `rest-fetch` |
93
+ | `stripe_customers_batch1.csv` (data) | `bulk-ingest` |
98
94
 
99
95
  ### `payments/`
100
96
 
101
- | File | Consumed by cookbook(s) | Source-of-truth |
102
- |---|---|---|
103
- | `_payment_accounts_payments_payment_account.liquid` | `kyc-notification-on-account-activation` | `priv/liquid_templates/data_activation/interoperability/payments/atomic_fi/_payment_accounts_payments_payment_account.liquid` |
104
- | `_payment_accounts_payments_mdm.liquid` | `kyc-notification-on-account-activation` | `priv/liquid_templates/data_activation/interoperability/payments/atomic_fi/_payment_accounts_payments_mdm.liquid` |
105
- | `_compliance_screenings_payments_compliance_screening.liquid` | `sanctions-screening-with-agent-review` | `priv/liquid_templates/data_activation/interoperability/payments/atomic_fi/_compliance_screenings_payments_compliance_screening.liquid` |
106
- | `_compliance_screenings_payments_mdm.liquid` | `sanctions-screening-with-agent-review` | `priv/liquid_templates/data_activation/interoperability/payments/atomic_fi/_compliance_screenings_payments_mdm.liquid` |
97
+ | File | Consumed by cookbook(s) |
98
+ |---|---|
99
+ | `_payment_accounts_payments_payment_account.liquid` | `kyc-notification-on-account-activation` |
100
+ | `_payment_accounts_payments_mdm.liquid` | `kyc-notification-on-account-activation` |
101
+ | `_compliance_screenings_payments_compliance_screening.liquid` | `sanctions-screening-with-agent-review` |
102
+ | `_compliance_screenings_payments_mdm.liquid` | `sanctions-screening-with-agent-review` |
@@ -3,11 +3,6 @@ title: "Organic marketing: score, greet, campaign — on one raw lake"
3
3
  summary: The whole organic-marketing surface in one walk. Stand up a tenant and a raw datalake, then run the scenarios end to end — an LLM agent banding inbound leads into a four-way SMS fan-out, a pure-Liquid birthday trigger that schedules a greeting a year out and closes the loop through its authenticated link, and an A/B campaign that gates on suppression, splits inside the workflow and dispatches on SMS and email in one run. Ends with delivery reconciliation and a natural-language read of the lake.
4
4
  use_case: organic-marketing
5
5
  slug: organic-marketing
6
- vitest_source:
7
- - integration-tests/tests/organic-marketing/agent-driven-workflow.test.ts
8
- - integration-tests/tests/organic-marketing/generic-tables.test.ts
9
- - integration-tests/tests/organic-marketing/manual-upload-tool.test.ts
10
- - integration-tests/tests/bootstrap/bootstrap.test.ts
11
6
  status: draft
12
7
  ---
13
8
 
@@ -3,16 +3,6 @@ title: "Payments compliance: screen, decide, notify — on a tokenized lake"
3
3
  summary: The whole payments-compliance surface in one walk. Stand up a tenant and a raw datalake, turn tokenization on because a model is going to read this data, then run two scenarios end to end — an LLM agent disambiguating gray-zone sanctions screenings into a verdict-keyed SMS fan-out, and a KYC notification fired by an account activation. Ends by reading one row back through raw, tokenized and redacted keys to show what each reader actually sees, then inspecting how far the tokenized copy has got and repairing one of its tables.
4
4
  use_case: payments-compliance
5
5
  slug: payments-compliance
6
- vitest_source:
7
- - integration-tests/tests/payments-compliance/sanctions-review-workflow.test.ts
8
- - integration-tests/tests/payments-compliance/kyc-upload-workflow.test.ts
9
- - integration-tests/tests/payments-compliance/interoperability-contracts.test.ts
10
- - integration-tests/tests/payments-compliance/run-dac-single.test.ts
11
- - integration-tests/tests/payments-compliance/create-dac.test.ts
12
- - integration-tests/tests/payments-compliance/system-templates.test.ts
13
- - integration-tests/tests/payments-compliance/manual-upload-tool.test.ts
14
- - integration-tests/tests/payments-compliance/data-sources.test.ts
15
- - integration-tests/tests/bootstrap/bootstrap.test.ts
16
6
  status: draft
17
7
  ---
18
8
 
@@ -3,11 +3,6 @@ title: "Primary care: review, triage, extract — on a tokenized lake"
3
3
  summary: "The whole primary-care surface in one walk. Stand up a tenant, a raw datalake and the tokenized and redacted copies, then run three scenarios end to end — a post-visit review request that only reaches patients whose appointment was actually fulfilled, an LLM agent triaging inbound contact-us messages into a category-keyed fan-out, and a vision agent reading a scanned document. Ends by reading one appointment back through all three lakes to show what each reader sees, then inspecting how far the tokenized copy has got and repairing one of its tables."
4
4
  use_case: primary-care-feedback
5
5
  slug: primary-care
6
- vitest_source:
7
- - integration-tests/tests/primary-care-feedback/standard-workflow.test.ts
8
- - integration-tests/tests/primary-care-feedback/agent-driven-workflow.test.ts
9
- - integration-tests/tests/primary-care-feedback/ai-agent-invoke.test.ts
10
- - integration-tests/tests/bootstrap/bootstrap.test.ts
11
6
  status: draft
12
7
  ---
13
8
 
@@ -3,14 +3,6 @@ title: "Subscription SaaS: one customer spine, three workflows, three ways in"
3
3
  summary: "The whole subscription-billing surface in one walk. Stand up a tenant and a raw datalake, declare one customers table, and run three workflows over it — a welcome SMS gated on reachability, a dunning reminder gated on reachability and KYC, and an LLM agent that bands accounts by priority and fans out one SMS per band. Then fill the same table three more ways — a bulk CSV upload, a pull-based REST fetch, and finally a teammate invited onto the tenant."
4
4
  use_case: subscription-saas
5
5
  slug: subscription-saas
6
- vitest_source:
7
- - integration-tests/tests/subscription-saas/standard-workflow.test.ts
8
- - integration-tests/tests/subscription-saas/dunning-sms-workflow.test.ts
9
- - integration-tests/tests/subscription-saas/agent-lead-triage.test.ts
10
- - integration-tests/tests/subscription-saas/run-dac-bulk.test.ts
11
- - integration-tests/tests/subscription-saas/run-dac-fetch.test.ts
12
- - integration-tests/tests/subscription-saas/invite-team.test.ts
13
- - integration-tests/tests/bootstrap/bootstrap.test.ts
14
6
  status: draft
15
7
  ---
16
8
 
@@ -251,13 +251,10 @@ not interchangeable.
251
251
 
252
252
  Two related shapes worth keeping straight:
253
253
 
254
- - **`mdm.verify`** (see `mdm.md`) takes looser input — it asks "is this
255
- really you?" about an already-known subject, so it needs `value` plus a
256
- `uri` **or** a `type`, not both. Its example is not a template for this
257
- one.
258
- - **payments** additionally requires `id_type` on each entry of its own
259
- verify payload. Per-domain differences are real; check the domain you are
260
- writing for.
254
+ - **`mdm.verify`** (see `mdm.md`) asks "is this really you?" about an
255
+ already-known subject. Its identifier entries carry `system` and `value`,
256
+ both required, with `id_type` and `country` optional — the same on every
257
+ datalake. Its example is not a template for this one.
261
258
 
262
259
  ## 4. Error envelopes
263
260
 
package/.agent/mdm.md CHANGED
@@ -41,8 +41,8 @@ vocabulary — `given_name`, `family_name`, `birth_date` and
41
41
  `gender` are gone, not renamed variants to pick between.
42
42
 
43
43
  ```
44
- MDMVerifyRequest every field the verifier matches
45
- ──────────────── ────────────────────────────────
44
+ MDMVerifyRequest every field the request takes
45
+ ──────────────── ─────────────────────────────
46
46
  required legal_entity_id
47
47
  individual first_name middle_name last_name
48
48
  preferred_name date_of_birth
@@ -53,6 +53,18 @@ vocabulary — `given_name`, `family_name`, `birth_date` and
53
53
  identifiers addresses
54
54
  ```
55
55
 
56
+ Not every field is evidence on its own (§2):
57
+
58
+ ```
59
+ can stand alone first_name last_name preferred_name
60
+ date_of_birth business_name date_formed
61
+ phone identifiers addresses
62
+ only beside one legal_entity_type middle_name
63
+ of those citizenship_country nationality
64
+ doing_business_as_names
65
+ jurisdiction_country email
66
+ ```
67
+
56
68
  An individual example:
57
69
 
58
70
  ```typescript
@@ -88,24 +100,56 @@ const { data: result } = await api.mdm.verify(
88
100
  ],
89
101
  },
90
102
  )
91
- // result.status === 'verified' | 'not_verified'
103
+ // result.status === 'verified' — the only success body
92
104
  // result.legal_entity_id === body.legal_entity_id
93
- // result.verified_at — ISO 8601 timestamp
94
- // when status === 'not_verified':
95
- // result.errors — { <field_name>: [reason, ...] } per-field failure detail
105
+ // result.verified_at — ISO 8601 timestamp
96
106
  ```
97
107
 
98
- The response is **polymorphic on `status`**:
108
+ `verified` is the **only** success body. A subject that does not
109
+ verify is not a body you read — the call throws, like every other
110
+ route that answers 4xx:
99
111
 
100
112
  ```
101
- status === 'verified' → 200 { legal_entity_id, verified_at }
102
- status === 'not_verified' → 422 { legal_entity_id, verified_at, errors }
113
+ verified → 200 { status: 'verified', legal_entity_id, verified_at }
114
+ did not verify → throws AlveraApiError, _httpStatus 422
115
+ { errors: [ { title: 'Invalid value',
116
+ source: { pointer: '/<field>' },
117
+ detail: '<why it failed>' } ] }
118
+ one entry per field that did not match
119
+ subject not found → throws AlveraApiError, _httpStatus 404
120
+ ```
121
+
122
+ So `data` is only ever the verified answer, and every failed check
123
+ reaches you in `catch`, in the same envelope every other 422 uses
124
+ (see `errors.md`). Read which fields failed from each entry's
125
+ `source.pointer`:
126
+
127
+ ```typescript
128
+ import type { AlveraApiError } from '@alvera-ai/platform-sdk'
129
+
130
+ try {
131
+ const { data } = await api.mdm.verify(tenantSlug, datalakeSlug, {
132
+ legal_entity_id: '00000000-0000-0000-0000-000000000000',
133
+ last_name: 'Garcia',
134
+ addresses: [{ postal_code: '94110', country: 'US' }],
135
+ })
136
+ // data.status === 'verified' — act on this person
137
+ } catch (err) {
138
+ const status = (err as { _httpStatus?: number })._httpStatus
139
+ if (status === 422) {
140
+ for (const e of (err as AlveraApiError).errors) {
141
+ // e.source.pointer — '/last_name', '/addresses', '/base', …
142
+ // e.detail — 'does not match any known name', …
143
+ }
144
+ } else if (status === 404) {
145
+ // no legal entity has this id
146
+ } else {
147
+ throw err
148
+ }
149
+ }
103
150
  ```
104
151
 
105
- A `not_verified` response is a successful identity check that
106
- returned a negative result — it arrives as a 422 whose body
107
- still carries the full envelope. Distinguish it from the other
108
- rejections (404 for missing subject, 401 for missing auth).
152
+ A 401 (missing or wrong key) throws too.
109
153
 
110
154
  ### One subject: the legal entity
111
155
 
@@ -128,14 +172,23 @@ declare the column; every generic-table row struct already has it.
128
172
 
129
173
  ## 2. Rules the type cannot encode
130
174
 
131
- ### `legal_entity_id` is required; at least one identity field with it
175
+ ### `legal_entity_id` is required; at least one field that stands alone with it
132
176
 
133
177
  `legal_entity_id` (the master record to verify against) is the
134
178
  only required field. Every other property is optional, and the
135
- verifier requires **at least one** of them at runtime:
136
-
137
- - a body with only `legal_entity_id` → 422 with
138
- `errors.base: ["at least one verification field is required"]`
179
+ verifier requires **at least one field that can stand alone as
180
+ evidence** at runtime: `first_name`, `last_name`, `preferred_name`,
181
+ `date_of_birth`, `business_name`, `date_formed`, `phone`,
182
+ `identifiers` or `addresses`. The other seven (`legal_entity_type`,
183
+ `middle_name`, `citizenship_country`, `nationality`,
184
+ `doing_business_as_names`, `jurisdiction_country`, `email`) are
185
+ accepted, but count only beside one of those nine.
186
+
187
+ - a body with only `legal_entity_id`, or with only fields from the
188
+ seven → 422, one entry with `source.pointer: '/base'` and
189
+ `detail: 'at least one verification field is required'`
190
+ - an address alone is enough evidence; if it does not match, the
191
+ 422 carries the `/addresses` entry and nothing else
139
192
 
140
193
  A body carrying the retired vocabulary — `given_name`,
141
194
  `family_name`, `birth_date`, `gender` — falls into that same 422,
@@ -160,9 +213,33 @@ addresses per-component exact, case- and whitespace-
160
213
  insensitive; at least one address must match
161
214
  ```
162
215
 
163
- All supplied fields must pass for the overall result to be
164
- `'verified'`. A single failing field flips the result to
165
- `'not_verified'` and surfaces the field name in `errors`.
216
+ All supplied fields must pass for the call to return
217
+ `'verified'`. Each field that fails adds one entry to the thrown
218
+ 422, pointing at the field you sent:
219
+
220
+ ```
221
+ source.pointer detail
222
+ /last_name does not match any known name
223
+ /first_name does not match any known name (see below)
224
+ /business_name does not match any known name
225
+ /date_of_birth does not match
226
+ /date_formed does not match
227
+ /identifiers no matching identifier found
228
+ /phone does not match any known phone number
229
+ /addresses no matching address found
230
+ /addresses/country can't be blank (an address sent without a country)
231
+ /base at least one verification field is required
232
+ ```
233
+
234
+ First, middle and preferred names are checked together as one
235
+ bag of given names, so they fail as one entry. It points at a
236
+ given-name field you actually sent: `/first_name` if you sent one,
237
+ else `/middle_name`, else `/preferred_name`.
238
+
239
+ Two fields failing is two entries; nothing is summarised into a
240
+ single verdict. The mock answers its wrong-details case with one
241
+ `/base` entry, `detail: 'Verification match failed'`; the platform
242
+ itself names the field.
166
243
 
167
244
  ### `legal_entity_id` is the legal entity's own id
168
245
 
@@ -176,11 +253,12 @@ redacted lakes — rather than a second row with its own id. The
176
253
  old failure mode where passing the regulated twin's id returned a
177
254
  404 cannot arise.
178
255
 
179
- If the supplied `legal_entity_id` doesn't resolve, the response
180
- is a 404, not a `'not_verified'`. The distinction matters:
256
+ If the supplied `legal_entity_id` doesn't resolve, the call throws
257
+ a 404 envelope (`title: 'Not Found'`, `detail: 'Subject not found'`,
258
+ empty `source.pointer`), not a 422. The distinction matters:
181
259
 
182
- - `'not_verified'` -> "entity exists, identity fields didn't match"
183
- - 404 -> "entity doesn't exist at all"
260
+ - 422 -> "entity exists, identity fields didn't match"
261
+ - 404 -> "entity doesn't exist at all"
184
262
 
185
263
  A consumer that conflates the two will mis-route an identity
186
264
  challenge as a missing-record error.
@@ -245,13 +323,14 @@ Two limits that follow from the same rule:
245
323
  **Server-derived (Response-only).**
246
324
 
247
325
  ```
248
- status enum — 'verified' | 'not_verified'
249
- verified_at string — ISO 8601 timestamp of the verification attempt
250
- errors object — present only when status === 'not_verified';
251
- maps field name to an array of failure reasons
326
+ status enum — 'verified', its only value
327
+ verified_at string — ISO 8601 timestamp of the verification
252
328
  ```
253
329
 
254
- **Caller-supplied (round-trip on the verified branch).**
330
+ There is no `errors` field on the response: a failed check is a
331
+ thrown 422 (§4), never a body.
332
+
333
+ **Caller-supplied (round-trip in the verified answer).**
255
334
 
256
335
  ```
257
336
  legal_entity_id required UUID — the master record to verify against
@@ -270,35 +349,35 @@ legal_entity_id required UUID — the master record to verify against
270
349
 
271
350
  ## 4. Error envelopes
272
351
 
273
- Standard JSON:API envelopes per `errors.md`. Common rejections:
274
-
275
- | `source.pointer` | Cause |
276
- |-------------------------------|--------------------------------------------------------|
277
- | `/legal_entity_id` | missing, or no legal entity has this id |
278
- | `errors.base` | no identity field was supplied, or only retired ones (`given_name`, `family_name`, `birth_date`, `gender`) which are ignored — 422 with `status: "not_verified"` in the body |
279
- | `/identifiers/0/system` | missing system on an identifier entry |
280
- | `/identifiers/0/value` | missing value on an identifier entry |
281
-
282
- The `not_verified` outcome is always a **422 response** carrying
283
- the full body envelope; the two shapes differ only in `errors`:
284
-
285
- - **Identity mismatch (most common)** — `errors` keyed by the
286
- failing field(s), e.g. `errors.last_name`, or `errors.base`
287
- with "Verification match failed". The entity exists; the
288
- supplied identity didn't match.
289
- - **No usable identity fields** — `errors.base` set to
290
- `["at least one verification field is required"]`. Verification
291
- was structurally impossible: nothing supplied, or only retired
292
- fields, which are ignored.
293
-
294
- Malformed identifier entries surface the embed failure flattened
295
- into the field's error list — `errors.identifiers: ["value can't
296
- be blank"]` — still a 422, never a 500. `system` and `value` are
297
- the required pair; `id_type` and `country` are optional.
298
-
299
- Pattern-match on `status` first, then on the HTTP status code.
300
- The 404 case (subject doesn't exist) is a separate envelope —
301
- see §6 gotcha 2.
352
+ Every error `.verify` returns is the standard JSON:API envelope
353
+ per `errors.md`, thrown as `AlveraApiError`. There is no second
354
+ shape. Common rejections:
355
+
356
+ | HTTP | `source.pointer` | Cause |
357
+ |------|---------------------------|-------|
358
+ | 422 | `/<field>` | a field you sent did not match the master record — one entry per field (§2 lists the pointers and details) |
359
+ | 422 | `/base` | no field that can stand alone as evidence was supplied (§2) — including a body of only `email`, `middle_name` or the other five that count only beside one, or only retired fields (`given_name`, `family_name`, `birth_date`, `gender`), which are ignored |
360
+ | 422 | `/legal_entity_id` | `legal_entity_id` missing from the body |
361
+ | 422 | `/identifiers/0/system` | missing system on an identifier entry |
362
+ | 422 | `/identifiers/0/value` | missing value on an identifier entry |
363
+ | 404 | `""` (empty) | no legal entity has this id — `title: 'Not Found'`, `detail: 'Subject not found'` |
364
+
365
+ A subject that does not verify and a body the platform refuses
366
+ arrive the same way: a 422 whose entries all carry
367
+ `title: 'Invalid value'`. Read the field from `source.pointer`.
368
+ There is no `errors` map keyed by field name, and a failure body
369
+ carries no `status`, `legal_entity_id` or `verified_at`.
370
+
371
+ `system` and `value` on an identifier are required by the request
372
+ schema, so a missing one is refused before verification runs, and
373
+ its pointer carries the entry's index. An embed the verifier
374
+ itself rejects points at the embed's field without an index: an
375
+ address sent without a country is `/addresses/country`, not
376
+ `/addresses/0/country`. `id_type` and `country` on an identifier
377
+ are optional. Either way it is a 422, never a 500.
378
+
379
+ Branch on `_httpStatus`: 422 for a failed check or a refused body,
380
+ 404 for a subject that does not exist (§6 gotcha 2).
302
381
 
303
382
  ## 5. Lifecycle
304
383
 
@@ -344,26 +423,30 @@ through `api.mdm`.
344
423
 
345
424
  ## 6. Gotchas
346
425
 
347
- 1. **Every `not_verified` outcome is a 422 whose body still
348
- carries the envelope.** Read the body's `status` — don't let
349
- generic 4xx handling swallow the `errors` payload that
350
- explains which fields failed. Gate the downstream "act on
351
- this person" path on `result.status === 'verified'`.
352
-
353
- 2. **`legal_entity_id` missing → 404, identity mismatch → 422
354
- with a body.** A verify call against a deleted or never-existed
355
- entity id returns a 404 envelope, NOT a `'not_verified'`
356
- response. Consumers that handle only the polymorphic
357
- verify body will surface the 404 as an unexpected exception.
358
- Wrap the verify call in standard 4xx handling per `errors.md`.
359
-
360
- 3. **At least one identity field is required.** A body with only
361
- `legal_entity_id` — or only retired fields like `given_name`
362
- or `birth_date`, which are ignored — returns a 422 with
363
- `errors.base` set to `["at least one verification field is
364
- required"]` and `status: "not_verified"` in the body. This is
365
- enforced at runtime rather than in the request schema, so the
366
- TypeScript type permits the body but the wire rejects it.
426
+ 1. **A subject that does not verify throws; there is no
427
+ `not_verified` body.** `data` is only ever the verified answer.
428
+ Every failed check is a thrown `AlveraApiError` with
429
+ `_httpStatus === 422`, one entry per mismatched field. Code
430
+ written for the old `{ status: 'not_verified', errors: {…} }`
431
+ body never runs: its branch is unreachable and the 422 lands in
432
+ `catch`. Don't let generic 4xx handling swallow it — read each
433
+ entry's `source.pointer` to learn which fields failed.
434
+
435
+ 2. **Subject missing → 404, identity mismatch → 422.** Both
436
+ throw, in the same envelope. A verify call against a deleted
437
+ or never-existed entity id throws a 404 (`title: 'Not Found'`,
438
+ empty pointer), not a 422. Branch on `_httpStatus` before
439
+ reading pointers, per `errors.md`.
440
+
441
+ 3. **At least one field that can stand alone is required.** A body
442
+ with only `legal_entity_id` — or only fields that count just
443
+ beside another (`email`, `middle_name`, `nationality`, …), or
444
+ only retired fields like `given_name` or `birth_date`, which are
445
+ ignored — throws a 422 with one
446
+ entry: `source.pointer: '/base'`, `detail: 'at least one
447
+ verification field is required'`. This is enforced at runtime
448
+ rather than in the request schema, so the TypeScript type
449
+ permits the body but the wire rejects it.
367
450
 
368
451
  4. **Unknown fields are ignored, not rejected.** There is one
369
452
  field set (§1) and no domain scoping, but anything outside it
@@ -385,8 +468,9 @@ through `api.mdm`.
385
468
  case, and trailing slashes in `system` URIs all matter.
386
469
  Compare with the master record's identifier system strings
387
470
  character-for-character; a `'http://hospital.org/mrn'` vs
388
- `'http://hospital.org/mrn/'` mismatch will flip the verifier
389
- to `'not_verified'` even when the value is correct.
471
+ `'http://hospital.org/mrn/'` mismatch fails the check with an
472
+ `/identifiers` entry (`no matching identifier found`) even when
473
+ the value is correct.
390
474
  `system` and `value` are the required pair. `id_type`
391
475
  (`'digital_identifier'`, `'us_ssn'`, `'lei'`) and `country`
392
476
  are optional — the per-domain rule that once made `id_type`