@ixo/oracle-runtime-workers 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -34,7 +34,7 @@
34
34
  "!src/plugins/pod-creator/test-fixtures.ts"
35
35
  ],
36
36
  "dependencies": {
37
- "@ixo/action-protocol": "0.7.0",
37
+ "@ixo/action-protocol": "0.8.0",
38
38
  "@blocknote/server-util": "0.29.1",
39
39
  "@composio/core": "0.10.0",
40
40
  "@composio/langchain": "0.9.2",
@@ -67,9 +67,9 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
- "@ixo/ixo-transaction": "0.2.0",
70
+ "@ixo/common": "1.7.1",
71
71
  "@ixo/ucan": "2.3.0",
72
- "@ixo/common": "1.7.1"
72
+ "@ixo/ixo-transaction": "0.2.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -7,6 +7,7 @@ import { type AgentMiddleware, createMiddleware } from 'langchain';
7
7
  import type { Logger } from '../../plugin-api/types';
8
8
  import { NOOP_LOGGER } from '../utils';
9
9
  import { isCapabilityGateRefusal } from './capability-gate';
10
+ import { isToolNotRun } from '../tool-not-run';
10
11
  import { turnCarryOf, turnStart } from './turn-boundary';
11
12
 
12
13
  export { turnStart };
@@ -145,9 +146,11 @@ export const createToolRepetitionGuardMiddleware = (
145
146
  const msg = messages[i];
146
147
  if (!(msg instanceof ToolMessage)) continue;
147
148
  if (msg.status !== 'error') continue;
148
- // The capability gate refused it without running it; after a
149
- // `load_capability` the same call is the expected next step.
150
- if (isCapabilityGateRefusal(msg)) continue;
149
+ // The capability gate refused it without running it, or the tool
150
+ // said it did not run until a step comes first: after that step
151
+ // (`load_capability`, reading the rules) the same call is the
152
+ // expected next one.
153
+ if (isCapabilityGateRefusal(msg) || isToolNotRun(msg)) continue;
151
154
  if (msg.name !== toolName) continue;
152
155
 
153
156
  const priorArgs = argsById.get(msg.tool_call_id);
@@ -22,6 +22,7 @@ import {
22
22
  } from '@langchain/core/messages';
23
23
  import { isAttachmentViewMessage } from '../../attachments/retention';
24
24
  import { isCapabilityGateRefusal } from './capability-gate';
25
+ import { isToolNotRun } from '../tool-not-run';
25
26
 
26
27
  export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
27
28
 
@@ -140,7 +141,11 @@ export function carriedCallsOfTurn(
140
141
  if (call.id) calls.set(call.id, { name: call.name, args: call.args });
141
142
  continue;
142
143
  }
143
- if (!ToolMessage.isInstance(message) || isCapabilityGateRefusal(message))
144
+ if (
145
+ !ToolMessage.isInstance(message) ||
146
+ isCapabilityGateRefusal(message) ||
147
+ isToolNotRun(message)
148
+ )
144
149
  continue;
145
150
  const call = calls.get(message.tool_call_id);
146
151
  if (!call) continue;
@@ -0,0 +1,30 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+
3
+ /** `response_metadata` key of a result whose tool did not run (never sent to a provider). */
4
+ const NOT_RUN = 'toolNotRun';
5
+
6
+ /**
7
+ * A tool result saying the tool did not run because another step must come
8
+ * first (for example reading a message type's rules before preparing it).
9
+ * It is an error result, so it never counts as a write that happened, and
10
+ * like a capability-gate refusal the repetition guard lets the identical
11
+ * call through once the step is done (`isToolNotRun`).
12
+ */
13
+ export function toolNotRunMessage(input: {
14
+ toolCallId: string;
15
+ name: string;
16
+ content: string;
17
+ }): ToolMessage {
18
+ return new ToolMessage({
19
+ tool_call_id: input.toolCallId,
20
+ name: input.name,
21
+ content: input.content,
22
+ status: 'error',
23
+ response_metadata: { [NOT_RUN]: true },
24
+ });
25
+ }
26
+
27
+ /** A result `toolNotRunMessage` made: the tool did not run. */
28
+ export function isToolNotRun(message: ToolMessage): boolean {
29
+ return message.response_metadata?.[NOT_RUN] === true;
30
+ }
@@ -1,3 +1,4 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
1
2
  import { Command } from '@langchain/langgraph';
2
3
  import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
3
4
  import { tool } from '@langchain/core/tools';
@@ -125,7 +126,14 @@ export function wrapPluginTool(
125
126
  if (pluginName !== undefined)
126
127
  requireToolPlane(ctx, pluginName, pluginTool);
127
128
  const output = await pluginTool.handler(args, ctx);
128
- if (!resultCap || output instanceof Command || output == null)
129
+ // A tool's own message (a result that says the tool did not run) is
130
+ // returned whole, never capped into text.
131
+ if (
132
+ !resultCap ||
133
+ output instanceof Command ||
134
+ ToolMessage.isInstance(output) ||
135
+ output == null
136
+ )
129
137
  return output;
130
138
  const text = typeof output === 'string' ? output : JSON.stringify(output);
131
139
  if (typeof text !== 'string' || text.length <= resultCap.capChars)
@@ -54,7 +54,7 @@ import {
54
54
  import { chainTxOperation, prepareHubAction } from '../hub-actions/request';
55
55
  import {
56
56
  bankReceiptSummary,
57
- bankStepsInBaseUnits,
57
+ chainTxAmountsInBaseUnits,
58
58
  } from '../hub-actions/transfer';
59
59
  import {
60
60
  HUB_CHECK_AFTER_EXPIRY_MS,
@@ -638,6 +638,14 @@ const ACTION_TICK_BUDGET_MS = 15_000;
638
638
  const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
639
639
  /** How long the Hub's capabilities answer is reused per object. */
640
640
  const HUB_CAPABILITIES_CACHE_MS = 60_000;
641
+ /**
642
+ * A capabilities read on a turn's critical path (the chain transaction
643
+ * tool's types): a short timeout, and after a failed read none for this
644
+ * long, so a slow or failing Hub costs a turn at most this timeout once per
645
+ * window instead of the full Hub timeout on every turn.
646
+ */
647
+ const HUB_TURN_CAPABILITIES_TIMEOUT_MS = 3_000;
648
+ const HUB_CAPABILITIES_FAILURE_CACHE_MS = 30_000;
641
649
  /** A Hub network name as the chain-id config key it is configured under. */
642
650
  /**
643
651
  * Whether two Hub outcomes are the same event. Receipts are compared in the
@@ -1054,11 +1062,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1054
1062
  private actionProposals: ActionProposalStore | null = null;
1055
1063
  private hubActionStore: HubActionStore | null = null;
1056
1064
  private hubActionClient: HubActionClient | null = null;
1057
- /** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS`. */
1065
+ /** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS` (and on a turn, while a read fails). */
1058
1066
  private hubCapabilities: {
1059
1067
  value: ActionCapabilitiesV1;
1060
1068
  at: number;
1061
1069
  } | null = null;
1070
+ /** The last failed capabilities read: a turn does not read again within `HUB_CAPABILITIES_FAILURE_CACHE_MS`. */
1071
+ private hubCapabilitiesFailure: { error: unknown; at: number } | null =
1072
+ null;
1073
+ /** A turn's capabilities read in flight, shared by concurrent turns. */
1074
+ private hubTurnCapabilitiesRead: Promise<ActionCapabilitiesV1> | null =
1075
+ null;
1062
1076
  /** The last `hub_action_options` answer for this owner (Gmail connection checks). */
1063
1077
  private lastHubOptions: {
1064
1078
  value: HubActionOptionsV2 | HubActionOptionsV3;
@@ -2277,7 +2291,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2277
2291
  ...(this.hubActionStore && this.hubActionClient
2278
2292
  ? {
2279
2293
  hubActions: {
2280
- capabilities: () => this.cachedHubCapabilities(),
2294
+ capabilities: (opts) =>
2295
+ opts?.turn
2296
+ ? this.turnHubCapabilities()
2297
+ : this.cachedHubCapabilities(),
2281
2298
  options: (input) => this.hubActionOptions(input),
2282
2299
  request: (input: {
2283
2300
  ownerDid: string;
@@ -2385,6 +2402,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2385
2402
  this.hubActionStore = null;
2386
2403
  this.hubActionClient = null;
2387
2404
  this.hubCapabilities = null;
2405
+ this.hubCapabilitiesFailure = null;
2406
+ this.hubTurnCapabilitiesRead = null;
2388
2407
  this.lastHubOptions = null;
2389
2408
  this.topicProposals = null;
2390
2409
  this.attachmentTextCache = null;
@@ -3524,16 +3543,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3524
3543
  }
3525
3544
 
3526
3545
  /** The Hub's capabilities, cached per object for `HUB_CAPABILITIES_CACHE_MS`. */
3527
- private async cachedHubCapabilities(): Promise<ActionCapabilitiesV1> {
3546
+ private async cachedHubCapabilities(
3547
+ timeoutMs?: number,
3548
+ ): Promise<ActionCapabilitiesV1> {
3528
3549
  const client = this.hubActionClient;
3529
3550
  if (!client) throw new Error('Hub actions are unavailable');
3530
3551
  const now = Date.now();
3531
3552
  const cached = this.hubCapabilities ?? null;
3532
3553
  if (cached && now - cached.at < HUB_CAPABILITIES_CACHE_MS)
3533
3554
  return cached.value;
3534
- const value = await client.capabilities();
3535
- this.hubCapabilities = { value, at: now };
3536
- return value;
3555
+ try {
3556
+ const value = await client.capabilities(timeoutMs);
3557
+ this.hubCapabilities = { value, at: Date.now() };
3558
+ this.hubCapabilitiesFailure = null;
3559
+ return value;
3560
+ } catch (error) {
3561
+ this.hubCapabilitiesFailure = { error, at: Date.now() };
3562
+ throw error;
3563
+ }
3564
+ }
3565
+
3566
+ /**
3567
+ * The capabilities for a turn's tool list: the cached ones while fresh;
3568
+ * else one read with `HUB_TURN_CAPABILITIES_TIMEOUT_MS`, shared by
3569
+ * concurrent turns and not repeated within
3570
+ * `HUB_CAPABILITIES_FAILURE_CACHE_MS` of a failed read. While reads
3571
+ * fail, the last capabilities read are answered (the Hub enforces its
3572
+ * own on every prepare); with none, the failure.
3573
+ */
3574
+ private async turnHubCapabilities(): Promise<ActionCapabilitiesV1> {
3575
+ const cached = this.hubCapabilities;
3576
+ if (cached && Date.now() - cached.at < HUB_CAPABILITIES_CACHE_MS)
3577
+ return cached.value;
3578
+ const failure = this.hubCapabilitiesFailure;
3579
+ if (
3580
+ failure &&
3581
+ Date.now() - failure.at < HUB_CAPABILITIES_FAILURE_CACHE_MS
3582
+ ) {
3583
+ if (cached) return cached.value;
3584
+ throw failure.error;
3585
+ }
3586
+ this.hubTurnCapabilitiesRead ??= this.cachedHubCapabilities(
3587
+ HUB_TURN_CAPABILITIES_TIMEOUT_MS,
3588
+ ).finally(() => {
3589
+ this.hubTurnCapabilitiesRead = null;
3590
+ });
3591
+ try {
3592
+ return await this.hubTurnCapabilitiesRead;
3593
+ } catch (error) {
3594
+ if (cached) return cached.value;
3595
+ throw error;
3596
+ }
3537
3597
  }
3538
3598
 
3539
3599
  /**
@@ -3586,13 +3646,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3586
3646
  /**
3587
3647
  * One chain transaction of the model's messages. The network is the
3588
3648
  * account's capability, the chain id this Worker's own for that
3589
- * network; the signer and the owner's identity in claims messages come
3649
+ * network; the signer and the owner's identity in the messages come
3590
3650
  * from the owner's DID (`chainTxOperation`). The model names none of
3591
- * them. Bank steps name a listed token and a display amount: with at
3592
- * least one, the owner's options are read once, fresh (the balances
3593
- * must be current), and each step is converted to base units and
3594
- * checked against the listed balance (`bankStepsInBaseUnits`). No fee
3595
- * is sent: the Hub simulates gas and sets it.
3651
+ * them. Bank steps name a listed token and a display amount, stake
3652
+ * steps an amount of IXO: with at least one of either, the owner's
3653
+ * options are read once, fresh (the balances must be current), and each
3654
+ * amount is converted to base units and checked against the listed
3655
+ * balance (`chainTxAmountsInBaseUnits`). No fee is sent: the Hub
3656
+ * simulates gas and sets it.
3596
3657
  */
3597
3658
  private async chainTransactionHubAction(input: {
3598
3659
  ownerDid: string;
@@ -3624,7 +3685,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3624
3685
  HUB_NETWORK_CHAIN[capabilities.network]
3625
3686
  ];
3626
3687
  const tokens = input.messages.some(
3627
- (message) => message.type === 'cosmos.bank.send',
3688
+ (message) =>
3689
+ message.type === 'cosmos.bank.send' ||
3690
+ message.type === 'cosmos.staking.delegate',
3628
3691
  )
3629
3692
  ? ((await this.hubActionOptions(input, true)).transfer?.tokens ?? null)
3630
3693
  : null;
@@ -3633,7 +3696,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3633
3696
  chainId,
3634
3697
  network: capabilities.network,
3635
3698
  memo: input.memo,
3636
- messages: bankStepsInBaseUnits(input.messages, tokens),
3699
+ messages: chainTxAmountsInBaseUnits(input.messages, tokens),
3637
3700
  });
3638
3701
  return this.requestHubAction({ ...input, operation }, true);
3639
3702
  }
@@ -0,0 +1,217 @@
1
+ import {
2
+ CHAIN_TX_MAX_MESSAGES,
3
+ CHAIN_TX_REFUSAL_CODES,
4
+ type ChainTxRefusal,
5
+ type ChainTxRefusalCode,
6
+ } from '@ixo/action-protocol';
7
+
8
+ /**
9
+ * What each chain transaction refusal code means, for the person (`means`,
10
+ * plain words the model may say) and what the model does next (`next`).
11
+ * Auth Hub sends the code when it will not make a card from what it read on
12
+ * chain, or when the node's simulation names one message
13
+ * (`invalid_message: messages[<i>].<field>: <code>`); the reason text itself
14
+ * is never given to the person. `satisfies` makes every code of the
15
+ * protocol need an entry.
16
+ */
17
+ export const CHAIN_TX_REFUSAL_WORDS = {
18
+ validator_not_found: {
19
+ means: 'there is no validator with that address on the network',
20
+ next: 'Find the validator again with chain_lookup validators and copy its address exactly.',
21
+ },
22
+ no_stake: {
23
+ means: 'they have nothing staked with this validator',
24
+ next: 'Check where their stake is with chain_lookup staking.',
25
+ },
26
+ stake_too_small: {
27
+ means: 'that is more than they have staked with this validator',
28
+ next: 'Check how much is staked there with chain_lookup staking and ask for a smaller amount.',
29
+ },
30
+ same_validator: {
31
+ means: 'the stake is already with that validator',
32
+ next: 'Ask which other validator it should move to.',
33
+ },
34
+ invalid_amount: {
35
+ means: 'the network does not accept that amount',
36
+ next: 'Ask for the amount again.',
37
+ },
38
+ nothing_to_collect: {
39
+ means: 'there are no rewards to collect from this validator yet',
40
+ next: 'Leave this validator out of the card.',
41
+ },
42
+ proposal_not_found: {
43
+ means: 'there is no proposal with that number',
44
+ next: 'Find the open proposals with chain_lookup gov_proposals.',
45
+ },
46
+ proposal_not_open: {
47
+ means:
48
+ 'voting on this proposal is not open (it has closed, or not started yet)',
49
+ next: 'Tell them; a vote cannot be recorded now.',
50
+ },
51
+ invalid_vote_option: {
52
+ means: 'that is not a vote the network accepts',
53
+ next: 'Ask whether they vote yes, no, abstain or no with veto.',
54
+ },
55
+ token_not_found: {
56
+ means: 'one of these impact tokens could not be found',
57
+ next: 'Check the tokens they hold with chain_lookup token_balances.',
58
+ },
59
+ collection_not_found: {
60
+ means: 'there is no claim collection with that number',
61
+ next: 'Find the collection with chain_lookup claim_collections.',
62
+ },
63
+ reservations_off: {
64
+ means: 'this collection does not take reservations',
65
+ next: 'File the claim without a reservation.',
66
+ },
67
+ reservation_exists: {
68
+ means: 'they already have a reservation in this collection',
69
+ next: 'File the claim with it, or wait for it to run out.',
70
+ },
71
+ claim_not_found: {
72
+ means: 'there is no claim with that id',
73
+ next: 'Find the claim with chain_lookup claims.',
74
+ },
75
+ claim_in_other_collection: {
76
+ means: 'that claim belongs to a different collection',
77
+ next: 'Check the claim’s collection with chain_lookup claim.',
78
+ },
79
+ wrong_admin: {
80
+ means: 'that is not the collection’s admin account',
81
+ next: 'Copy the admin account from chain_lookup claim_collection.',
82
+ },
83
+ already_in_state: {
84
+ means: 'the collection is already in that state',
85
+ next: 'Nothing needs to change; tell them.',
86
+ },
87
+ start_date_missing: {
88
+ means: 'the collection has no start date yet',
89
+ next: 'Ask when it should open and include that start date.',
90
+ },
91
+ end_before_start: {
92
+ means: 'the end date would come before the start date',
93
+ next: 'Ask for dates where the end comes after the start.',
94
+ },
95
+ quota_below_count: {
96
+ means: 'the new limit is below the number of claims already filed',
97
+ next: 'Check the count with chain_lookup claim_collection and ask for a higher limit.',
98
+ },
99
+ token_payments_present: {
100
+ means:
101
+ 'that payment includes contract tokens, which cannot be changed from here',
102
+ next: 'Leave that payment out of the card so it stays as it is.',
103
+ },
104
+ ends_too_soon: {
105
+ means:
106
+ 'the end date is too soon: it has to be after this review link expires',
107
+ next: 'Ask for a later end date.',
108
+ },
109
+ deadline_too_soon: {
110
+ means:
111
+ 'the date to decide by is too soon: it has to be after this review link expires',
112
+ next: 'Ask for a later date.',
113
+ },
114
+ current_permission_ends_too_soon: {
115
+ means: 'their current permission ends too soon to keep its end date',
116
+ next: 'Ask whether to set a new end date.',
117
+ },
118
+ unlimited_permission_exists: {
119
+ means: 'they already have an unlimited permission of this kind',
120
+ next: 'It has to be removed first; ask whether to remove it in the same card.',
121
+ },
122
+ other_permission_exists: {
123
+ means: 'a different kind of permission is already set up for them here',
124
+ next: 'It has to be removed first; ask whether to remove it in the same card.',
125
+ },
126
+ too_many_constraints: {
127
+ means:
128
+ 'this permission already covers too many collections to change it from a card',
129
+ next: 'Some collections have to be removed from it first; tell them.',
130
+ },
131
+ no_such_permission: {
132
+ means: 'they do not have this permission',
133
+ next: 'Check what they have with chain_lookup authz_grants.',
134
+ },
135
+ collection_of_other_entity: {
136
+ means: 'that collection belongs to a different entity',
137
+ next: 'Check the collection with chain_lookup claim_collection.',
138
+ },
139
+ entity_not_found: {
140
+ means: 'there is no entity with that id',
141
+ next: 'Find the entity with chain_lookup entities.',
142
+ },
143
+ account_name_taken: {
144
+ means: 'this entity already has an account with that name',
145
+ next: 'Ask for another name.',
146
+ },
147
+ document_not_found: {
148
+ means: 'there is no identity record with that id',
149
+ next: 'Check the id with chain_lookup did_document.',
150
+ },
151
+ nothing_to_remove: {
152
+ means: 'that item is not in the identity record',
153
+ next: 'Read the record with chain_lookup did_document and copy the id exactly.',
154
+ },
155
+ already_exists: {
156
+ means: 'an item with that id is already there',
157
+ next: 'Choose another id, or remove the old item first.',
158
+ },
159
+ not_available: {
160
+ means: 'that isn’t available on this service yet',
161
+ next: 'Do not retry; tell the person it cannot be done here for now.',
162
+ },
163
+ chain_data_too_large: {
164
+ means: 'this is too large to change in one go here',
165
+ next: 'Do not retry; tell the person it cannot be done through chat.',
166
+ },
167
+ invalid_did: {
168
+ means: 'one of the identifiers is not a valid ID on the network',
169
+ next: 'Copy it again exactly from a lookup.',
170
+ },
171
+ } as const satisfies Record<
172
+ ChainTxRefusalCode,
173
+ { means: string; next: string }
174
+ >;
175
+
176
+ /**
177
+ * A message field path as the protocol names one (`validatorAddress`,
178
+ * `payments.approval.amount[1].denom`): checked here as well, because the
179
+ * path is repeated in text the model reads.
180
+ */
181
+ const REFUSAL_FIELD =
182
+ /^[A-Za-z][A-Za-z0-9_]*(?:\[\d{1,3}\])?(?:\.[A-Za-z][A-Za-z0-9_]*(?:\[\d{1,3}\])?){0,5}$/;
183
+
184
+ /**
185
+ * A parsed refusal the runtime puts into words: a message index 0..7, a
186
+ * plain field path of at most 100 characters and a code of the protocol's
187
+ * list. Anything else is passed on as the Hub's quoted reason instead.
188
+ */
189
+ export function wordableRefusal(
190
+ refusal: ChainTxRefusal | null,
191
+ ): (ChainTxRefusal & { code: ChainTxRefusalCode }) | null {
192
+ if (!refusal) return null;
193
+ const { index, field, code } = refusal;
194
+ if (
195
+ !Number.isInteger(index) ||
196
+ index < 0 ||
197
+ index >= CHAIN_TX_MAX_MESSAGES ||
198
+ field.length > 100 ||
199
+ !REFUSAL_FIELD.test(field) ||
200
+ code === null ||
201
+ !CHAIN_TX_REFUSAL_CODES.includes(code)
202
+ )
203
+ return null;
204
+ return { index, field, code };
205
+ }
206
+
207
+ /**
208
+ * A coded chain transaction refusal in the model's words: which step, what
209
+ * it means for the person and the next step. The field and code are for
210
+ * the model only; the Hub's reason text is not repeated.
211
+ */
212
+ export function chainTxRefusalWords(
213
+ refusal: ChainTxRefusal & { code: ChainTxRefusalCode },
214
+ ): string {
215
+ const words = CHAIN_TX_REFUSAL_WORDS[refusal.code];
216
+ return `Auth Hub could not prepare this transaction: in step ${refusal.index + 1}, ${words.means}. ${words.next} Tell the person in your own plain words; this is not text to show them (for you: messages[${refusal.index}].${refusal.field}, ${refusal.code}).`;
217
+ }