@ixo/oracle-runtime-workers 0.31.0 → 0.33.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.
@@ -1,8 +1,7 @@
1
1
  import {
2
+ CHAIN_TX_GROUP_MESSAGE_TYPES,
2
3
  CHAIN_TX_MAX_MESSAGES,
3
- CHAIN_TX_MAX_TOKEN_LINES,
4
4
  CHAIN_TX_MESSAGE_TYPES,
5
- DENOM,
6
5
  operationJsonSchema,
7
6
  parseOperation,
8
7
  type ActionOutcomeV1,
@@ -10,6 +9,14 @@ import {
10
9
  type ActionReferenceV1,
11
10
  type ChainTxMessageType,
12
11
  } from '@ixo/action-protocol';
12
+ import {
13
+ AIMessage,
14
+ ToolMessage,
15
+ type BaseMessage,
16
+ } from '@langchain/core/messages';
17
+ import { tool } from '@langchain/core/tools';
18
+ import { convertToOpenAITool } from '@langchain/core/utils/function_calling';
19
+ import { createMiddleware, type AgentMiddleware } from 'langchain';
13
20
  import { z } from 'zod';
14
21
  import { quoteHubText } from '../../hub-actions/hub-text';
15
22
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
@@ -20,6 +27,21 @@ import type {
20
27
  RuntimeContext,
21
28
  } from '../../plugin-api/types';
22
29
  import { ixoAddressOf } from '../../core/user-identity';
30
+ import { toolNotRunMessage } from '../../core/tool-not-run';
31
+ import {
32
+ CHAIN_TX_CATALOG,
33
+ CHAIN_TX_GENERAL_RULES,
34
+ GROUP_ACTION_SCHEMA,
35
+ GROUP_ACTION_TYPES,
36
+ chainTxMessageInput,
37
+ hasUnsafeText,
38
+ } from './chain-tx-catalog';
39
+
40
+ export {
41
+ CHAIN_TX_CATALOG,
42
+ CHAIN_TX_GENERAL_RULES,
43
+ hasUnsafeText,
44
+ } from './chain-tx-catalog';
23
45
 
24
46
  const configSchema = z.object({
25
47
  HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
@@ -65,192 +87,7 @@ const statusSchema = z.strictObject({
65
87
  });
66
88
  const discoverSchema = z.strictObject({});
67
89
 
68
- /**
69
- * The messages of one chain transaction (`ixo.chain.tx.v1`), as the model
70
- * writes them. The shapes are the protocol's minus the owner's identity
71
- * (a claim's `agentDid`, an evaluation's `agentAddress`), which the runtime
72
- * fills, as it fills the chain id, network and signer; a bank step names a
73
- * display amount, which the owner object converts to base units from the
74
- * owner's token list. The protocol parses the whole operation again before
75
- * it reaches the Hub. The rules match Auth Hub's (ids, address length, DID
76
- * escapes, memo characters), so a message the Hub would refuse is refused
77
- * here, before any Hub call.
78
- */
79
- /** A 20-byte bech32 account address: `ixo1` and 38 characters (a 32-byte contract address is longer). */
80
- const IXO_ADDRESS = /^ixo1[023456789acdefghjklmnpqrstuvwxyz]{38}$/;
81
- const BASE_UNITS = /^[1-9][0-9]{0,77}$/;
82
- /** A DID; `%` only as a `%xx` escape. */
83
- const ANY_DID = /^did:[a-z0-9]+:(?:[A-Za-z0-9._:-]|%[0-9A-Fa-f]{2})+$/;
84
- /** Claim, collection, token and proof ids: no spaces, quotes or markup. */
85
- const ID_CHARS = /^[A-Za-z0-9:._/-]+$/;
86
- const ID_RULE = 'Only letters, digits and : . _ / - (copied exactly).';
87
- /**
88
- * Characters that would let text render as something else: C0 and C1
89
- * controls, zero-width and direction marks (U+200B–U+200F), line and
90
- * paragraph separators (U+2028–U+2029), bidi embeddings, overrides and
91
- * isolates (U+202A–U+202E, U+2066–U+2069), the Arabic letter mark (U+061C)
92
- * and the byte order mark (U+FEFF). Auth Hub refuses the same set.
93
- */
94
- export function hasUnsafeText(value: string): boolean {
95
- for (const char of value) {
96
- const code = char.codePointAt(0) ?? 0;
97
- if (
98
- code <= 0x1f ||
99
- (code >= 0x7f && code <= 0x9f) ||
100
- code === 0x061c ||
101
- (code >= 0x200b && code <= 0x200f) ||
102
- (code >= 0x2028 && code <= 0x202e) ||
103
- (code >= 0x2066 && code <= 0x2069) ||
104
- code === 0xfeff
105
- )
106
- return true;
107
- }
108
- return false;
109
- }
110
90
  const MEMO_BYTES = 256;
111
- const address = (what: string) =>
112
- z
113
- .string()
114
- .regex(
115
- IXO_ADDRESS,
116
- 'A 20-byte ixo1… account address (42 characters); contract addresses are refused.',
117
- )
118
- .describe(`${what} (an ixo1… account address).`);
119
- const id = (max: number) => z.string().min(1).max(max).regex(ID_CHARS, ID_RULE);
120
- const baseUnits = (what: string) =>
121
- z
122
- .string()
123
- .regex(BASE_UNITS, 'A whole number of base units above zero.')
124
- .describe(what);
125
- const claimId = id(256).describe('The claim id, exactly as stored.');
126
- const collectionId = z
127
- .string()
128
- .regex(/^(0|[1-9][0-9]{0,19})$/, 'Digits only, no leading zeros.')
129
- .describe('The claim collection id (digits).');
130
-
131
- /** A display amount: a plain decimal in the token's units, as the user said it. */
132
- const DISPLAY_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
133
-
134
- /**
135
- * One chain-transaction message type: the one-line `summary` the request
136
- * tool's description lists, the full `rules` describe_chain_messages
137
- * returns (with the fields of `schema`), and the strict `schema` the
138
- * request handler checks each message against. Adding a message type is
139
- * one entry here (once `@ixo/action-protocol` and Auth Hub support it).
140
- */
141
- interface ChainTxCatalogEntry {
142
- summary: string;
143
- rules: string;
144
- schema: z.ZodObject;
145
- }
146
-
147
- export const CHAIN_TX_CATALOG = {
148
- 'cosmos.bank.send': {
149
- summary:
150
- "send coins (IXO or another token the owner holds) from the owner's wallet.",
151
- rules: [
152
- "Sends coins from the owner's wallet to `toAddress`, alone (a single coin send) or as a step of a batch.",
153
- '`amount` is the amount as the user said it, in the token’s display units ("1.5" for 1.5 IXO): a plain decimal, never base units. The runtime looks the token up in the owner’s token list (hub_action_options `transfer.tokens`), converts the amount with the listed `decimals`, and refuses a token not in the list, more digits after the point than the token has, zero, and more than the listed balance (the bank steps of one token together).',
154
- '`denom` is optional and defaults to uixo (IXO, 6 decimals). For another token copy its `denom` exactly from `transfer.tokens` (never guess an `ibc/…` hash); name the full denom to the owner for anything but IXO, and say when the token is unverified (it lists 0 decimals: its amount is a whole number of base units).',
155
- ].join(' '),
156
- schema: z.strictObject({
157
- type: z.literal('cosmos.bank.send'),
158
- toAddress: address('The recipient'),
159
- amount: z
160
- .string()
161
- .max(80)
162
- .regex(
163
- DISPLAY_AMOUNT,
164
- 'A plain decimal amount in the token’s display units, such as 1.5 (never base units).',
165
- )
166
- .describe(
167
- 'The amount as the user said it, in the token’s display units (1.5 = 1.5 IXO).',
168
- ),
169
- denom: z
170
- .string()
171
- .regex(DENOM, 'A denom exactly as hub_action_options lists it.')
172
- .optional()
173
- .describe(
174
- 'The token’s denom, copied exactly from hub_action_options `transfer.tokens`; leave out for uixo (IXO).',
175
- ),
176
- }),
177
- },
178
- 'ixo.claims.submit': {
179
- summary:
180
- 'file a claim the owner has already created with a claim collection.',
181
- rules:
182
- 'Files a claim the owner has already created with a claim collection. `claimId` is the stored claim’s id, `collectionId` the collection, `adminAddress` the collection admin’s address. The runtime adds the owner’s DID as the filer. The collection admin must have authorized the owner to submit claims. Collections with member budgets cannot be filed into here (no member address is sent). Set `useIntent` only when the collection works with intents.',
183
- schema: z.strictObject({
184
- type: z.literal('ixo.claims.submit'),
185
- collectionId,
186
- claimId,
187
- adminAddress: address("The collection admin's address"),
188
- useIntent: z
189
- .boolean()
190
- .optional()
191
- .describe('true only when the collection pays claims through intents.'),
192
- }),
193
- },
194
- 'ixo.claims.evaluate': {
195
- summary:
196
- 'record the owner’s decision, as evaluator, on someone else’s claim (approve, reject, dispute, invalidate).',
197
- rules:
198
- 'Records the owner’s decision, as evaluator, on someone else’s claim (`claimId` in `collectionId`). `status` 1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate. It is recorded as the owner’s (the runtime adds their DID and address, never the claimant’s); `oracle` is the evaluator DID recorded on the evaluation (normally the owner’s DID, or the oracle entity DID the collection admin authorized); `adminAddress` is the collection admin’s address; `reason` (a number) and `verificationProof` are optional. The collection admin must have authorized the owner as an evaluator. An approval can pay the claimant.',
199
- schema: z.strictObject({
200
- type: z.literal('ixo.claims.evaluate'),
201
- claimId,
202
- collectionId,
203
- oracle: z
204
- .string()
205
- .max(200)
206
- .regex(ANY_DID, 'A DID (did:<method>:…; % only as %xx).')
207
- .describe('The evaluator DID recorded on the evaluation (a did:…).'),
208
- adminAddress: address("The collection admin's address"),
209
- status: z
210
- .literal([1, 2, 3, 4])
211
- .describe(
212
- '1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate.',
213
- ),
214
- reason: z
215
- .number()
216
- .int()
217
- .min(0)
218
- .max(4_294_967_295)
219
- .optional()
220
- .describe("The evaluator's reason code, when there is one."),
221
- verificationProof: id(300)
222
- .optional()
223
- .describe(
224
- 'The evaluation proof (a credential CID), when there is one.',
225
- ),
226
- }),
227
- },
228
- 'ixo.token.transfer': {
229
- summary:
230
- "move IXO impact tokens (1155-style token ids, whole amounts) from the owner's wallet.",
231
- rules: `Moves IXO impact tokens (1155-style: each line a token \`id\` of 32 hex characters and a whole \`amount\`, 1 to ${CHAIN_TX_MAX_TOKEN_LINES} lines) from the owner’s wallet to \`recipient\`. Impact tokens have no decimals: an amount is a whole number of tokens. All tokens in one message must come from one token contract. These are not the IXO coin: coins go through \`cosmos.bank.send\`.`,
232
- schema: z.strictObject({
233
- type: z.literal('ixo.token.transfer'),
234
- recipient: address('The recipient'),
235
- tokens: z
236
- .array(
237
- z.strictObject({
238
- id: id(128).describe(
239
- 'The impact token id (32 hex characters), exactly as listed.',
240
- ),
241
- amount: baseUnits('A whole number of tokens above zero.'),
242
- }),
243
- )
244
- .min(1)
245
- .max(CHAIN_TX_MAX_TOKEN_LINES),
246
- }),
247
- },
248
- } as const satisfies Record<ChainTxMessageType, ChainTxCatalogEntry>;
249
-
250
- /** What every message type shares, returned once by describe_chain_messages. */
251
- export const CHAIN_TX_GENERAL_RULES =
252
- 'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused). Ids (`claimId`, `collectionId`, token `id`, `verificationProof`) use only letters, digits and `: . _ / -`; copy them exactly and never invent them. Give only the fields listed for a type: the runtime adds the chain, the network, the signer and the owner’s own DID and address where a message needs them, and Auth Hub sets the fee. Up to 8 messages per transaction, run in order; all succeed or none do.';
253
-
254
91
  const memoSchema = z
255
92
  .string()
256
93
  .max(MEMO_BYTES)
@@ -266,20 +103,38 @@ const memoSchema = z
266
103
  /**
267
104
  * A catalogue field reduced to its basic type: no descriptions, patterns or
268
105
  * limits (those stay in describe_chain_messages and the handler's check).
106
+ * A union of message types (a new group proposal's `actions`) is given as
107
+ * `typeUnion` gives the messages themselves: fields only for the types in
108
+ * `described`.
269
109
  */
270
- export function fieldsOnly(schema: z.core.$ZodType): z.ZodType {
110
+ export function fieldsOnly(
111
+ schema: z.core.$ZodType,
112
+ described?: ReadonlySet<string>,
113
+ ): z.ZodType {
271
114
  if (schema instanceof z.ZodObject)
272
115
  return z.strictObject(
273
116
  Object.fromEntries(
274
117
  Object.entries(schema.shape).map(([key, field]) => [
275
118
  key,
276
- fieldsOnly(field),
119
+ fieldsOnly(field, described),
277
120
  ]),
278
121
  ),
279
122
  );
280
123
  if (schema instanceof z.ZodOptional)
281
- return fieldsOnly(schema.unwrap()).optional();
282
- if (schema instanceof z.ZodArray) return z.array(fieldsOnly(schema.element));
124
+ return fieldsOnly(schema.unwrap(), described).optional();
125
+ if (schema instanceof z.ZodNullable)
126
+ return fieldsOnly(schema.unwrap(), described).nullable();
127
+ if (schema instanceof z.ZodArray)
128
+ return z.array(fieldsOnly(schema.element, described));
129
+ if (schema instanceof z.ZodDiscriminatedUnion)
130
+ return typeUnion(
131
+ schema.options.map((option) => {
132
+ if (!(option instanceof z.ZodObject))
133
+ throw new Error('A message union holds a non-object branch');
134
+ return [messageTypeOf(option), option] as const;
135
+ }),
136
+ described,
137
+ );
283
138
  if (schema instanceof z.ZodString) return z.string();
284
139
  if (schema instanceof z.ZodNumber) return z.number();
285
140
  if (schema instanceof z.ZodBoolean) return z.boolean();
@@ -289,45 +144,97 @@ export function fieldsOnly(schema: z.core.$ZodType): z.ZodType {
289
144
  );
290
145
  }
291
146
 
147
+ /** The `type` a catalogue message schema is for (its `type` literal). */
148
+ function messageTypeOf(schema: z.ZodObject): string {
149
+ const type = schema.shape.type;
150
+ const [value] = type instanceof z.ZodLiteral ? [...type.values] : [];
151
+ if (typeof value !== 'string')
152
+ throw new Error('A catalogue message schema has no `type` literal');
153
+ return value;
154
+ }
155
+
292
156
  /**
293
157
  * One message type as the model is given it: its field names and basic
294
158
  * types, so a provider that holds a tool call to its schema sends every
295
159
  * field. Loose, so a field the type does not have reaches the handler,
296
160
  * which names it.
297
161
  */
298
- function messageFieldsOnly(schema: z.ZodObject) {
162
+ function messageFieldsOnly(
163
+ schema: z.ZodObject,
164
+ described?: ReadonlySet<string>,
165
+ ) {
299
166
  return z.looseObject(
300
167
  Object.fromEntries(
301
168
  Object.entries(schema.shape).map(([key, field]) => [
302
169
  key,
303
- fieldsOnly(field),
170
+ fieldsOnly(field, described),
304
171
  ]),
305
172
  ),
306
173
  );
307
174
  }
308
175
 
309
176
  /**
310
- * The schema the model is given: per type, its fields (messageFieldsOnly),
311
- * so a type adds its short field list and the rules are fetched with
312
- * describe_chain_messages when needed. Each message is then checked by the
177
+ * Messages as the model is given them: per type its fields
178
+ * (messageFieldsOnly) when the type was described in this session
179
+ * (`described`; absent: every type), and one open branch naming every
180
+ * other type (`type` one of them, any other field allowed). A union inside
181
+ * a message (a new group proposal's `actions`) takes `nested` instead.
182
+ */
183
+ function typeUnion(
184
+ schemas: readonly (readonly [string, z.ZodObject])[],
185
+ described?: ReadonlySet<string>,
186
+ nested: ReadonlySet<string> | undefined = described,
187
+ ) {
188
+ const withFields = schemas
189
+ .filter(([type]) => !described || described.has(type))
190
+ .map(([, schema]) => messageFieldsOnly(schema, nested));
191
+ const [firstUnread, ...unread] = schemas
192
+ .filter(([type]) => described && !described.has(type))
193
+ .map(([type]) => type);
194
+ const branches =
195
+ firstUnread === undefined
196
+ ? withFields
197
+ : [
198
+ ...withFields,
199
+ z.looseObject({ type: z.literal([firstUnread, ...unread]) }),
200
+ ];
201
+ const [first, ...rest] = branches;
202
+ if (!first) throw new Error('The chain transaction catalogue is empty');
203
+ return z.discriminatedUnion('type', [first, ...rest]);
204
+ }
205
+
206
+ /**
207
+ * The schema the model is given: the messages as `typeUnion` gives them,
208
+ * a new group proposal's actions the same way. A type the model has not
209
+ * read about costs its name only; the request handler refuses it until it
210
+ * is described, and the next model call is given its fields
211
+ * (`chainTxFieldsMiddleware`). Without a session store (`described`
212
+ * absent) every message type carries its fields, but a new proposal's
213
+ * actions are named only: the 14 action types' fields would repeat their
214
+ * top-level branches (26 228 characters instead of 20 950); the
215
+ * handler names any field it misses. Each message is checked by the
313
216
  * handler against its full catalogue schema.
314
217
  */
315
218
  export function chainTransactionSchemaFor(
316
219
  catalog: Readonly<Record<string, { schema: z.ZodObject }>>,
220
+ described?: ReadonlySet<string>,
317
221
  ) {
318
- const [first, ...rest] = Object.values(catalog).map((entry) =>
319
- messageFieldsOnly(entry.schema),
320
- );
321
- if (!first) throw new Error('The chain transaction catalogue is empty');
322
222
  return z.strictObject({
323
223
  messages: z
324
- .array(z.discriminatedUnion('type', [first, ...rest]))
224
+ .array(
225
+ typeUnion(
226
+ Object.entries(catalog).map(
227
+ ([type, entry]) => [type, entry.schema] as const,
228
+ ),
229
+ described,
230
+ described ?? new Set<string>(),
231
+ ),
232
+ )
325
233
  .min(1)
326
234
  .max(CHAIN_TX_MAX_MESSAGES),
327
235
  memo: memoSchema,
328
236
  });
329
237
  }
330
- const chainTransactionSchema = chainTransactionSchemaFor(CHAIN_TX_CATALOG);
331
238
 
332
239
  /**
333
240
  * The request tool's description: what it does, the discovery step, one
@@ -342,9 +249,285 @@ export function chainTransactionDescription(
342
249
  ...Object.entries(catalog).map(
343
250
  ([type, entry]) => `- \`${type}\`: ${entry.summary}`,
344
251
  ),
345
- 'Up to 8 messages run in order as one transaction: all succeed or none do. Never invent ids or addresses (ask the owner or use other tools); coin amounts are display units ("1.5"), never base units. When the result is unavailable, tell the owner its reason in plain words; when it says retrying or changing the fields will not help, do not retry.',
252
+ 'Up to 8 messages run in order as one transaction: all succeed or none do. Never invent ids or addresses (ask the owner or use other tools); coin and stake amounts are display units ("1.5"), never base units. Tell the owner what will happen in plain everyday words, never message type names or chain terms. When the result is unavailable, tell the owner its reason in plain words; when it says retrying or changing the fields will not help, do not retry.',
346
253
  ].join('\n');
347
254
  }
255
+
256
+ /** The catalogue entries of `types`, in catalogue order. */
257
+ function catalogOf(types: readonly ChainTxMessageType[]) {
258
+ return Object.fromEntries(
259
+ CHAIN_TX_MESSAGE_TYPES.filter((type) => types.includes(type)).map(
260
+ (type) => [type, CHAIN_TX_CATALOG[type]],
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * request_chain_transaction for the message types this account may use
267
+ * (`availableChainTxTypes`): the model is given their summaries, and the
268
+ * fields of those `described` in this session (null: of every one), so it
269
+ * neither sees nor pays for a type Auth Hub would refuse or one it has not
270
+ * read about, and its arguments are checked against the same schema (a
271
+ * message of another type is refused before any Hub call). Null when the
272
+ * account may use none (describe_chain_messages then answers
273
+ * `availableTypes: []`). Built once per (types, described) pair.
274
+ */
275
+ export function chainTransactionTool(
276
+ types: readonly ChainTxMessageType[],
277
+ described: readonly ChainTxMessageType[] | null = null,
278
+ ): PluginTool | null {
279
+ const catalog = catalogOf(types);
280
+ if (!Object.keys(catalog).length) return null;
281
+ const fields = described
282
+ ? new Set(
283
+ Object.keys(catalog).filter((type) =>
284
+ described.some((d) => d === type),
285
+ ),
286
+ )
287
+ : undefined;
288
+ const key = `${Object.keys(catalog).join(',')}|${fields ? [...fields].join(',') : '*'}`;
289
+ const cached = chainTransactionTools.get(key);
290
+ if (cached) {
291
+ // Most recently used last, so the bound below drops the oldest.
292
+ chainTransactionTools.delete(key);
293
+ chainTransactionTools.set(key, cached);
294
+ return cached;
295
+ }
296
+ const schema = chainTransactionSchemaFor(catalog, fields);
297
+ const tool: PluginTool = {
298
+ name: 'request_chain_transaction',
299
+ description: chainTransactionDescription(catalog),
300
+ schema,
301
+ effect: 'write',
302
+ handler: (input, ctx) => requestChainTransaction(schema, input, ctx),
303
+ };
304
+ // Type lists come from Auth Hub capabilities (one or two per deployment)
305
+ // and described sets from sessions; the bound keeps the map small, the
306
+ // least recently used pair is built again when needed.
307
+ if (chainTransactionTools.size >= CHAIN_TRANSACTION_TOOL_CACHE)
308
+ chainTransactionTools.delete(chainTransactionTools.keys().next().value!);
309
+ chainTransactionTools.set(key, tool);
310
+ return tool;
311
+ }
312
+ const CHAIN_TRANSACTION_TOOL_CACHE = 32;
313
+
314
+ /**
315
+ * The message types described in a conversation (`describe_chain_messages`),
316
+ * per session id in the owner's own key-value rows, each with the run that
317
+ * first described it: durable across restarts of the owner object, kept
318
+ * while used (`DESCRIBED_IDLE_TTL_MS`), and at most `DESCRIBED_MAX_SESSIONS`
319
+ * sessions per owner. A new session starts with none.
320
+ */
321
+ const DESCRIBED_NAMESPACE = 'hub-actions/chain-tx-described';
322
+ const DESCRIBED_IDLE_TTL_MS = 7 * 24 * 60 * 60_000;
323
+ const DESCRIBED_MAX_SESSIONS = 200;
324
+
325
+ /** The stored `{type: runId}` record of a session (anything else: none). */
326
+ function describedRecord(
327
+ value: unknown,
328
+ ): Partial<Record<ChainTxMessageType, string>> {
329
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
330
+ const record: Partial<Record<ChainTxMessageType, string>> = {};
331
+ for (const type of CHAIN_TX_MESSAGE_TYPES) {
332
+ const run: unknown = Reflect.get(value, type);
333
+ if (typeof run === 'string') record[type] = run;
334
+ }
335
+ return record;
336
+ }
337
+
338
+ /**
339
+ * The types whose rules a `describe_chain_messages` call in `messages` read:
340
+ * its arguments (on the AI message that made the call), counted only when
341
+ * its result is there too and is not an error.
342
+ */
343
+ function describedInHistory(
344
+ messages: readonly BaseMessage[],
345
+ ): Set<ChainTxMessageType> {
346
+ const calls = new Map<string, unknown>();
347
+ for (const message of messages)
348
+ if (AIMessage.isInstance(message))
349
+ for (const call of message.tool_calls ?? [])
350
+ if (call.name === 'describe_chain_messages' && call.id)
351
+ calls.set(call.id, call.args);
352
+ const described = new Set<ChainTxMessageType>();
353
+ for (const message of messages) {
354
+ if (
355
+ !ToolMessage.isInstance(message) ||
356
+ message.name !== 'describe_chain_messages' ||
357
+ message.status === 'error'
358
+ )
359
+ continue;
360
+ const args = describeChainMessagesSchema.safeParse(
361
+ calls.get(message.tool_call_id),
362
+ );
363
+ if (args.success) for (const type of args.data.types) described.add(type);
364
+ }
365
+ return described;
366
+ }
367
+
368
+ /** This turn in the session: `${sessionId}|${runId}`. */
369
+ const runKeyOf = (ctx: RuntimeContext) =>
370
+ `${ctx.session.id}|${ctx.session.runId ?? ''}`;
371
+
372
+ /**
373
+ * Turns whose described types could not be stored: they fall back to every
374
+ * type's fields and no refusal (a store failure must not make chain cards
375
+ * unusable). Bounded; the oldest turn is dropped first.
376
+ */
377
+ const UNSTORED_TURNS_MAX = 256;
378
+ const unstoredTurns = new Set<string>();
379
+
380
+ /**
381
+ * The types this call may use (and whose fields the model is given), by
382
+ * one rule: described in the history this call sees (`messages`), or
383
+ * stored by an earlier run of the session. A type the current run stored
384
+ * counts only once its `describe_chain_messages` result is in that history,
385
+ * so a describe made in the same model response as the request (a parallel
386
+ * call) never lets the request through, whichever finishes first. Null when
387
+ * the session cannot remember (no store or session, or the store failed):
388
+ * every type then carries its fields and none is refused.
389
+ */
390
+ async function describedChainTxTypes(
391
+ ctx: RuntimeContext,
392
+ messages: readonly BaseMessage[],
393
+ ): Promise<ChainTxMessageType[] | null> {
394
+ if (!ctx.kv || !ctx.session.id || unstoredTurns.has(runKeyOf(ctx)))
395
+ return null;
396
+ let stored: Partial<Record<ChainTxMessageType, string>>;
397
+ try {
398
+ stored = describedRecord(
399
+ await ctx.kv.get(DESCRIBED_NAMESPACE, ctx.session.id),
400
+ );
401
+ } catch (error) {
402
+ ctx.logger.warn(
403
+ `[hub-actions] described chain message types unreadable; every type keeps its fields: ${error instanceof Error ? error.message : String(error)}`,
404
+ );
405
+ return null;
406
+ }
407
+ const inHistory = describedInHistory(messages);
408
+ const runId = ctx.session.runId ?? '';
409
+ return CHAIN_TX_MESSAGE_TYPES.filter(
410
+ (type) =>
411
+ inHistory.has(type) ||
412
+ (stored[type] !== undefined && stored[type] !== runId),
413
+ );
414
+ }
415
+
416
+ /** Records the described types; a type keeps the run that first described it. */
417
+ async function rememberDescribed(
418
+ ctx: RuntimeContext,
419
+ types: readonly ChainTxMessageType[],
420
+ ): Promise<void> {
421
+ if (!ctx.kv || !ctx.session.id) return;
422
+ const runId = ctx.session.runId ?? '';
423
+ try {
424
+ await ctx.kv.update(
425
+ DESCRIBED_NAMESPACE,
426
+ ctx.session.id,
427
+ (current) => ({
428
+ ...Object.fromEntries(types.map((type) => [type, runId])),
429
+ ...describedRecord(current),
430
+ }),
431
+ { idleTtlMs: DESCRIBED_IDLE_TTL_MS, maxEntries: DESCRIBED_MAX_SESSIONS },
432
+ );
433
+ } catch (error) {
434
+ ctx.logger.warn(
435
+ `[hub-actions] described chain message types not stored; this turn gives every type's fields: ${error instanceof Error ? error.message : String(error)}`,
436
+ );
437
+ if (unstoredTurns.size >= UNSTORED_TURNS_MAX)
438
+ unstoredTurns.delete(unstoredTurns.values().next().value!);
439
+ unstoredTurns.add(runKeyOf(ctx));
440
+ }
441
+ }
442
+
443
+ /**
444
+ * The account's chain types for a turn, read once (by the request tool and
445
+ * then reused by `chainTxFieldsMiddleware` for every model call).
446
+ */
447
+ const turnChainTxTypes = new WeakMap<
448
+ RuntimeContext,
449
+ Promise<readonly ChainTxMessageType[]>
450
+ >();
451
+ function chainTxTypesForTurn(
452
+ ctx: RuntimeContext,
453
+ ): Promise<readonly ChainTxMessageType[]> {
454
+ let types = turnChainTxTypes.get(ctx);
455
+ if (!types) {
456
+ types = availableChainTxTypes(ctx);
457
+ turnChainTxTypes.set(ctx, types);
458
+ }
459
+ return types;
460
+ }
461
+
462
+ /** The function definition of a request tool, per description, built once. */
463
+ const toolDefinitions = new WeakMap<
464
+ PluginTool,
465
+ Map<string, Record<string, unknown>>
466
+ >();
467
+ function definitionOf(
468
+ pluginTool: PluginTool,
469
+ description: string,
470
+ ): Record<string, unknown> {
471
+ let byDescription = toolDefinitions.get(pluginTool);
472
+ if (!byDescription) {
473
+ byDescription = new Map();
474
+ toolDefinitions.set(pluginTool, byDescription);
475
+ }
476
+ let definition = byDescription.get(description);
477
+ if (!definition) {
478
+ const { type, function: fn } = convertToOpenAITool(
479
+ tool(async () => '', {
480
+ name: pluginTool.name,
481
+ description,
482
+ schema: pluginTool.schema,
483
+ }),
484
+ );
485
+ // A plain object (a server-tool definition to LangChain), not a tool
486
+ // instance: LangChain refuses a new instance of a registered tool.
487
+ definition = { type, function: fn };
488
+ byDescription.set(description, definition);
489
+ }
490
+ return definition;
491
+ }
492
+
493
+ /**
494
+ * The per-turn tool is built before the turn's first model call, so a type
495
+ * described during the turn would reach the next model call without its
496
+ * fields, and a provider drops fields its schema does not declare (the
497
+ * 0.29.0 failure). Before each model call this gives the model the request
498
+ * tool for what is described by then (`describedChainTxTypes` over the
499
+ * call's messages), as its function definition with the registered tool's
500
+ * name and description; the registered tool, whose schema accepts the
501
+ * fields of every type, still runs the call. A tool the capability gate
502
+ * hid is not in the request, so nothing is added; a failure to read the
503
+ * store leaves the request as it is.
504
+ */
505
+ function chainTxFieldsMiddleware(ctx: RuntimeContext) {
506
+ return createMiddleware({
507
+ name: 'HubActionsChainTxFieldsMiddleware',
508
+ wrapModelCall: async (request, handler) => {
509
+ const index = request.tools.findIndex(
510
+ (entry) => entry.name === 'request_chain_transaction',
511
+ );
512
+ const registered = request.tools[index];
513
+ if (!registered || typeof registered.description !== 'string')
514
+ return handler(request);
515
+ const described = await describedChainTxTypes(ctx, request.messages);
516
+ if (!described) return handler(request);
517
+ const current = chainTransactionTool(
518
+ await chainTxTypesForTurn(ctx),
519
+ described,
520
+ );
521
+ if (!current) return handler(request);
522
+ const tools = [...request.tools];
523
+ tools[index] = definitionOf(current, registered.description);
524
+ return handler({ ...request, tools });
525
+ },
526
+ });
527
+ }
528
+ const chainTransactionTools = new Map<string, PluginTool>();
529
+
530
+ /** The request tool as the whole catalogue gives it (no account capabilities). */
348
531
  export const CHAIN_TRANSACTION_TOOL_DESCRIPTION =
349
532
  chainTransactionDescription(CHAIN_TX_CATALOG);
350
533
 
@@ -360,7 +543,7 @@ const describeChainMessagesSchema = z.strictObject({
360
543
  });
361
544
 
362
545
  export const DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION =
363
- 'Read the full rules of chain-transaction message types before using them in request_chain_transaction: every field with its meaning, format and limits, what the runtime fills, and chain caveats. Ask only for the types you need. Also returns `availableTypes`: the types this account may use.';
546
+ 'Read the full rules of chain-transaction message types before using them in request_chain_transaction: every field with its meaning, format and limits, what the runtime fills, and chain caveats. Ask only for the types you need: request_chain_transaction takes only types described in this conversation, and gives their fields from the next call on. Also returns `availableTypes`: the types this account may use.';
364
547
 
365
548
  export type HubActionRequestResult =
366
549
  | {
@@ -510,11 +693,36 @@ async function requestHubAction(
510
693
  }
511
694
 
512
695
  async function requestChainTransaction(
696
+ schema: ReturnType<typeof chainTransactionSchemaFor>,
513
697
  input: unknown,
514
698
  ctx: RuntimeContext,
515
- ): Promise<HubActionRequestResult> {
516
- const parsed = chainTransactionSchema.safeParse(input);
699
+ ): Promise<HubActionRequestResult | ToolMessage> {
700
+ const parsed = schema.safeParse(input);
517
701
  if (!parsed.success) return chainTxShapeRefusal(issuesOf(parsed.error));
702
+ const described = await describedChainTxTypes(ctx, ctx.history.messages);
703
+ if (described) {
704
+ const unread = [
705
+ ...new Set(
706
+ parsed.data.messages
707
+ .flatMap((message) => [
708
+ chainTxTypeOf(message),
709
+ ...groupActionTypesOf(message),
710
+ ])
711
+ .filter(
712
+ (type): type is ChainTxMessageType =>
713
+ type !== null && !described.includes(type),
714
+ ),
715
+ ),
716
+ ];
717
+ if (unread.length)
718
+ // Not run, and not a failure: the repetition guard lets the same
719
+ // call through once the rules are read.
720
+ return toolNotRunMessage({
721
+ toolCallId: ctx.toolCallId ?? '',
722
+ name: 'request_chain_transaction',
723
+ content: `For you, not the person: nothing was sent and nothing is wrong. Before a card with ${unread.map((type) => `\`${type}\``).join(', ')}, read the rules: call describe_chain_messages with types ${JSON.stringify(unread)}, then call request_chain_transaction again with exactly the fields it lists. Do not mention this step to the person.`,
724
+ });
725
+ }
518
726
  const checked = strictChainTxMessages(parsed.data.messages);
519
727
  if ('issues' in checked) return chainTxShapeRefusal(checked.issues);
520
728
  if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
@@ -569,6 +777,19 @@ function strictChainTxMessages(
569
777
  ): { messages: ChainTxMessageInput[] } | { issues: string[] } {
570
778
  const checked: ChainTxMessageInput[] = [];
571
779
  const issues: string[] = [];
780
+ // A group step is a chain of reads in Auth Hub and names a new
781
+ // proposal's number on its receipt: it goes alone on its card (the
782
+ // protocol refuses `messages` otherwise).
783
+ if (
784
+ messages.length > 1 &&
785
+ messages.some((message) => {
786
+ const type = chainTxTypeOf(message);
787
+ return type !== null && GROUP_STEP_TYPES.includes(type);
788
+ })
789
+ )
790
+ issues.push(
791
+ 'messages: a group step (a group vote, carrying out or closing a group proposal, a new group proposal) goes alone on its card; prepare the other messages on another card',
792
+ );
572
793
  messages.forEach((message, index) => {
573
794
  const type = chainTxTypeOf(message);
574
795
  if (!type) {
@@ -576,7 +797,7 @@ function strictChainTxMessages(
576
797
  return;
577
798
  }
578
799
  const result = CHAIN_TX_CATALOG[type].schema.safeParse(message);
579
- if (result.success) checked.push(result.data);
800
+ if (result.success) checked.push(chainTxMessageInput(result.data));
580
801
  else issues.push(...issuesOf(result.error, ['messages', index]));
581
802
  });
582
803
  return issues.length ? { issues } : { messages: checked };
@@ -589,6 +810,31 @@ function chainTxTypeOf(message: unknown): ChainTxMessageType | null {
589
810
  return CHAIN_TX_MESSAGE_TYPES.find((known) => known === type) ?? null;
590
811
  }
591
812
 
813
+ const GROUP_STEP_TYPES: readonly ChainTxMessageType[] =
814
+ CHAIN_TX_GROUP_MESSAGE_TYPES;
815
+
816
+ /**
817
+ * The action types a new group proposal holds (each a type a group may
818
+ * do): their rules are read like a top-level message's before the card.
819
+ * Anything else is left to the message's own check.
820
+ */
821
+ function groupActionTypesOf(message: unknown): ChainTxMessageType[] {
822
+ if (
823
+ chainTxTypeOf(message) !== 'daodao.proposal.create' ||
824
+ typeof message !== 'object' ||
825
+ message === null ||
826
+ !('actions' in message) ||
827
+ !Array.isArray(message.actions)
828
+ )
829
+ return [];
830
+ return message.actions
831
+ .map(chainTxTypeOf)
832
+ .filter(
833
+ (type): type is ChainTxMessageType =>
834
+ type !== null && GROUP_ACTION_TYPES.some((allowed) => allowed === type),
835
+ );
836
+ }
837
+
592
838
  function chainTxShapeRefusal(
593
839
  issues: readonly string[],
594
840
  ): HubActionRequestResult {
@@ -634,9 +880,13 @@ function issuesOf(
634
880
 
635
881
  /**
636
882
  * The catalogue types this account may use: the Hub capabilities'
637
- * `chainTx.messageTypes` (none when chain transactions are off for the
638
- * account), or the whole catalogue when the capabilities cannot be read
639
- * (not a channel turn, or the Hub did not answer).
883
+ * `chainTx.messageTypes`, which follow the protocol level the Hub speaks
884
+ * with this runtime (level 5: the four 0.6.0 types; 6: 33; 7: all 37, the
885
+ * four group steps included). None when chain transactions cannot be used
886
+ * here: not a channel turn, Hub actions off, chain transactions off for the
887
+ * account, or capabilities that cannot be read (the owner object answers its
888
+ * last good capabilities while a read fails, and does not retry a failed
889
+ * read for 30 s, so this never holds a turn for long).
640
890
  */
641
891
  async function availableChainTxTypes(
642
892
  ctx: RuntimeContext,
@@ -647,21 +897,34 @@ async function availableChainTxTypes(
647
897
  !ctx.session.channel ||
648
898
  !ctx.hubActions
649
899
  )
650
- return CHAIN_TX_MESSAGE_TYPES;
900
+ return [];
651
901
  let chainTx;
652
902
  try {
653
- chainTx = (await ctx.hubActions.capabilities()).chainTx;
903
+ chainTx = (await ctx.hubActions.capabilities({ turn: true })).chainTx;
654
904
  } catch (error) {
655
905
  ctx.logger.warn(
656
- `[hub-actions] capabilities unavailable for describe_chain_messages: ${error instanceof Error ? error.message : String(error)}`,
906
+ `[hub-actions] capabilities unavailable, no chain transaction types this turn: ${error instanceof Error ? error.message : String(error)}`,
657
907
  );
658
- return CHAIN_TX_MESSAGE_TYPES;
908
+ return [];
659
909
  }
660
910
  return CHAIN_TX_MESSAGE_TYPES.filter((type) =>
661
911
  chainTx?.messageTypes.includes(type),
662
912
  );
663
913
  }
664
914
 
915
+ /**
916
+ * A new group proposal's action as describe_chain_messages lists it: the
917
+ * types it may be, each with its own fields (described on its own), so one
918
+ * description does not repeat fourteen others.
919
+ */
920
+ const GROUP_ACTION_FIELDS = {
921
+ type: 'object',
922
+ description:
923
+ 'One message of the types listed in `type`, with exactly that type’s fields (describe_chain_messages that type); the group’s own account is its actor.',
924
+ properties: { type: { type: 'string', enum: [...GROUP_ACTION_TYPES] } },
925
+ required: ['type'],
926
+ };
927
+
665
928
  /** Each type's fields as JSON Schema, built once. */
666
929
  const catalogueFields = new Map<ChainTxMessageType, unknown>();
667
930
  function fieldsOf(type: ChainTxMessageType): unknown {
@@ -669,6 +932,14 @@ function fieldsOf(type: ChainTxMessageType): unknown {
669
932
  if (fields === undefined) {
670
933
  const { properties, required } = z.toJSONSchema(
671
934
  CHAIN_TX_CATALOG[type].schema,
935
+ {
936
+ override: ({ zodSchema, jsonSchema }) => {
937
+ if (zodSchema !== GROUP_ACTION_SCHEMA) return;
938
+ for (const key of Object.keys(jsonSchema))
939
+ Reflect.deleteProperty(jsonSchema, key);
940
+ Object.assign(jsonSchema, GROUP_ACTION_FIELDS);
941
+ },
942
+ },
672
943
  );
673
944
  fields = { properties, required };
674
945
  catalogueFields.set(type, fields);
@@ -686,6 +957,7 @@ async function describeChainMessages(
686
957
  status: 'unavailable',
687
958
  reason: `Name 1 to ${CHAIN_TX_MAX_MESSAGES} different message types, each one of: ${CHAIN_TX_MESSAGE_TYPES.join(', ')}.`,
688
959
  };
960
+ await rememberDescribed(ctx, parsed.data.types);
689
961
  return {
690
962
  status: 'known',
691
963
  general: CHAIN_TX_GENERAL_RULES,
@@ -750,6 +1022,55 @@ export function modelOutcome(
750
1022
  return { ...outcome, receipt: modelReceipt(outcome.receipt) };
751
1023
  }
752
1024
 
1025
+ /**
1026
+ * What a `cancelled` outcome means. Auth Hub withdraws a card when the
1027
+ * person cancels it, and also on its own: a chain transaction whose details
1028
+ * changed on chain after the review (`409 review_changed`, reason
1029
+ * `chain_changed`, shown on the review page only). The oracle gets
1030
+ * `cancelled` either way, so the model must not say who withdrew it.
1031
+ */
1032
+ export const CANCELLED_MEANING =
1033
+ 'Withdrawn before anything was sent: nothing happened on chain. The person may have cancelled it, or Auth Hub withdrew it because something it depended on changed after the review; do not say the person cancelled it. If they still want it, offer to prepare a fresh one.';
1034
+
1035
+ /**
1036
+ * A carried-out group proposal whose actions failed: the transaction
1037
+ * succeeds (code 0) even then, and the network marks the proposal failed
1038
+ * for good, so the receipt's `groupActions` is the only sign.
1039
+ */
1040
+ export const GROUP_ACTIONS_FAILED_MEANING =
1041
+ 'The transaction went through, but the proposal’s actions failed: nothing in the proposal happened, the network has marked it failed for good and it can’t be carried out again. The network fee was used unless a fee grant covered it. Never say it is done; tell the person plainly.';
1042
+
1043
+ /** A carried-out group proposal whose transaction did not show whether its actions ran. */
1044
+ export const GROUP_ACTIONS_UNKNOWN_MEANING =
1045
+ 'The transaction went through, but Auth Hub could not tell from it whether the proposal’s actions happened. Check the proposal’s status with chain_lookup dao_proposals (executed: they happened; execution_failed: they did not) before saying anything about it; never say it is done until then.';
1046
+
1047
+ /** A new group proposal whose number Auth Hub could not read from the transaction. */
1048
+ export const PROPOSAL_NUMBER_UNKNOWN_MEANING =
1049
+ 'The new proposal was made, but Auth Hub could not read its number from the transaction. Find the new proposal with chain_lookup dao_proposals (the group’s newest) before naming it; never guess its number.';
1050
+
1051
+ /**
1052
+ * What an outcome means beyond its fields, for the model: a withdrawn card,
1053
+ * a carried-out proposal whose actions failed or may have, a new proposal
1054
+ * without its number. Undefined for anything else.
1055
+ */
1056
+ export function outcomeMeaning(
1057
+ outcome: ActionOutcomeV1 | null,
1058
+ ): string | undefined {
1059
+ if (!outcome) return undefined;
1060
+ if (outcome.status === 'cancelled') return CANCELLED_MEANING;
1061
+ const receipt = outcome.receipt;
1062
+ if (receipt?.kind !== 'ixo.chain.tx.v1') return undefined;
1063
+ for (const step of receipt.steps) {
1064
+ if (step.type === 'daodao.proposal.execute') {
1065
+ if (step.groupActions === 'failed') return GROUP_ACTIONS_FAILED_MEANING;
1066
+ if (step.groupActions === 'unknown') return GROUP_ACTIONS_UNKNOWN_MEANING;
1067
+ }
1068
+ if (step.type === 'daodao.proposal.create' && step.proposalId === null)
1069
+ return PROPOSAL_NUMBER_UNKNOWN_MEANING;
1070
+ }
1071
+ return undefined;
1072
+ }
1073
+
753
1074
  async function hubActionStatus(
754
1075
  input: unknown,
755
1076
  ctx: RuntimeContext,
@@ -771,12 +1092,14 @@ async function hubActionStatus(
771
1092
  status: 'unavailable',
772
1093
  reason: 'No Hub action with this id was prepared for this owner.',
773
1094
  };
1095
+ const meaning = outcomeMeaning(read.outcome);
774
1096
  return {
775
1097
  status: 'known',
776
1098
  reviewStatus: read.reviewStatus,
777
1099
  operationStatus: read.operationStatus,
778
1100
  outcome: modelOutcome(read.outcome),
779
1101
  ...(read.summary ? { summary: read.summary } : {}),
1102
+ ...(meaning ? { meaning } : {}),
780
1103
  };
781
1104
  } catch (error) {
782
1105
  // An Auth Hub that must be updated first: plain words, not a tool error.
@@ -843,6 +1166,9 @@ const manifest: PluginManifest = {
843
1166
  whenToUse: [
844
1167
  'The user requests a registered form submission or a coin send.',
845
1168
  'The user asks to file or evaluate a claim, or to send coins or impact tokens on chain.',
1169
+ 'The user asks to stake, unstake or move IXO, claim staking rewards, vote on a network proposal or retire impact tokens.',
1170
+ 'The user asks to reserve or dispute a claim, create or change a claim collection, give or remove a permission, create or update an entity, or change the links of an identity.',
1171
+ 'The user asks to vote on a proposal of a group (DAO) they belong to, carry out or close a group proposal, or put a new proposal to a group.',
846
1172
  'The user asks to review an exact private resource or to sign a statement.',
847
1173
  'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
848
1174
  'The user asks for document access or to save a private note.',
@@ -871,6 +1197,23 @@ export class HubActionsPlugin extends OraclePlugin {
871
1197
  env.AUTH_HUB_CHANNEL_SERVICE_KEY.length > 0
872
1198
  );
873
1199
  }
1200
+ /**
1201
+ * request_chain_transaction is built per turn: its schema names only the
1202
+ * message types this account may use (Auth Hub capabilities, cached by
1203
+ * the owner object), so 37 types do not reach a model that may use 4.
1204
+ */
1205
+ override async getRequestTools(ctx: RuntimeContext): Promise<PluginTool[]> {
1206
+ const [available, described] = await Promise.all([
1207
+ chainTxTypesForTurn(ctx),
1208
+ describedChainTxTypes(ctx, ctx.history.messages),
1209
+ ]);
1210
+ const tool = chainTransactionTool(available, described);
1211
+ return tool ? [tool] : [];
1212
+ }
1213
+ /** Gives each model call the request tool with the fields described so far in the turn. */
1214
+ override getRequestMiddlewares(ctx: RuntimeContext): AgentMiddleware[] {
1215
+ return [chainTxFieldsMiddleware(ctx)];
1216
+ }
874
1217
  override getTools(): PluginTool[] {
875
1218
  return [
876
1219
  {
@@ -904,17 +1247,10 @@ export class HubActionsPlugin extends OraclePlugin {
904
1247
  effect: 'read',
905
1248
  handler: describeChainMessages,
906
1249
  },
907
- {
908
- name: 'request_chain_transaction',
909
- description: CHAIN_TRANSACTION_TOOL_DESCRIPTION,
910
- schema: chainTransactionSchema,
911
- effect: 'write',
912
- handler: requestChainTransaction,
913
- },
914
1250
  {
915
1251
  name: 'hub_action_status',
916
1252
  description:
917
- 'Read the verified status of a Hub action already prepared for this owner. Never executes.',
1253
+ 'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it.',
918
1254
  schema: statusSchema,
919
1255
  effect: 'read',
920
1256
  handler: hubActionStatus,