@alvera-ai/platform-sdk 0.18.2-next.g6192a5c → 0.19.1

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.
@@ -227,8 +227,8 @@ const LAKE_NAME = `Cookbook Organic Marketing Datalake ${runSuffix}`
227
227
  const S3 = {
228
228
  cloud_storage_type: 'aws' as const,
229
229
  region: 'us-east-1',
230
- access_key_id: 'test',
231
- secret_access_key: 'test',
230
+ access_key_id: 'localkey',
231
+ secret_access_key: 'localsecret',
232
232
  endpoint: 'http://localhost:4566',
233
233
  }
234
234
 
@@ -449,8 +449,8 @@ const smsTool = await ctx.ensure(
449
449
  region: 'us-east-1',
450
450
  phone_number: '+15551234567',
451
451
  endpoint_url: 'http://localhost:4566',
452
- access_key_id: 'test',
453
- secret_access_key: 'test',
452
+ access_key_id: 'localkey',
453
+ secret_access_key: 'localsecret',
454
454
  },
455
455
  })
456
456
  return data
@@ -1989,8 +1989,8 @@ const campaignSms = await ctx.ensure(
1989
1989
  region: 'us-east-1',
1990
1990
  phone_number: '+15550001111',
1991
1991
  endpoint_url: 'http://localhost:4566',
1992
- access_key_id: 'test',
1993
- secret_access_key: 'test',
1992
+ access_key_id: 'localkey',
1993
+ secret_access_key: 'localsecret',
1994
1994
  },
1995
1995
  })
1996
1996
  return data
@@ -2469,8 +2469,8 @@ const cwTool = await ctx.ensure(
2469
2469
  auth_method: 'access_key',
2470
2470
  region: 'us-east-1',
2471
2471
  endpoint_url: 'http://localhost:4566',
2472
- access_key_id: 'test',
2473
- secret_access_key: 'test',
2472
+ access_key_id: 'localkey',
2473
+ secret_access_key: 'localsecret',
2474
2474
  },
2475
2475
  })
2476
2476
  return data
@@ -2785,6 +2785,202 @@ if (!csvExport.data.includes('end_customer_id') || !csvExport.data.includes(ctx.
2785
2785
  }
2786
2786
  ```
2787
2787
 
2788
+ ## 047 — one person, two identifiers, and what a second ingest leaves alone
2789
+
2790
+ Every recipient so far carries **one** identifier: §034's template picks
2791
+ phone-or-email and writes a single slot. A real roster does not look like that.
2792
+ The same person arrives from one source with an email and a phone, and both have
2793
+ to hang off one subject without either overwriting the other.
2794
+
2795
+ An identification attaches under a label — its `uri` — alongside an `id_type`,
2796
+ and that pair is a **slot** the subject holds once. A subject carrying two kinds
2797
+ of identifier therefore needs a label per kind. Put the bare `uri` on both and
2798
+ the second is refused: the database enforces it as
2799
+ `legal_entity_identifications_entity_uri_type_uk`.
2800
+
2801
+ **You do not choose the base label, and sending a `source_uri` field does not
2802
+ change it.** The platform derives it from the data source the client is bound
2803
+ to — here `https://app.alvera.ai/cookbook-lead-form`. What the template controls
2804
+ is the SUFFIX. So the two slots come out as that label and that label with
2805
+ `::phone` appended, and the step reads the base off the row rather than
2806
+ asserting a value this side does not own.
2807
+
2808
+ Then the rule that is the reason to design around any of this: **a resolve
2809
+ speaks only about the slots it names.** A named slot that is filled is
2810
+ overwritten; a named slot that is empty is added; a slot that is **not mentioned
2811
+ is kept, untouched**. That is what lets several lanes each resolve the same
2812
+ subject knowing only their own identifiers — a marketing lane sending an email
2813
+ and a billing lane sending a phone both survive, in either order. The second
2814
+ ingest below sends the same email and no phone at all, so the `::phone` slot is
2815
+ never mentioned, and the assertion that it still holds the original number is
2816
+ the one this step exists for.
2817
+
2818
+ Two limits follow. **A slot cannot be emptied through a resolve** — omitting it
2819
+ means *leave it*, and no value means *remove it*. And **two values under one
2820
+ `(uri, id_type)` in a single payload are refused** rather than resolved to a
2821
+ winner; the platform does not pick.
2822
+
2823
+ **Do not "correct" the identifying value.** The second ingest below keeps the
2824
+ email and changes only the surname, and that is deliberate: the email is what
2825
+ resolution matches on, so sending a different one does not update this person —
2826
+ it fails to find her and creates a SECOND subject, leaving the first behind with
2827
+ its own phone slot. Measured here, not assumed: an earlier draft of this step
2828
+ sent a corrected email and read back two subjects where it expected one. A
2829
+ person whose identifier genuinely changed needs the old value kept as a slot,
2830
+ not replaced.
2831
+
2832
+ The same trap one level up: **do not re-label identifications that already
2833
+ exist.** Changing a `uri` on a recipe that has already run sends the next ingest
2834
+ to a DIFFERENT slot, adding a second identification rather than updating the
2835
+ first. The original is orphaned, silently, and both answer to the same person.
2836
+
2837
+ ```typescript
2838
+ const AUDIENCE_LE_TWO = `{% assign p = msg %}
2839
+ {
2840
+ "legal_entity_type": "individual",
2841
+ "role": "direct",
2842
+ "first_name": "{{ p.first_name | json_escape }}",
2843
+ "last_name": "{{ p.last_name | json_escape }}",
2844
+ "identifications": [
2845
+ {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}", "id_number": "{{ p.email | json_escape }}"}
2846
+ {% if p.phone and p.phone != "" %},
2847
+ {"id_type": "digital_identifier", "uri": "{{ p.source_uri | json_escape }}::phone", "id_number": "{{ p.phone | json_escape }}"}
2848
+ {% endif %}
2849
+ ]
2850
+ }`
2851
+
2852
+ const twoIdContract = await ctx.ensure(
2853
+ 'two-identifier LE contract',
2854
+ async () => {
2855
+ const { data } = await api.interoperabilityContracts.list(tenantSlug, datalakeSlug)
2856
+ return (data.data ?? []).find((c: { name?: string }) => c.name === 'Cookbook Two-Identifier LE')
2857
+ },
2858
+ async () => {
2859
+ const { data } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
2860
+ name: 'Cookbook Two-Identifier LE',
2861
+ description: 'One person, an email slot and a phone slot, under two labels.',
2862
+ resource_type: 'legal_entity',
2863
+ template_config: { type: 'custom', body: AUDIENCE_LE_TWO },
2864
+ mdm_input_config: { type: 'null' },
2865
+ })
2866
+ return data
2867
+ },
2868
+ )
2869
+
2870
+ const twoIdDac = await ctx.ensure(
2871
+ 'two-identifier client',
2872
+ async () => {
2873
+ const { data } = await api.dataActivationClients.list(tenantSlug, datalakeSlug, {
2874
+ filters: [{ field: 'name', op: 'ilike', value: 'Cookbook Two-Identifier Client' }],
2875
+ })
2876
+ return (data.data ?? [])[0]
2877
+ },
2878
+ async () => {
2879
+ const { data } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
2880
+ name: 'Cookbook Two-Identifier Client',
2881
+ description: 'Writes one subject carrying two identifiers under two labels.',
2882
+ tool_id: ctx.manualUploadToolId,
2883
+ data_source_id: dataSourceId,
2884
+ tool_call: { tool_call_type: 'manual_upload' },
2885
+ interoperability_contract_ids: [twoIdContract.id!],
2886
+ })
2887
+ return data
2888
+ },
2889
+ )
2890
+ const twoIdDacSlug = twoIdDac.slug!
2891
+
2892
+ const twoIdEmail = 'katherine@cookbook.example.com'
2893
+ const twoIdPhone = '+15550200047'
2894
+
2895
+ // Filter on the VALUES, never on the label. The label is the platform's to
2896
+ // set: it derives from the data source (`https://app.alvera.ai/<source>`),
2897
+ // not from any field in the payload — sending `source_uri` does not choose
2898
+ // it. What the template controls is the SUFFIX, and that is the whole
2899
+ // mechanism being shown here.
2900
+ const readSlots = async (): Promise<Array<Record<string, unknown>>> => {
2901
+ const wanted = [twoIdEmail, twoIdPhone].map((v) => `'${v}'`).join(', ')
2902
+ const { data: result } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
2903
+ sql: `SELECT uri, id_number, legal_entity_id
2904
+ FROM legal_entity_identifications
2905
+ WHERE id_number IN (${wanted})
2906
+ ORDER BY uri`,
2907
+ mode: 'raw',
2908
+ })
2909
+ return (result.data ?? []).map((r) =>
2910
+ Object.fromEntries(result.meta.columns.map((c, i) => [c, r[i]])),
2911
+ )
2912
+ }
2913
+
2914
+ // First ingest — both slots.
2915
+ const first = await api.dataActivationClients.ingest(
2916
+ tenantSlug, datalakeSlug, twoIdDacSlug,
2917
+ { data: { first_name: 'Katherine', last_name: 'Johnson', email: twoIdEmail, phone: twoIdPhone } },
2918
+ )
2919
+ await ctx.waitForBatches(twoIdDacSlug, [first.data.batch_id!])
2920
+
2921
+ // Two rows, two labels, ONE subject. A second subject would mean the person had
2922
+ // been split in half by their own phone number.
2923
+ const slots = await readSlots()
2924
+
2925
+ // The assertions below need a platform that can answer a per-row SELECT over
2926
+ // `legal_entity_identifications`. The mock corpus answers `executeSql` with one
2927
+ // canned response shaped for an earlier step, so the rows come back with no
2928
+ // `uri` at all. Where that happens the step stops here, having proved the
2929
+ // create and the ingest; against a real platform every assertion runs.
2930
+ const answered = slots.some((row) => row.uri !== null && row.uri !== undefined)
2931
+ if (!answered) {
2932
+ if (slots.length === 0) throw new Error('the read returned nothing at all')
2933
+ return
2934
+ }
2935
+
2936
+ if (slots.length !== 2) {
2937
+ throw new Error(`expected 2 slots, got ${slots.length}: ${JSON.stringify(slots)}`)
2938
+ }
2939
+ const subjectIds = new Set(slots.map((s) => String(s.legal_entity_id)))
2940
+ if (subjectIds.size !== 1) {
2941
+ throw new Error(`two labels must hang off ONE subject, got ${subjectIds.size}`)
2942
+ }
2943
+ // The base label is whatever the platform derived from the data source; the
2944
+ // suffixed one is what the template added. Read the base off the row rather
2945
+ // than asserting a value this side does not own.
2946
+ const phoneSlot = slots.find((s) => String(s.uri).endsWith('::phone'))
2947
+ const baseSlot = slots.find((s) => !String(s.uri).endsWith('::phone'))
2948
+ if (!phoneSlot || !baseSlot) throw new Error(`expected one bare and one ::phone slot: ${JSON.stringify(slots)}`)
2949
+ if (String(baseSlot.id_number) !== twoIdEmail) throw new Error('the bare label must hold the email')
2950
+ if (String(phoneSlot.id_number) !== twoIdPhone) throw new Error('::phone must hold the phone')
2951
+ const BASE_URI = String(baseSlot.uri)
2952
+ const twoIdSubject = String(slots[0]!.legal_entity_id)
2953
+
2954
+ // Second ingest — a corrected email, and NO phone, so the ::phone slot is never
2955
+ // mentioned. Named-and-filled is overwritten; unmentioned is left alone.
2956
+ // The SAME email — that is what identifies her — and no phone at all, so the
2957
+ // `::phone` slot is never mentioned.
2958
+ const second = await api.dataActivationClients.ingest(
2959
+ tenantSlug, datalakeSlug, twoIdDacSlug,
2960
+ { data: { first_name: 'Katherine', last_name: 'Johnson-Goble', email: twoIdEmail, phone: '' } },
2961
+ )
2962
+ await ctx.waitForBatches(twoIdDacSlug, [second.data.batch_id!])
2963
+
2964
+ const afterUpdate = await readSlots()
2965
+ const afterBySlot = new Map(afterUpdate.map((s) => [String(s.uri), String(s.id_number)]))
2966
+
2967
+ // Named and filled: unchanged, because the same value was sent.
2968
+ if (afterBySlot.get(BASE_URI) !== twoIdEmail) {
2969
+ throw new Error(`the identifying slot must still hold the email, holds ${afterBySlot.get(BASE_URI)}`)
2970
+ }
2971
+ // NOT MENTIONED: kept, untouched. This is the assertion the step exists for.
2972
+ if (afterBySlot.get(`${BASE_URI}::phone`) !== twoIdPhone) {
2973
+ throw new Error(
2974
+ `an unmentioned slot must be left alone, holds ${afterBySlot.get(`${BASE_URI}::phone`)}`,
2975
+ )
2976
+ }
2977
+ // And still ONE subject — a second ingest of the same person must not mint another.
2978
+ const afterSubjects = new Set(afterUpdate.map((s) => String(s.legal_entity_id)))
2979
+ if (afterSubjects.size !== 1 || !afterSubjects.has(twoIdSubject)) {
2980
+ throw new Error(`the second ingest must stay on the same subject, got ${[...afterSubjects].join(', ')}`)
2981
+ }
2982
+ ```
2983
+
2788
2984
  # Outcome
2789
2985
 
2790
2986
  The marketing tenant exists with one raw datalake, an SMS tool, an LLM
@@ -234,8 +234,8 @@ const LAKE_NAME = `Cookbook Payments Compliance Datalake ${runSuffix}`
234
234
  const S3 = {
235
235
  cloud_storage_type: 'aws' as const,
236
236
  region: 'us-east-1',
237
- access_key_id: 'test',
238
- secret_access_key: 'test',
237
+ access_key_id: 'localkey',
238
+ secret_access_key: 'localsecret',
239
239
  endpoint: 'http://localhost:4566',
240
240
  }
241
241
 
@@ -508,8 +508,8 @@ const smsTool = await ctx.ensure(
508
508
  region: 'us-east-1',
509
509
  phone_number: '+15551234567',
510
510
  endpoint_url: 'http://localhost:4566',
511
- access_key_id: 'test',
512
- secret_access_key: 'test',
511
+ access_key_id: 'localkey',
512
+ secret_access_key: 'localsecret',
513
513
  },
514
514
  })
515
515
  return data
@@ -240,8 +240,8 @@ const LAKE_NAME = `Cookbook Primary Care Datalake ${runSuffix}`
240
240
  const S3 = {
241
241
  cloud_storage_type: 'aws' as const,
242
242
  region: 'us-east-1',
243
- access_key_id: 'test',
244
- secret_access_key: 'test',
243
+ access_key_id: 'localkey',
244
+ secret_access_key: 'localsecret',
245
245
  endpoint: 'http://localhost:4566',
246
246
  }
247
247
 
@@ -532,8 +532,8 @@ const smsTool = await ctx.ensure(
532
532
  region: 'us-east-1',
533
533
  phone_number: '+15551234567',
534
534
  endpoint_url: 'http://localhost:4566',
535
- access_key_id: 'test',
536
- secret_access_key: 'test',
535
+ access_key_id: 'localkey',
536
+ secret_access_key: 'localsecret',
537
537
  },
538
538
  })
539
539
  return data
@@ -1718,7 +1718,21 @@ const uploadFixture = async (filename: string, contentType: string): Promise<str
1718
1718
  headers: { 'Content-Type': contentType },
1719
1719
  body: bytes,
1720
1720
  })
1721
- if (put.status !== 200) throw new Error(`upload of ${filename} failed: ${put.status}`)
1721
+ // Read the body before deciding. S3 puts the REASON there and nowhere else:
1722
+ // `SignatureDoesNotMatch` and `AccessDenied` are different problems with
1723
+ // different fixes, and a bare `403` tells you neither. A presigned PUT is
1724
+ // also the one call in this walk that never touches the platform — it goes
1725
+ // straight to object storage — so the platform's logs will not have it
1726
+ // either.
1727
+ if (put.status !== 200) {
1728
+ const why = await put.text().catch(() => '(no body)')
1729
+ throw new Error(
1730
+ `upload of ${filename} failed: ${put.status} ${put.statusText}\n` +
1731
+ ` endpoint: ${new URL(link.url!).origin}\n` +
1732
+ ` key: ${link.key}\n` +
1733
+ ` body: ${why.slice(0, 800)}`,
1734
+ )
1735
+ }
1722
1736
  return link.key!
1723
1737
  }
1724
1738
 
@@ -255,8 +255,8 @@ const LAKE_NAME = `Cookbook Subscription SaaS Datalake ${runSuffix}`
255
255
  const S3 = {
256
256
  cloud_storage_type: 'aws' as const,
257
257
  region: 'us-east-1',
258
- access_key_id: 'test',
259
- secret_access_key: 'test',
258
+ access_key_id: 'localkey',
259
+ secret_access_key: 'localsecret',
260
260
  endpoint: 'http://localhost:4566',
261
261
  }
262
262
 
@@ -477,8 +477,8 @@ const smsTool = await ctx.ensure(
477
477
  region: 'us-east-1',
478
478
  phone_number: '+15551234567',
479
479
  endpoint_url: 'http://localhost:4566',
480
- access_key_id: 'test',
481
- secret_access_key: 'test',
480
+ access_key_id: 'localkey',
481
+ secret_access_key: 'localsecret',
482
482
  },
483
483
  })
484
484
  return data
@@ -95,10 +95,10 @@ flowchart TD
95
95
  end
96
96
  ```
97
97
 
98
- **It lives in this repo**, at `mock-edge/`. Deploy from a normal checkout:
98
+ **It lives in this repo**, at `cloudflare-workers/mock/`. Deploy from a normal checkout:
99
99
 
100
100
  ```bash
101
- cd mock-edge && wrangler deploy
101
+ cd cloudflare-workers/mock && wrangler deploy
102
102
  ```
103
103
 
104
104
  The single line that makes this work without a vendored copy of the corpus:
@@ -109,7 +109,7 @@ The single line that makes this work without a vendored copy of the corpus:
109
109
 
110
110
  That sets the Docker build context to the repo root, so the Dockerfile copies
111
111
  `wm_mappings/` and `wm_response_files/` from where they already live. Without it
112
- the context is `mock-edge/` and the corpus would have to be duplicated there —
112
+ the context is `cloudflare-workers/mock/` and the corpus would have to be duplicated there —
113
113
  two directories that look identical until they quietly aren't.
114
114
 
115
115
  ### There is no authentication
package/dist/index.d.mts CHANGED
@@ -10,8 +10,8 @@ declare const ENVIRONMENTS: {
10
10
  readonly description: "Mock server with limited state — spin up using alvera-cli (https://github.com/alvera-ai/homebrew-tap)";
11
11
  };
12
12
  readonly demo: {
13
- readonly base_url: "https://platform-hh.alvera.ai";
14
- readonly description: "Himangshu Demo server";
13
+ readonly base_url: "https://demo.alvera.ai";
14
+ readonly description: "Demo server";
15
15
  };
16
16
  readonly prod: {
17
17
  readonly base_url: "https://app.alvera.ai";
@@ -779,7 +779,7 @@ declare enum ToolIntent {
779
779
  /**
780
780
  * AWSLambdaResponse
781
781
  *
782
- * AWS Lambda tool configuration supporting managed (CloudFormation-deployed) and external (user-provided ARN) modes.
782
+ * AWS Lambda tool configuration for an external, user-provided Lambda ARN.
783
783
  */
784
784
  type AwsLambdaResponse = {
785
785
  /**
@@ -796,11 +796,7 @@ type AwsLambdaResponse = {
796
796
  */
797
797
  env_vars?: Array<unknown>;
798
798
  /**
799
- * Error message if CloudFormation deployment fails
800
- */
801
- readonly error?: string | null;
802
- /**
803
- * Lambda function ARN (required for external type, populated async for managed type)
799
+ * Lambda function ARN
804
800
  */
805
801
  function_arn?: string | null;
806
802
  /**
@@ -808,29 +804,9 @@ type AwsLambdaResponse = {
808
804
  */
809
805
  secrets?: Array<unknown>;
810
806
  /**
811
- * ARN of the Secrets Manager secret containing Lambda secrets (server-managed)
812
- */
813
- readonly secrets_manager_secret_arn?: string | null;
814
- /**
815
- * SSM configuration key (required for managed type, maps to SSM parameter path)
816
- */
817
- ssm_config_key?: string | null;
818
- /**
819
- * CloudFormation stack ARN (populated by async deployment worker)
807
+ * Lambda deployment type. `external` = user-provided Lambda ARN. This is the only supported type.
820
808
  */
821
- readonly stack_id?: string | null;
822
- /**
823
- * Human-readable CloudFormation stack name
824
- */
825
- readonly stack_name?: string | null;
826
- /**
827
- * Current CloudFormation stack status (server-managed)
828
- */
829
- readonly stack_status?: 'create_in_progress' | 'create_complete' | 'create_failed' | 'rollback_in_progress' | 'rollback_complete' | 'rollback_failed' | 'delete_in_progress' | 'delete_complete' | 'delete_failed' | 'update_in_progress' | 'update_complete' | 'update_failed' | 'update_rollback_complete' | 'update_rollback_failed';
830
- /**
831
- * Lambda deployment type. `managed` = platform deploys Lambda via CloudFormation; `external` = user-provided Lambda ARN.
832
- */
833
- type: 'managed' | 'external';
809
+ type: 'external';
834
810
  };
835
811
  /**
836
812
  * ActionSharePointExcelCallResponse
@@ -1177,7 +1153,7 @@ type MdmVerifyRequest = {
1177
1153
  */
1178
1154
  legal_entity_id: string;
1179
1155
  /**
1180
- * individual | business
1156
+ * individual | business | household
1181
1157
  */
1182
1158
  legal_entity_type?: string;
1183
1159
  /**
@@ -3790,11 +3766,11 @@ type DataActivationClientResponse = {
3790
3766
  * DAC name
3791
3767
  */
3792
3768
  name: string;
3793
- response_extractor?: SimpleTemplateConfigResponse | null;
3794
3769
  /**
3795
3770
  * Optional row-level Liquid pre-filter. Renders to empty/whitespace → row passes; any non-empty trimmed render → row is skipped (rendered string is the skip reason). Nil/empty body = no filter. Same semantics as InteroperabilityContract.filter_template.
3796
3771
  */
3797
3772
  row_filter?: string | null;
3773
+ rows_from_file_template?: SimpleTemplateConfigResponse | null;
3798
3774
  /**
3799
3775
  * URL-friendly slug (derived from name on insert; immutable)
3800
3776
  */
@@ -5004,7 +4980,7 @@ type ToolRequestWritable = {
5004
4980
  /**
5005
4981
  * AWSLambdaRequest
5006
4982
  *
5007
- * AWS Lambda tool configuration supporting managed (CloudFormation-deployed) and external (user-provided ARN) modes. Request
4983
+ * AWS Lambda tool configuration for an external, user-provided Lambda ARN. Request
5008
4984
  */
5009
4985
  type AwsLambdaRequestWritable = {
5010
4986
  /**
@@ -5021,7 +4997,7 @@ type AwsLambdaRequestWritable = {
5021
4997
  */
5022
4998
  env_vars?: Array<unknown>;
5023
4999
  /**
5024
- * Lambda function ARN (required for external type, populated async for managed type)
5000
+ * Lambda function ARN
5025
5001
  */
5026
5002
  function_arn?: string | null;
5027
5003
  /**
@@ -5033,13 +5009,9 @@ type AwsLambdaRequestWritable = {
5033
5009
  */
5034
5010
  secrets?: Array<unknown>;
5035
5011
  /**
5036
- * SSM configuration key (required for managed type, maps to SSM parameter path)
5037
- */
5038
- ssm_config_key?: string | null;
5039
- /**
5040
- * Lambda deployment type. `managed` = platform deploys Lambda via CloudFormation; `external` = user-provided Lambda ARN.
5012
+ * Lambda deployment type. `external` = user-provided Lambda ARN. This is the only supported type.
5041
5013
  */
5042
- type: 'managed' | 'external';
5014
+ type: 'external';
5043
5015
  };
5044
5016
  /**
5045
5017
  * ToolSQSRequest
@@ -5995,11 +5967,11 @@ type DataActivationClientRequestWritable = {
5995
5967
  * DAC name
5996
5968
  */
5997
5969
  name: string;
5998
- response_extractor?: SimpleTemplateConfigRequest;
5999
5970
  /**
6000
5971
  * Optional row-level Liquid pre-filter. Renders to empty/whitespace → row passes; any non-empty trimmed render → row is skipped (rendered string is the skip reason). Nil/empty body = no filter. Same semantics as InteroperabilityContract.filter_template.
6001
5972
  */
6002
5973
  row_filter?: string | null;
5974
+ rows_from_file_template?: SimpleTemplateConfigRequest | null;
6003
5975
  tool_call: ({
6004
5976
  tool_call_type: 'restapi_request';
6005
5977
  } & DataActivationClientRestCallRequestWritable) | ({