@alvera-ai/platform-sdk 0.17.0 → 0.18.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.
Files changed (83) hide show
  1. package/.agent/AGENTS.md +82 -144
  2. package/.agent/account_management.md +2 -2
  3. package/.agent/action_logs.md +4 -4
  4. package/.agent/ai_agents.md +28 -21
  5. package/.agent/ai_sandbox.md +49 -39
  6. package/.agent/connected_apps.md +3 -3
  7. package/.agent/cookbook/_fixtures/README.md +1 -1
  8. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
  9. package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
  10. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
  11. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
  12. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
  13. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
  14. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
  15. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
  16. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
  17. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
  18. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
  19. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
  20. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
  21. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
  22. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
  23. package/.agent/cookbook/organic-marketing.md +2801 -0
  24. package/.agent/cookbook/payments-compliance.md +2180 -0
  25. package/.agent/cookbook/primary-care.md +2175 -0
  26. package/.agent/cookbook/subscription-saas.md +2403 -0
  27. package/.agent/data_activation_clients.md +65 -52
  28. package/.agent/datalakes.md +338 -171
  29. package/.agent/errors.md +3 -3
  30. package/.agent/generic_tables.md +151 -62
  31. package/.agent/interoperability_contracts.md +57 -22
  32. package/.agent/mdm.md +136 -153
  33. package/.agent/messages.md +36 -34
  34. package/.agent/mock-services.md +1 -1
  35. package/.agent/mutations.md +2 -2
  36. package/.agent/templates.md +14 -13
  37. package/.agent/tools.md +63 -21
  38. package/.agent/type_naming.md +13 -13
  39. package/.agent/workflows.md +99 -53
  40. package/README.md +2 -2
  41. package/dist/bin/platform-sdk.mjs +33 -47
  42. package/dist/bin/platform-sdk.mjs.map +1 -1
  43. package/dist/index.d.mts +565 -379
  44. package/dist/index.d.mts.map +1 -1
  45. package/dist/index.mjs +494 -59
  46. package/dist/index.mjs.map +1 -1
  47. package/package.json +4 -3
  48. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
  49. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
  50. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
  51. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
  52. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
  53. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
  54. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
  55. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
  56. package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
  57. package/.agent/cookbook/_setup/foundation.md +0 -359
  58. package/.agent/cookbook/_setup/healthcare.md +0 -361
  59. package/.agent/cookbook/_setup/payments.md +0 -365
  60. package/.agent/cookbook/_setup/subscription.md +0 -364
  61. package/.agent/cookbook/action-status-updaters.md +0 -278
  62. package/.agent/cookbook/ai-agent-invoke.md +0 -279
  63. package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
  64. package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
  65. package/.agent/cookbook/bulk-ingest.md +0 -302
  66. package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
  67. package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
  68. package/.agent/cookbook/generic-tables.md +0 -244
  69. package/.agent/cookbook/invite-team.md +0 -200
  70. package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
  71. package/.agent/cookbook/marketing-campaign-send.md +0 -1044
  72. package/.agent/cookbook/paginated-restapi-poller.md +0 -383
  73. package/.agent/cookbook/rest-fetch.md +0 -273
  74. package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
  75. package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
  76. package/.agent/cookbook/system-templates.md +0 -165
  77. package/.agent/cookbook/talk-to-data.md +0 -178
  78. package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
  79. package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
  80. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  81. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  82. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  83. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
@@ -0,0 +1,80 @@
1
+ {% comment %}
2
+ Foundation Airflow Lead Form → Foundation LegalEntity attrs.
3
+
4
+ Maps a single lead-form row into a `LegalEntity` upsert payload. Used by
5
+ a contract whose `resource_type` is `"legal_entity"` and whose
6
+ `mdm_input_config` is `{ type: "null" }` — the row IS the subject, so
7
+ there is nothing to resolve and the legal entity is written directly.
8
+ Identifier dedupe in `Platform.Datasets.LegalEntities.upsert_legal_entity/5`
9
+ collapses repeat submissions sharing the same digital identifier (the
10
+ verified email) onto one legal-entity row.
11
+
12
+ ONE WRITER PER SUBJECT. That dedupe is a find-or-create against a unique
13
+ index on `(uri, id_type, id_number)` that deliberately omits the legal
14
+ entity, so two contracts resolving the same subject at the same instant
15
+ do not each succeed — the loser is refused. This template is safe on its
16
+ own client; pair it with a generic-table contract and they must be
17
+ sequenced, never bound to one client together.
18
+
19
+ ## Branches
20
+ * `legal_entity_type: "individual"` when `company` is empty
21
+ * `legal_entity_type: "business"` when `company` is non-empty
22
+ (the company name becomes `business_name` and the submitter's name
23
+ is dropped — businesses don't carry first/last on the LE row)
24
+
25
+ ## Identifications
26
+ * One identification per row: `id_type: "digital_identifier"`,
27
+ `uri: <p.source_uri>` — the system-of-record URI, which the Data
28
+ Activation Client injects from the configured `data_source.uri` —
29
+ and `id_number: <email>`. There is one schema and it holds the real
30
+ value; masking is infrastructure (the tokenized and redacted lakes),
31
+ not a second set of attrs to write into.
32
+
33
+ * NOTE the key names. A LegalEntity identification is
34
+ `id_type` / `id_number` / `uri`, which is what this template writes.
35
+ An MDMInput identifier — what a contract's `mdm_input_config`
36
+ renders — is `uri` / `type` / `value`. Unknown keys are dropped
37
+ rather than refused, so the wrong shape resolves nothing and fails
38
+ as a bare `mdm_dispatcher_error` naming no field.
39
+ {% endcomment %}
40
+ {% assign p = msg %}
41
+ {% assign le_type = "individual" %}
42
+ {% if p.company and p.company != "" %}{% assign le_type = "business" %}{% endif %}
43
+ {% assign name_parts = p.name | default: "" | split: " " %}
44
+ {% assign first_name = name_parts[0] | default: "" %}
45
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
46
+ {% assign last_name = last_name_parts | join: " " %}
47
+ {
48
+ "legal_entity_type": "{{ le_type }}",
49
+ "role": "direct",
50
+ {% if le_type == "business" %}"business_name": "{{ p.company | json_escape }}",{% endif %}
51
+ {% if le_type == "individual" and first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
52
+ {% if le_type == "individual" and last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
53
+ {% comment %}
54
+ Optional date_of_birth + phone_numbers, flat like every other
55
+ field. There is ONE LegalEntity schema now and it declares both —
56
+ `field(:date_of_birth, MaskedDate)` and
57
+ `has_many(:phone_numbers, LegalEntityPhoneNumber)`. GH-859 removed
58
+ the public/regulated pair of schemas, and with it the nested
59
+ `regulated_legal_entity` block this template used to carry:
60
+ masking is infrastructure — the tokenized and redacted lakes —
61
+ not a second schema to write into. A column holds its real value.
62
+
63
+ `date_of_birth` is rendered as ISO 8601 YYYY-MM-DD; if the CSV
64
+ column is absent, the {% if %} guard renders nothing and
65
+ :date_of_birth stays nil.
66
+
67
+ `phone_numbers` renders as a single-element list — matches the
68
+ one-phone-per-lead shape of inbound lead-capture forms. Absent
69
+ column leaves the has_many empty.
70
+ {% endcomment %}
71
+ {% if p.date_of_birth and p.date_of_birth != "" %}"date_of_birth": "{{ p.date_of_birth | json_escape }}",{% endif %}
72
+ {% if p.phone and p.phone != "" %}"phone_numbers": [{ "phone_number": "{{ p.phone | json_escape }}" }],{% endif %}
73
+ "identifications": [
74
+ {
75
+ "id_type": "digital_identifier",
76
+ "uri": "{{ p.source_uri | json_escape }}",
77
+ "id_number": "{{ p.email | json_escape }}"
78
+ }
79
+ ]
80
+ }
@@ -13,7 +13,7 @@
13
13
  whose `find_legal_entity_by_identifications/2` dedupe path collapses
14
14
  repeat resolves sharing the same `(id_type, id_number, uri)` triple
15
15
  onto the SAME LegalEntity row written by Contract A.
16
- 3. Pins the resulting GT event's `mdm_subject_id == legal_entity.id`.
16
+ 3. Pins the resulting GT event's `legal_entity_id == legal_entity.id`.
17
17
 
18
18
  The verifying platform URI flows from `data_source.uri` injected by DAC
19
19
  as `source_uri` — same upstream channel as Contract A's LE template, so
@@ -42,6 +42,7 @@
42
42
  "identifiers": [
43
43
  {
44
44
  "system": "{{ p.source_uri | json_escape }}",
45
+ "type": "digital_identifier",
45
46
  "value": "{{ p.email | json_escape }}"
46
47
  }
47
48
  ]
@@ -0,0 +1,57 @@
1
+ {% comment %}
2
+ Atomic FI ComplianceScreenings → `compliance_screenings` generic_table row.
3
+
4
+ Contract B (`resource_type: "generic_table"`, `generic_table_id` pinned
5
+ to the compliance-screenings table).
6
+
7
+ Source: GET /api/compliance-screenings on Atomic FI (paginated; the
8
+ fetch pipeline extracts the `data` array via the response_extractor and
9
+ streams one row per `msg`).
10
+
11
+ GH-859 removed the `compliance_screening` resource, and with it the
12
+ changeset that used to `put_change` the FK columns and call
13
+ `cast_tokenized_data` on the way past. Both jobs move to declarations:
14
+ `legal_entity_id` is stamped by the platform from Contract B's MDM
15
+ resolve (never a column, never written here), and the narrative + name
16
+ fields are `privacy_requirement: "tokenize"` / `"redact"` on the table,
17
+ so the raw value goes in and the derived lakes do the masking.
18
+
19
+ Two things survive unchanged, because they were always this template's
20
+ own work:
21
+
22
+ * The analytic numerics (`screening_score`, `aml_risk_score`,
23
+ `aml_velocity_count`) render UNQUOTED so they land as real numbers.
24
+ The workflow's gray-zone filter compares `screening_score` with
25
+ `>=` and `<`; a quoted "0.88" would compare as a string and the
26
+ band would silently stop meaning anything.
27
+ * `sanctions_matches` is explicitly looped. Liquid's default
28
+ rendering of a list produces a stringified Elixir list, not JSON —
29
+ the `{% for %}` emits one object per element so the jsonb column
30
+ receives a real array.
31
+ {% endcomment %}
32
+ {% assign p = msg %}
33
+ {
34
+ "compliance_screening_number": "{{ p.compliance_screening_number | default: p.id | json_escape }}",
35
+ "account_holder_ref": "{{ p.account_holder_id | default: "" | json_escape }}",
36
+ "scope": "{{ p.scope | default: "account_holder" | json_escape }}",
37
+ "screening_type": "{{ p.screening_type | default: "sanctions" | json_escape }}",
38
+ "screening_status": "{{ p.screening_status | default: "pending" | json_escape }}",
39
+ "screened_entity_type": "{{ p.screened_entity_type | default: "individual" | json_escape }}",
40
+ "screening_score": {{ p.screening_score | default: 0 }},
41
+ "match_count": {{ p.match_count | default: 0 }},
42
+ "sanctions_screening_status": "{{ p.sanctions_screening_status | default: "" | json_escape }}",
43
+ "screened_entity_name": "{{ p.screened_entity_name | default: "" | json_escape }}",
44
+ "review_notes": "{{ p.review_notes | default: "" | json_escape }}",
45
+ "pep_list_name": "{{ p.pep_list_name | default: "" | json_escape }}",
46
+ "aml_risk_score": {{ p.aml_risk_score | default: 0 }},
47
+ "aml_velocity_count": {{ p.aml_velocity_count | default: 0 }},
48
+ "aml_high_risk_country": "{{ p.aml_high_risk_country | default: "" | json_escape }}",
49
+ "sanctions_matches": [{% for m in p.sanctions_matches %}{
50
+ "matched_name": "{{ m.matched_name | json_escape }}",
51
+ "matched_entity_type": "{{ m.matched_entity_type | default: "" | json_escape }}",
52
+ "match_score": {{ m.match_score | default: 0 }},
53
+ "source_list": "{{ m.source_list | default: "" | json_escape }}",
54
+ "source_id": "{{ m.source_id | default: "" | json_escape }}",
55
+ "false_positive_qualifier": "{{ m.false_positive_qualifier | default: "" | json_escape }}"
56
+ }{% unless forloop.last %},{% endunless %}{% endfor %}]
57
+ }
@@ -0,0 +1,30 @@
1
+ {% comment %}
2
+ Atomic FI ComplianceScreenings → LegalEntity attrs.
3
+
4
+ Contract A (`resource_type: "legal_entity"`, `mdm_input_config`
5
+ `{ type: "null" }`). The account HOLDER a screening was run against is
6
+ the MDM subject, so this template writes that legal entity directly,
7
+ keyed on Atomic FI's `account_holder_id`.
8
+
9
+ Contract B's MDM input emits the SAME `(uri, account_holder_id)` pair,
10
+ which is what makes both contracts converge on one legal entity per
11
+ account holder rather than two.
12
+ {% endcomment %}
13
+ {% assign p = msg %}
14
+ {% assign name_parts = p.screened_entity_name | default: "" | split: " " %}
15
+ {% assign first_name = name_parts[0] | default: "" %}
16
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
17
+ {% assign last_name = last_name_parts | join: " " %}
18
+ {
19
+ "legal_entity_type": "individual",
20
+ "role": "direct",
21
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
22
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.account_holder_id | json_escape }}",{% endif %}
23
+ "identifications": [
24
+ {
25
+ "id_type": "digital_identifier",
26
+ "uri": "{{ p.source_uri | default: "api.atomic.fi" | json_escape }}",
27
+ "id_number": "{{ p.account_holder_id | json_escape }}"
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,44 @@
1
+ {% comment %}
2
+ Atomic FI ComplianceScreenings → MDMInput JSON.
3
+
4
+ Used by Contract B's `mdm_input_config` (`resource_type:
5
+ "generic_table"`). A screening references an account-holder id
6
+ (`p.account_holder_id`); resolving it routes through the LegalEntity
7
+ upsert, whose identification dedupe collapses repeat resolves sharing
8
+ the same `(id_type, id_number, uri)` triple onto the SAME LegalEntity
9
+ row Contract A wrote. The resolved subject's id is then stamped onto
10
+ the screening row as `legal_entity_id`.
11
+
12
+ GH-859 removed the `owner_type` / `account_holder_*` vocabulary along
13
+ with the AccountHolder resource: there is no LE+AH parent pair to
14
+ find-or-create, just the legal entity. The identifier `uri` flows from
15
+ `data_source.uri` — injected by the DAC as `source_uri`, the same channel
16
+ Contract A reads — so both contracts converge on one subject.
17
+
18
+ NOTE the key names. An MDMInput identifier is `uri` / `type` / `value`. A
19
+ LegalEntity identification, which is what Contract A writes, is `id_type` /
20
+ `id_number` / `uri`. They are different shapes for the same idea, and
21
+ unknown keys are dropped rather than refused — so the wrong one resolves
22
+ nothing and fails as a bare `mdm_dispatcher_error` naming no field.
23
+
24
+ The screened entity's name is carried onto the subject when the export
25
+ supplies one, which is what lets a reviewer see who was screened
26
+ without joining back to the screening row.
27
+ {% endcomment %}
28
+ {% assign p = msg %}
29
+ {% assign name_parts = p.screened_entity_name | default: "" | split: " " %}
30
+ {% assign first_name = name_parts[0] | default: "" %}
31
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
32
+ {% assign last_name = last_name_parts | join: " " %}
33
+ {
34
+ "legal_entity_type": "individual",
35
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
36
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.account_holder_id | json_escape }}",{% endif %}
37
+ "identifiers": [
38
+ {
39
+ "uri": "{{ p.source_uri | default: "api.atomic.fi" | json_escape }}",
40
+ "type": "digital_identifier",
41
+ "value": "{{ p.account_holder_id | json_escape }}"
42
+ }
43
+ ]
44
+ }
@@ -0,0 +1,57 @@
1
+ {% comment %}
2
+ Atomic FI PaymentAccount → `payment_accounts` generic_table row.
3
+
4
+ Contract B (`resource_type: "generic_table"`, `generic_table_id` pinned
5
+ to the payment-accounts table). One JSON field per GT column.
6
+
7
+ Source: GET /api/payment-accounts on Atomic FI (paginated; the fetch
8
+ pipeline extracts the `data` array via the response_extractor and
9
+ streams one row per `msg`).
10
+
11
+ GH-859 removed the `payment_account` resource along with every other
12
+ per-domain dataset, so the changeset that used to `put_change` the FK
13
+ columns is gone with it. `legal_entity_id` arrives the way it does for
14
+ every generic table now: Contract B's `mdm_input_config` resolves the
15
+ account holder and the platform stamps the subject onto the row. It is
16
+ NEVER declared as a column and never written here.
17
+
18
+ What survives the conversion is the part that was always this
19
+ template's own work:
20
+
21
+ * PCI-DSS-sensitive fields (`account_number`, `iban`, `card_pan`,
22
+ `wallet_address`) and the free-text narratives are declared
23
+ `privacy_requirement: "tokenize"` / `"redact"` on the table, so
24
+ the raw value goes in here and the derived lakes do the masking.
25
+ Masking is infrastructure, not a second schema to write into.
26
+ * `destination_country` is derived from `swift_bic` (chars 5-6 per
27
+ ISO 9362) or `iban` (chars 1-2 per ISO 13616), BIC preferred when
28
+ both are present, so a downstream agent reads the field instead of
29
+ re-parsing the identifier.
30
+ * `enabled_regimes` is joined into one delimited string rather than
31
+ rendered as a JSON array — a generic-table column is scalar, and a
32
+ `{{ x }}` on a list would emit a stringified Elixir list anyway.
33
+ {% endcomment %}
34
+ {% assign p = msg %}
35
+ {% if p.swift_bic and p.swift_bic != "" %}
36
+ {% assign derived_country = p.swift_bic | slice: 4, 2 | upcase %}
37
+ {% elsif p.iban and p.iban != "" %}
38
+ {% assign derived_country = p.iban | slice: 0, 2 | upcase %}
39
+ {% else %}
40
+ {% assign derived_country = "" %}
41
+ {% endif %}
42
+ {
43
+ "payment_account_external_id": "{{ p.payment_account_external_id | default: "" | json_escape }}",
44
+ "payment_account_number": "{{ p.payment_account_number | default: "" | json_escape }}",
45
+ "account_holder_ref": "{{ p.account_holder_id | default: "" | json_escape }}",
46
+ "status": "{{ p.status | default: "pending" | json_escape }}",
47
+ "account_type": "{{ p.account_type | default: "bank_account" | json_escape }}",
48
+ "currency": "{{ p.currency | default: "" | json_escape }}",
49
+ "routing_number": "{{ p.routing_number | default: "" | json_escape }}",
50
+ "swift_bic": "{{ p.swift_bic | default: "" | json_escape }}",
51
+ "bank_name": "{{ p.bank_name | default: "" | json_escape }}",
52
+ "account_number": "{{ p.account_number | default: "" | json_escape }}",
53
+ "iban": "{{ p.iban | default: "" | json_escape }}",
54
+ "destination_country": "{{ derived_country | json_escape }}",
55
+ "purpose_of_payment_detail": "{{ p.purpose_of_payment_detail | default: "" | json_escape }}",
56
+ "enabled_regimes": "{% for r in p.enabled_regimes %}{{ r | json_escape }}{% unless forloop.last %},{% endunless %}{% endfor %}"
57
+ }
@@ -0,0 +1,36 @@
1
+ {% comment %}
2
+ Atomic FI PaymentAccount → LegalEntity attrs.
3
+
4
+ Contract A (`resource_type: "legal_entity"`, `mdm_input_config`
5
+ `{ type: "null" }`). The account HOLDER is the MDM subject a payment
6
+ account belongs to, so this template writes that legal entity
7
+ directly, keyed on Atomic FI's `account_holder_id`.
8
+
9
+ GH-859 removed the per-domain identity chain: there is no
10
+ `account_holder` resource sitting between the payment account and its
11
+ legal entity, and no `owner_type` to choose. One LegalEntity schema
12
+ carries every subject, and a payment account is a generic table row
13
+ that points at one.
14
+
15
+ Contract B's MDM input emits the SAME `(uri, account_holder_id)` pair,
16
+ which is what makes both contracts converge on one legal entity per
17
+ account holder rather than two.
18
+ {% endcomment %}
19
+ {% assign p = msg %}
20
+ {% assign name_parts = p.account_holder_name | default: "" | split: " " %}
21
+ {% assign first_name = name_parts[0] | default: "" %}
22
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
23
+ {% assign last_name = last_name_parts | join: " " %}
24
+ {
25
+ "legal_entity_type": "individual",
26
+ "role": "direct",
27
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
28
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.account_holder_id | json_escape }}",{% endif %}
29
+ "identifications": [
30
+ {
31
+ "id_type": "digital_identifier",
32
+ "uri": "{{ p.source_uri | default: "api.atomic.fi" | json_escape }}",
33
+ "id_number": "{{ p.account_holder_id | json_escape }}"
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,41 @@
1
+ {% comment %}
2
+ Atomic FI PaymentAccount → MDMInput JSON.
3
+
4
+ Used by Contract B's `mdm_input_config` (`resource_type:
5
+ "generic_table"`). A payment-account row references an account-holder
6
+ id (`p.account_holder_id`); resolving it routes through the LegalEntity
7
+ upsert, whose identification dedupe collapses repeat resolves sharing
8
+ the same `(id_type, id_number, uri)` triple onto the SAME LegalEntity
9
+ row Contract A wrote. The resolved subject's id is then stamped onto
10
+ the payment-account row as `legal_entity_id`.
11
+
12
+ GH-859 removed the `owner_type` / `account_holder_*` vocabulary along
13
+ with the AccountHolder resource itself: there is no LegalEntity +
14
+ AccountHolder pair to find-or-create, just the legal entity. The
15
+ identifier `uri` flows from `data_source.uri` — injected by the DAC as
16
+ `source_uri`, the same channel Contract A reads — so both contracts emit
17
+ an identical pair and converge on one subject.
18
+
19
+ NOTE the key names. An MDMInput identifier is `uri` / `type` / `value`. A
20
+ LegalEntity identification, which is what Contract A writes, is `id_type` /
21
+ `id_number` / `uri`. They are different shapes for the same idea, and
22
+ unknown keys are dropped rather than refused — so the wrong one resolves
23
+ nothing and fails as a bare `mdm_dispatcher_error` naming no field.
24
+ {% endcomment %}
25
+ {% assign p = msg %}
26
+ {% assign name_parts = p.account_holder_name | default: "" | split: " " %}
27
+ {% assign first_name = name_parts[0] | default: "" %}
28
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
29
+ {% assign last_name = last_name_parts | join: " " %}
30
+ {
31
+ "legal_entity_type": "individual",
32
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
33
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.account_holder_id | json_escape }}",{% endif %}
34
+ "identifiers": [
35
+ {
36
+ "uri": "{{ p.source_uri | default: "api.atomic.fi" | json_escape }}",
37
+ "type": "digital_identifier",
38
+ "value": "{{ p.account_holder_id | json_escape }}"
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,70 @@
1
+ {% comment %}
2
+ CAHPS Appointments → `appointments` generic_table row.
3
+
4
+ Contract B (`resource_type: "generic_table"`, `generic_table_id` pinned
5
+ to the appointments table). One JSON field per GT column.
6
+
7
+ GH-859 removed the `appointment` dataset along with every other
8
+ per-domain resource, so the FHIR R4 Appointment shape this template
9
+ used to emit — `participants[]` with `actor_type`, a nested identifier
10
+ array — has nowhere to go. An appointment is a table you declare, and
11
+ its columns are flat.
12
+
13
+ The load-bearing part survives unchanged: the vendor's
14
+ `appt_slot_status` string is mapped onto the canonical `status` the
15
+ workflow filter reads. That mapping is the contract's job, not the
16
+ workflow's — a filter that had to know about `"3 - checked out"` would
17
+ be coupled to one vendor's export format.
18
+
19
+ `legal_entity_id` is NEVER declared or written here. Contract B's
20
+ `mdm_input_config` resolves the patient and the platform stamps the
21
+ subject's id onto the row; the name is reserved, so declaring a column
22
+ of it 422s this create rather than shadowing the stamp.
23
+
24
+ GT columns:
25
+ * `appointment_id` — the vendor's id (unique, so a re-ingest updates)
26
+ * `status` — canonical: fulfilled / cancelled / arrived /
27
+ booked / pending
28
+ * `appt_start` — vendor `3/12/2026` + `2:30 PM` composed into one
29
+ ISO 8601 local timestamp
30
+ * `minutes_duration`— integer, so downstream analytics can average it
31
+ * `description`, `department`, `provider_id`, `provider_name`
32
+ * `patient_name`, `patient_mobile` — tokenized PII, and the SMS
33
+ destination the workflow's action templates read
34
+ * `patient_ref` — the vendor's patient id, carried so a row can be
35
+ traced back to the export without joining the subject
36
+ * `care_gaps` — jsonb ARRAY of open care gaps. Declared
37
+ `jsonb` + `is_array: true` + `redact`, which makes it the one
38
+ column here that exercises all three of those at once.
39
+
40
+ NOTE the explicit loop on `care_gaps`. Liquid's `to_json` on a nested
41
+ array renders Ruby-ish inspect output rather than JSON, so each entry is
42
+ written field by field. An absent `care_gaps` renders `[]` — a real
43
+ empty array, not null — which is what the column expects.
44
+ {% endcomment %}
45
+ {% assign a = msg %}
46
+ {% assign raw_status = a.appt_slot_status | default: "" | downcase | strip %}
47
+ {% assign status = "pending" %}
48
+ {% if raw_status == "2 - checked in" %}{% assign status = "arrived" %}
49
+ {% elsif raw_status == "3 - checked out" or raw_status == "4 - charge entered" %}{% assign status = "fulfilled" %}
50
+ {% elsif raw_status == "f - filled" %}{% assign status = "booked" %}
51
+ {% elsif raw_status == "x - cancelled" %}{% assign status = "cancelled" %}
52
+ {% endif %}
53
+ {
54
+ "appointment_id": "{{ a.appointment_id | default: "" | json_escape }}",
55
+ "status": "{{ status }}",
56
+ {% if a.appt_date and a.appt_date != "" %}"appt_start": "{{ a.appt_date | parse_date: "{M}/{D}/{YYYY}" }}T{% if a.appt_start_time and a.appt_start_time != "" %}{{ a.appt_start_time | convert_time: "{h12}:{m} {am}" }}{% else %}00:00:00{% endif %}",{% endif %}
57
+ {% if a.appt_slot_duration and a.appt_slot_duration != "" %}"minutes_duration": {{ a.appt_slot_duration }},{% endif %}
58
+ "description": "{{ a.appt_type | default: "" | json_escape }}",
59
+ "department": "{{ a.svc_department | default: "" | json_escape }}",
60
+ "provider_id": "{{ a.rndrng_provider_id | default: "" | json_escape }}",
61
+ "provider_name": "{{ a.rndrng_provider | default: "" | json_escape }}",
62
+ "patient_name": "{{ a.patient_name | default: "" | json_escape }}",
63
+ "patient_mobile": "{{ a.patient_mobile_no | default: "" | json_escape }}",
64
+ "patient_ref": "{{ a.patient_id | default: "" | json_escape }}",
65
+ "care_gaps": [{% for g in a.care_gaps %}{
66
+ "code": "{{ g.code | json_escape }}",
67
+ "description": "{{ g.description | default: "" | json_escape }}",
68
+ "due_date": "{{ g.due_date | default: "" | json_escape }}"
69
+ }{% unless forloop.last %},{% endunless %}{% endfor %}]
70
+ }
@@ -0,0 +1,52 @@
1
+ {% comment %}
2
+ CAHPS Appointments → LegalEntity attrs.
3
+
4
+ Contract A (`resource_type: "legal_entity"`, `mdm_input_config`
5
+ `{ type: "null" }`) — the patient on the CAHPS row IS the MDM subject,
6
+ so this template writes the LegalEntity directly.
7
+
8
+ GH-859 removed the per-domain identity schemas: there is no `patient`
9
+ resource and no FHIR R4 Patient shape to fill in. One LegalEntity
10
+ schema carries every subject the platform knows, and it is flat —
11
+ `first_name` / `last_name` / `date_of_birth` / `phone_numbers` /
12
+ `identifications`, not `name[].given` / `birth_date` / `telecom[]`.
13
+
14
+ ## Identifications
15
+ * `patient-id` — always present, the practice's own patient id
16
+ * `enterprise-id` — only when the CAHPS export carries one (a
17
+ multi-practice enterprise MRN)
18
+
19
+ Both use `id_type: "digital_identifier"` with the verifying platform's
20
+ URI, which the DAC injects from the configured `data_source.uri` as
21
+ `source_uri`. Contract B's MDM input emits the SAME `(uri, patient_id)`
22
+ pair, which is what makes both contracts converge on one LegalEntity
23
+ per patient rather than two.
24
+
25
+ Parent-row filtering is handled by the contract's `filter_template`,
26
+ not here.
27
+ {% endcomment %}
28
+ {% assign p = msg %}
29
+ {% assign name_parts = p.patient_name | default: "" | split: " " %}
30
+ {% assign first_name = name_parts[0] | default: "" %}
31
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
32
+ {% assign last_name = last_name_parts | join: " " %}
33
+ {
34
+ "legal_entity_type": "individual",
35
+ "role": "direct",
36
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
37
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.patient_name | default: "" | strip | json_escape }}",{% endif %}
38
+ {% if p.patientdob and p.patientdob != "" %}"date_of_birth": "{{ p.patientdob | parse_date: "{M}/{D}/{YYYY}" }}",{% endif %}
39
+ {% if p.patient_mobile_no and p.patient_mobile_no != "" %}"phone_numbers": [{ "phone_number": "{{ p.patient_mobile_no | json_escape }}" }],{% endif %}
40
+ "identifications": [
41
+ {
42
+ "id_type": "digital_identifier",
43
+ "uri": "{{ p.source_uri | json_escape }}",
44
+ "id_number": "{{ p.patient_id | default: "" | json_escape }}"
45
+ }{% if p.enterprise_id and p.enterprise_id != "" %},
46
+ {
47
+ "id_type": "digital_identifier",
48
+ "uri": "{{ p.source_uri | json_escape }}:enterprise",
49
+ "id_number": "{{ p.enterprise_id | json_escape }}"
50
+ }{% endif %}
51
+ ]
52
+ }
@@ -0,0 +1,42 @@
1
+ {% comment %}
2
+ CAHPS Appointments → MDMInput JSON.
3
+
4
+ Used by Contract B's `mdm_input_config` (`resource_type:
5
+ "generic_table"`). The DAC pipeline renders this against the CAHPS row,
6
+ validates the result as an MDM input, and resolves it — routing the
7
+ attrs through the LegalEntity upsert, whose identification dedupe
8
+ collapses repeat resolves sharing the same `(id_type, id_number, uri)`
9
+ triple onto the SAME LegalEntity row Contract A wrote. The resolved
10
+ subject's id is then stamped onto the appointment row as
11
+ `legal_entity_id`.
12
+
13
+ The `system` value flows from `data_source.uri`, injected by the DAC as
14
+ `source_uri` — the same channel Contract A's template reads — so both
15
+ contracts emit an identical `(uri, patient_id)` pair and converge on
16
+ one LegalEntity per patient.
17
+
18
+ GH-859 removed the per-domain identity vocabularies. There is ONE field
19
+ set: `first_name` / `last_name` / `date_of_birth` / `phone` / `email` /
20
+ `identifiers`. `given_name`, `family_name` and `birth_date` are gone —
21
+ not renamed variants to pick between, and a template still sending them
22
+ resolves nothing rather than failing loudly.
23
+ {% endcomment %}
24
+ {% assign p = msg %}
25
+ {% assign name_parts = p.patient_name | default: "" | split: " " %}
26
+ {% assign first_name = name_parts[0] | default: "" %}
27
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
28
+ {% assign last_name = last_name_parts | join: " " %}
29
+ {
30
+ "legal_entity_type": "individual",
31
+ {% if first_name != "" and last_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
32
+ {% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% else %}"last_name": "{{ p.patient_name | default: "" | strip | json_escape }}",{% endif %}
33
+ {% if p.patientdob and p.patientdob != "" %}"date_of_birth": "{{ p.patientdob | parse_date: "{M}/{D}/{YYYY}" }}",{% endif %}
34
+ {% if p.patient_mobile_no and p.patient_mobile_no != "" %}"phone": "{{ p.patient_mobile_no | json_escape }}",{% endif %}
35
+ "identifiers": [
36
+ {
37
+ "system": "{{ p.source_uri | json_escape }}",
38
+ "value": "{{ p.patient_id | default: "" | json_escape }}",
39
+ "type": "digital_identifier"
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,38 @@
1
+ {% comment %}
2
+ Stripe Customers → `customers` generic_table row.
3
+
4
+ Contract B (`resource_type: "generic_table"`, `generic_table_id`
5
+ pinned to the Stripe Customers table). One JSON field per GT column.
6
+
7
+ Contract B also carries an `mdm_input_config` (separate template), so
8
+ the platform resolves each row's subject and stamps `legal_entity_id`
9
+ onto the row. `legal_entity_id` is NEVER declared as a column and
10
+ never written here — the stamp is the platform's, and the name is
11
+ reserved, so declaring it 422s this create rather than shadowing it.
12
+
13
+ GT columns:
14
+ * `customer_number` — Stripe's customer id (the unique column, so a
15
+ re-fetch updates the same row instead of inserting a duplicate)
16
+ * `customer_type`, `status`, `currency` — commercial state, not PII
17
+ * `name`, `email`, `phone`, `tax_id` — tokenized PII. `tax_id` is
18
+ the KYC gate the dunning workflow filters on, so a template that
19
+ drops it makes every row look KYC-incomplete and the workflow
20
+ filters all of them — green ingest, silently empty outcome.
21
+ * `delinquent` — boolean; Stripe sends the string "false"/"true",
22
+ which renders unquoted here and lands as a real boolean
23
+ * `address_city`, `address_country` — coarse location, not PII
24
+ {% endcomment %}
25
+ {% assign p = msg %}
26
+ {
27
+ "customer_number": "{{ p.customer_number | json_escape }}",
28
+ "customer_type": "{{ p.customer_type | default: "individual" | json_escape }}",
29
+ "status": "{{ p.status | default: "prospect" | json_escape }}",
30
+ "name": "{{ p.name | default: "" | json_escape }}",
31
+ "email": "{{ p.email | default: "" | json_escape }}",
32
+ "phone": "{{ p.phone | default: "" | json_escape }}",
33
+ "tax_id": "{{ p.tax_id | default: "" | json_escape }}",
34
+ "currency": "{{ p.currency | default: "" | json_escape }}",
35
+ "delinquent": {{ p.delinquent | default: "false" }},
36
+ "address_city": "{{ p.address_city | default: "" | json_escape }}",
37
+ "address_country": "{{ p.address_country | default: "" | json_escape }}"
38
+ }
@@ -0,0 +1,48 @@
1
+ {% comment %}
2
+ Stripe Customers → LegalEntity attrs.
3
+
4
+ Contract A (`resource_type: "legal_entity"`, `mdm_input_config`
5
+ `{ type: "null" }`) — this template writes the LegalEntity directly,
6
+ because the fetched Stripe customer IS the MDM subject.
7
+
8
+ ## Branches
9
+ * `legal_entity_type: "individual"` when Stripe's `customer_type`
10
+ is `individual` — the display name splits into first/last
11
+ * `legal_entity_type: "business"` otherwise (`enterprise`, …) — the
12
+ display name becomes `business_name` and no first/last is written,
13
+ because a business does not carry them
14
+
15
+ ## Identifications
16
+ * One per row: `id_type: "digital_identifier"`, `uri: <p.source_uri>`
17
+ (the DAC injects the configured `data_source.uri`),
18
+ `id_number: <customer_number>`. Contract B's MDM input emits the
19
+ SAME `(uri, value)` pair, which is what makes both contracts
20
+ converge on one LegalEntity row per customer instead of two.
21
+
22
+ There is ONE LegalEntity schema — GH-859 removed the public/regulated
23
+ pair, and with it the nested `regulated_customer` block this template
24
+ used to carry. Masking is infrastructure (the tokenized and redacted
25
+ lakes), not a second schema to write into.
26
+ {% endcomment %}
27
+ {% assign p = msg %}
28
+ {% assign le_type = "business" %}
29
+ {% if p.customer_type == "individual" %}{% assign le_type = "individual" %}{% endif %}
30
+ {% assign name_parts = p.name | default: "" | split: " " %}
31
+ {% assign first_name = name_parts[0] | default: "" %}
32
+ {% assign last_name_parts = name_parts | slice: 1, 9 %}
33
+ {% assign last_name = last_name_parts | join: " " %}
34
+ {
35
+ "legal_entity_type": "{{ le_type }}",
36
+ "role": "direct",
37
+ {% if le_type == "business" %}"business_name": "{{ p.name | json_escape }}",{% endif %}
38
+ {% if le_type == "individual" and first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
39
+ {% if le_type == "individual" and last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
40
+ {% if p.phone and p.phone != "" %}"phone_numbers": [{ "phone_number": "{{ p.phone | json_escape }}" }],{% endif %}
41
+ "identifications": [
42
+ {
43
+ "id_type": "digital_identifier",
44
+ "uri": "{{ p.source_uri | json_escape }}",
45
+ "id_number": "{{ p.customer_number | json_escape }}"
46
+ }
47
+ ]
48
+ }