@alvera-ai/platform-sdk 0.21.2 → 0.21.3-next.g393d024

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
@@ -113,8 +113,8 @@ also merges each finished batch into NDJSON artifacts in cold
113
113
  storage:
114
114
 
115
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)
116
+ <datalake>/workflows/<wf_slug>/merged/<batch_id>.ndjson (per-subject)
117
+ <datalake>/workflows/<wf_slug>/merged/<batch_id>/<decision_key>.ndjson (per-action payloads)
118
118
  ```
119
119
 
120
120
  The batch log row (`api.workflows.batchLogs.get`) carries the
@@ -409,6 +409,10 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
409
409
  | (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
410
410
  | `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
411
411
 
412
+ `refresh()` has two refusals of its own, both **409** with an empty
413
+ `source.pointer`: the updater is halted, or a poll for it is already
414
+ queued or running. Neither queues anything. See §7.
415
+
412
416
  ## 5. Lifecycle
413
417
 
414
418
  ### Create
@@ -509,7 +513,7 @@ check or retry for partial-delete states.
509
513
  The poll follows pages while `has_next` is true and stops on
510
514
  the first empty page.
511
515
 
512
- ## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
516
+ ## 7. Manual trigger via `refresh` — 202 or 409, and what `last_run_*` means
513
517
 
514
518
  An action status updater CAN be fired on demand:
515
519
 
@@ -548,16 +552,67 @@ while (Date.now() < deadline) {
548
552
  // row.last_run_status / last_run_error now describe THIS run
549
553
  ```
550
554
 
555
+ ### When `refresh` answers 409, nothing was queued
556
+
557
+ `refresh` refuses with **409 Conflict** in two cases. The body is the
558
+ standard `errors[]` envelope (`errors.md`), thrown with
559
+ `_httpStatus === 409`. Either way **no poll is queued**, and a one-shot
560
+ `updater_body` override sent with that call is not queued either, so
561
+ resend it on the retry.
562
+
563
+ | Case | `detail` | What clears it |
564
+ |------|----------|----------------|
565
+ | **Halted**: `status` is not `'active'` | `Updater is halted: status is <status>. Set status to active to resume polling.`, followed by ` Last run error: <text>` when one is recorded | Fix the cause, then `update()` with `status: 'active'` (below). Retrying `refresh` changes nothing. |
566
+ | **A poll is already queued or running**: a cron tick's, an earlier refresh's, or a later page of a run under way | `A poll for this updater is already queued, so this refresh was not. Retry once it finishes.` | Wait for that poll to finish (`last_run_at` advances), then call `refresh` again. |
567
+
568
+ Both carry `title: "Conflict"` and an empty `source.pointer`, so
569
+ `title` cannot tell them apart. Read the row instead: the halt is
570
+ checked first, so `status !== 'active'` means halted and anything else
571
+ means a poll is in flight.
572
+
573
+ ```typescript
574
+ async function refreshOrExplain(updaterId: string): Promise<void> {
575
+ try {
576
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
577
+ } catch (err) {
578
+ if ((err as { _httpStatus?: number })._httpStatus !== 409) throw err
579
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
580
+ if (data.status !== 'active') {
581
+ // Halted. Fix the cause, then update() with status: 'active'.
582
+ throw new Error(`updater halted (${data.status}): ${data.last_run_error ?? 'no run error recorded'}`)
583
+ }
584
+ // A poll is already queued or running. Wait for last_run_at to
585
+ // advance past data.last_run_at, then call refresh again.
586
+ }
587
+ }
588
+ ```
589
+
590
+ ### One run at a time per updater
591
+
592
+ A run reads every page of its window before it ends. A cron tick that
593
+ finds an earlier run of the same updater still going does not start a
594
+ second run beside it: it **skips**, and records when in
595
+ `last_skipped_at`. A skip is not a run, so it leaves `last_run_at`,
596
+ `last_run_status`, `last_run_error` and `last_run_events_found` alone.
597
+ The same rule is why `refresh` answers 409 while a poll is queued or
598
+ running.
599
+
600
+ `last_skipped_at` is `null` until a tick has skipped. A recent one
601
+ means a run outlasted a cron interval. If skips keep recurring, runs
602
+ take longer than the interval: lengthen `cron_expression` or narrow
603
+ the poll window.
604
+
551
605
  What the ASU still does NOT have is a **per-run log** (nothing like
552
606
  a data activation client's `logs` or a workflow's `batchLogs`). The
553
- run-outcome surface is four fields on the row itself, plus the halt
554
- flag:
607
+ run-outcome surface is four fields on the row itself, plus the skip
608
+ stamp and the halt flag:
555
609
 
556
610
  ```
557
611
  last_run_at when the poll worker last made progress
558
612
  last_run_status 'ok' | 'error'
559
613
  last_run_error why the last run stopped; null when it read its whole window
560
614
  last_run_events_found events fetched so far in the current run
615
+ last_skipped_at when a cron tick last skipped because a run was still going; null if none has
561
616
  status 'active' | 'cycle_detected'
562
617
  ```
563
618
 
@@ -584,7 +639,8 @@ a poll whose pagination does not advance (a provider cursor that never
584
639
  drains, or a one-shot `updater_body` override). Every later job then
585
640
  fails without calling the provider, and `last_run_error` carries the
586
641
  diagnostic. **Nothing clears it but setting `status` back to
587
- `'active'`.** It is also the cheapest deterministic failure to assert
642
+ `'active'`.** While it is halted, `refresh` answers 409 and queues
643
+ nothing (above). It is also the cheapest deterministic failure to assert
588
644
  on: a non-advancing config trips it within seconds, no provider
589
645
  credential rejection required. Related: a `restapi` updater that
590
646
  declares pagination but whose `path`/`params`/`body` never read
@@ -593,8 +649,9 @@ declares pagination but whose `path`/`params`/`body` never read
593
649
 
594
650
  **If a message's status is not reconciling, do this — in order:**
595
651
 
596
- 1. Call `refresh()`, wait for `last_run_at` to advance past the
597
- baseline you captured, then read `last_run_status` /
652
+ 1. Call `refresh()`. A 409 says why nothing was queued: halted, or
653
+ a poll already in flight (above). Otherwise wait for `last_run_at`
654
+ to advance past the baseline you captured, then read `last_run_status` /
598
655
  `last_run_error` / `status`. They name the failing stage: window
599
656
  render, source fetch, template render, JSON parse — or a halted
600
657
  poll cycle.
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
 
@@ -82,8 +82,8 @@ well-defined key prefixes inside the bucket the credentials point at:
82
82
 
83
83
  ```
84
84
  {datalake_slug}/data-activations/{client_slug}/{batch_id}/{dataset_table}.ndjson
85
- {datalake_slug}/workflows/{wf_slug}/{batch_id}/merged.ndjson
86
- {datalake_slug}/workflows/{wf_slug}/{batch_id}/{decision_key}/merged.ndjson
85
+ {datalake_slug}/workflows/{wf_slug}/merged/{batch_id}.ndjson
86
+ {datalake_slug}/workflows/{wf_slug}/merged/{batch_id}/{decision_key}.ndjson
87
87
  ```
88
88
 
89
89
  The merged NDJSONs are produced by background jobs
package/.agent/errors.md CHANGED
@@ -108,11 +108,13 @@ don't distinguish layers from the JSON.
108
108
  depends on the failure. Ordinary field validation (`"can't be blank"`,
109
109
  `"is invalid"`, a custom `add_error` rule) is **422**. A failure that collides
110
110
  with the current state of the resource is **409 Conflict**: a **uniqueness
111
- collision** (`unique_constraint` / `unsafe_validate_unique`) or a **delete
112
- blocked by dependants** (a foreign-key/assoc constraint firing on delete). So
113
- do not assume every semantic conflict is a tidy 422 — read `_httpStatus`, not
114
- just the body. Read `source.pointer`, fix the field (or resolve the collision),
115
- resubmit.
111
+ collision** (`unique_constraint` / `unsafe_validate_unique`), a **delete
112
+ blocked by dependants** (a foreign-key/assoc constraint firing on delete), or
113
+ an **action status updater refresh the updater's state refuses**: it is
114
+ halted, or a poll for it is already queued or running
115
+ (`action_status_updaters.md` §7). So do not assume every semantic conflict is
116
+ a tidy 422 — read `_httpStatus`, not just the body. Read `source.pointer`, fix
117
+ the field (or resolve the collision), resubmit.
116
118
 
117
119
  ## Concrete examples
118
120
 
@@ -174,6 +176,25 @@ comes back as a **409 Conflict** (`_httpStatus === 409`):
174
176
  }
175
177
  ```
176
178
 
179
+ **State refusal → 409, no field** (`actionStatusUpdaters.refresh()` while a
180
+ poll for that updater is already queued). The refusal is about the resource,
181
+ not a field in your body, so `source.pointer` is empty and `title` is the
182
+ status phrase:
183
+
184
+ ```json
185
+ {
186
+ "errors": [
187
+ { "source": { "pointer": "" },
188
+ "title": "Conflict",
189
+ "detail": "A poll for this updater is already queued, so this refresh was not. Retry once it finishes." }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ A halted updater answers the same way with `Updater is halted: status is
195
+ <status>. Set status to active to resume polling.`, plus ` Last run error:
196
+ <text>` when one is recorded. Nothing was queued in either case.
197
+
177
198
  ## HTTP status codes (under the hood)
178
199
 
179
200
  The SDK abstracts HTTP responses behind typed methods. Consumers mostly see
@@ -189,7 +210,7 @@ platform maps:
189
210
  | 401 | Missing or invalid Bearer / `x-api-key` |
190
211
  | 403 | Authenticated but role lacks permission |
191
212
  | 404 | Resource doesn't exist OR cross-tenant access (404, not 403) |
192
- | 409 | Uniqueness collision, delete blocked by dependants, or URL/body id mismatch — same `errors[]` envelope |
213
+ | 409 | Uniqueness collision, delete blocked by dependants, URL/body id mismatch, or an updater `refresh()` refused (halted, or a poll already queued) — same `errors[]` envelope |
193
214
  | 422 | Validation failed; envelope above is the thrown body |
194
215
  | 5xx | Platform-internal failure; safe to retry with backoff |
195
216
 
@@ -200,6 +221,11 @@ Treat 5xx as infra noise — retry with exponential backoff. Treat 422 (and the
200
221
  resolve the collision, then resubmit. **Never retry a 422 or 409 with the same
201
222
  body** — the failure won't clear on its own.
202
223
 
224
+ One 409 does clear on its own: `actionStatusUpdaters.refresh()` refused because
225
+ a poll for that updater is already queued or running. Retry it once that poll
226
+ finishes (the updater's `last_run_at` advances). The halted refusal does not
227
+ clear on its own: set the updater's `status` back to `'active'` first.
228
+
203
229
  ## Mapping `source.pointer` back to your TypeScript type
204
230
 
205
231
  A `source.pointer` like `/db_reader_user` maps directly to the
@@ -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