@ixo/oracle-runtime-workers 0.41.2 → 0.42.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.
@@ -9,24 +9,87 @@ import type { ServicePodBlueprint } from './blueprint-types';
9
9
  */
10
10
  export interface PreparedPodBatch {
11
11
  /**
12
- * The messages, in execution order: `MsgCreateEntity` first, then the
13
- * claim collection(s), entity accounts and grants (`POD_BATCH_TYPE_URLS`).
14
- * Each is `{ typeUrl, value }` in proto-JSON (camelCase fields, integer
15
- * amounts as strings, bytes as base64) with exactly its catalog fields.
12
+ * The messages, in execution order: `MsgCreateEntity` first, then only
13
+ * `POD_BATCH_TYPE_URLS` (the bundled gateway sends `MsgCreateEntity`
14
+ * alone). Each is `{ typeUrl, value }` in proto-JSON (camelCase fields,
15
+ * integer amounts as strings, bytes as base64, timestamps as RFC 3339
16
+ * strings) with exactly its catalog fields.
16
17
  */
17
18
  messages: ITrxMsg[];
18
19
  /** Human-readable summary of what the batch creates (at most 1000 characters). */
19
20
  summary: string;
20
21
  /** Estimated cost, when the gateway can compute it. */
21
22
  estimatedCost?: string;
23
+ /**
24
+ * Public documents the messages name (by URL and SHA-256 proof) that are
25
+ * not published yet: `publishDocuments` publishes them once the user
26
+ * approved, before the wallet is asked to sign. Empty when every document
27
+ * the messages name is already public.
28
+ */
29
+ documents: PendingDocument[];
30
+ }
31
+
32
+ /**
33
+ * A public document a batch names (the POD's profile, its domain card),
34
+ * reserved on the oracle's homeserver when the batch is prepared and
35
+ * uploaded only after the user approved it.
36
+ */
37
+ export interface PendingDocument {
38
+ /** The reserved media it is uploaded as: `mxc://<server>/<media id>`. */
39
+ mxc: string;
40
+ /** Its media type, e.g. `application/ld+json`. */
41
+ contentType: string;
42
+ /** The file name the homeserver records. */
43
+ filename: string;
44
+ /** The exact text: the message carries the SHA-256 of these bytes as its proof. */
45
+ text: string;
46
+ }
47
+
48
+ /**
49
+ * An expected refusal (a missing identity, a homeserver that is down), which
50
+ * the tools report to the model as a result instead of throwing.
51
+ */
52
+ export interface GatewayRefusal {
53
+ ok: false;
54
+ message: string;
55
+ /** Whether the same call can succeed later unchanged (something was down). */
56
+ retryable: boolean;
57
+ }
58
+
59
+ /** A gateway answer: the value, or why there is none. */
60
+ export type GatewayResult<T extends object> =
61
+ | ({ ok: true } & T)
62
+ | GatewayRefusal;
63
+
64
+ /**
65
+ * What a broadcast transaction says about the POD. `pending`: not on chain
66
+ * (or not indexed) within the lookup's time, ask again later with the same
67
+ * hash; `failed`: it failed, or it is not the POD this conversation sent, so
68
+ * asking again cannot help; `unavailable`: the chain could not be read.
69
+ */
70
+ export type PodConfirmation =
71
+ | { status: 'created'; podDid: string; summary: string }
72
+ | {
73
+ status: 'pending' | 'failed' | 'unavailable';
74
+ message: string;
75
+ retryable: boolean;
76
+ };
77
+
78
+ /** What the user's wallet was asked to sign, as the create session records it. */
79
+ export interface SentPodBatch {
80
+ /** The messages exactly as sent (proto-JSON, catalog-validated). */
81
+ messages: ITrxMsg[];
82
+ /** The profile the POD was prepared with (for its domain card). */
83
+ profile: PodProfile;
22
84
  }
23
85
 
24
86
  /**
25
87
  * The message types a POD creation batch may carry. The chain runs the
26
88
  * messages of one transaction in order, all or nothing, so a later message
27
89
  * can name what an earlier one creates — the entity DID, its admin account,
28
- * the collection id — as long as the gateway predicts those values right
29
- * (otherwise the whole transaction fails and nothing is created).
90
+ * the collection id — only by predicting those values (otherwise the whole
91
+ * transaction fails and nothing is created). The bundled gateway does not
92
+ * predict: it sends `MsgCreateEntity` alone.
30
93
  */
31
94
  export const POD_BATCH_TYPE_URLS = [
32
95
  '/ixo.entity.v1beta1.MsgCreateEntity',
@@ -62,49 +125,78 @@ export function podBatchProblem(
62
125
  return null;
63
126
  }
64
127
 
65
- /** The created POD, resolved from the broadcast transaction. */
66
- export interface CreatedPod {
67
- podDid: string;
68
- summary: string;
128
+ /** What the user agreed to call the POD, for its on-chain profile. */
129
+ export interface PodProfile {
130
+ /** The POD's name: plain text, 1 to 100 characters. */
131
+ name: string;
132
+ /** What the POD does, in plain text (at most 600 characters). */
133
+ description?: string;
69
134
  }
70
135
 
71
136
  /**
72
- * The chain-facing seam for the create path. The planned concrete implementation
73
- * calls the IXO MCP server (hosted on Cloudflare) to compose the POD-creation
74
- * batch (`MsgCreateEntity` + the claim collection + authz grants) and to read a
75
- * broadcast transaction back. The oracle never signs creation — the user's
76
- * wallet does — so the gateway only ever returns proto-JSON messages, which
77
- * the plugin validates against the `@ixo/ixo-transaction` catalog before
78
- * storing them, and resolves results.
137
+ * The chain-facing seam for the create path. The oracle never signs
138
+ * creation — the user's wallet does — so a gateway only ever returns
139
+ * proto-JSON messages, which the plugin validates against the
140
+ * `@ixo/ixo-transaction` catalog before storing them, and resolves the
141
+ * broadcast transaction afterwards.
79
142
  *
80
- * Both methods receive the request `RuntimeContext` because reaching the IXO MCP
81
- * server follows the runtime's remote-MCP pattern (see the sandbox plugin):
82
- * resolve the server DID via did:web and mint a per-user `ixo:*` UCAN invocation
83
- * through `ctx.ucan` for the `Authorization` header, with the MCP URL read from
84
- * `ctx.config`. The seam is injected so the create path stays unit-testable
85
- * without a live server or chain.
143
+ * The bundled implementation is `createIxoChainGateway()`
144
+ * (`ixo-chain-gateway.ts`): one `MsgCreateEntity` owned by the user, its
145
+ * profile reserved on the oracle's homeserver and published after approval,
146
+ * read back from chain REST; then the POD's domain card. Every method
147
+ * receives the request `RuntimeContext` (the user, the config, the turn's
148
+ * abort signal, the oracle's Matrix device) and answers an expected refusal
149
+ * as a result; a throw is a programming error. The seam is injected so the
150
+ * create path stays unit-testable without a live homeserver or chain, and
151
+ * so a fork can compose a different batch.
86
152
  */
87
153
  export interface ChainGateway {
88
154
  preparePodBatch(
89
- input: { blueprint: ServicePodBlueprint; network: Network },
155
+ input: {
156
+ blueprint: ServicePodBlueprint;
157
+ network: Network;
158
+ profile: PodProfile;
159
+ },
90
160
  ctx: RuntimeContext,
91
- ): Promise<PreparedPodBatch>;
161
+ ): Promise<GatewayResult<PreparedPodBatch>>;
162
+ /** Publish a prepared batch's pending documents; publishing one twice is a success. */
163
+ publishDocuments(
164
+ input: { documents: readonly PendingDocument[] },
165
+ ctx: RuntimeContext,
166
+ ): Promise<GatewayResult<object>>;
167
+ /** Read the transaction back and check it is the POD `sent` describes. */
92
168
  confirmPodCreation(
93
- input: { txHash: string; network: Network },
169
+ input: { txHash: string; network: Network; sent: SentPodBatch },
170
+ ctx: RuntimeContext,
171
+ ): Promise<PodConfirmation>;
172
+ /** The batch that adds a confirmed POD's public domain card (one message). */
173
+ prepareDomainCard(
174
+ input: { network: Network; podDid: string; profile: PodProfile },
175
+ ctx: RuntimeContext,
176
+ ): Promise<GatewayResult<PreparedPodBatch>>;
177
+ /** Ask the domain indexer to index the POD now; best effort, never throws. */
178
+ requestIndexing(
179
+ input: { network: Network; podDid: string },
94
180
  ctx: RuntimeContext,
95
- ): Promise<CreatedPod>;
181
+ ): Promise<void>;
96
182
  }
97
183
 
98
184
  const notConfigured = (): never => {
99
185
  throw new Error(
100
- 'ChainGateway not configured: the POD create path needs a chain gateway ' +
101
- 'wired to the IXO MCP server before it can prepare or confirm ' +
102
- 'transactions.',
186
+ 'ChainGateway not configured: this oracle was built without a chain ' +
187
+ 'gateway, so it cannot prepare or confirm POD creation transactions.',
103
188
  );
104
189
  };
105
190
 
106
- /** Default gateway that errors until a real one is wired. */
191
+ /**
192
+ * A gateway that refuses every call, for a fork that wants the design
193
+ * pipeline without on-chain creation: the create tools report creation as
194
+ * unavailable without calling it.
195
+ */
107
196
  export const notConfiguredChainGateway: ChainGateway = {
108
197
  preparePodBatch: async () => notConfigured(),
198
+ publishDocuments: async () => notConfigured(),
109
199
  confirmPodCreation: async () => notConfigured(),
200
+ prepareDomainCard: async () => notConfigured(),
201
+ requestIndexing: async () => notConfigured(),
110
202
  };
@@ -5,6 +5,7 @@ import {
5
5
  } from '@ixo/ixo-transaction';
6
6
  import { z } from 'zod';
7
7
  import type { RuntimeContext } from '../../plugin-api/types';
8
+ import { DOMAIN_INDEXER_URLS } from '../domain-indexer/domain-indexer.plugin';
8
9
  import {
9
10
  chainIdConfigSchema,
10
11
  chainIdsFromConfig,
@@ -18,6 +19,9 @@ import {
18
19
  */
19
20
  export const DEFAULT_NETWORK: Network = 'testnet';
20
21
 
22
+ /** An IXO entity DID as the chain derives it. */
23
+ const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
24
+
21
25
  /**
22
26
  * Plugin-owned env vars. The capsules registry URL and `NETWORK` are read as
23
27
  * siblings from the merged config (owned by the skills plugin / base schema),
@@ -42,14 +46,49 @@ export const podCreatorConfigSchema = z.object({
42
46
  .default(false),
43
47
  /** The chain id each network's signing request names. */
44
48
  ...chainIdConfigSchema.shape,
49
+ /**
50
+ * The relayer node a created POD names, instead of the network's default
51
+ * ({@link POD_RELAYER_NODE_DIDS}).
52
+ */
53
+ POD_CREATOR_RELAYER_NODE_DID: z
54
+ .string()
55
+ .regex(ENTITY_DID, 'expected an entity DID: did:ixo:entity:<32 hex>')
56
+ .optional(),
57
+ /**
58
+ * The Portal a created POD is linked to, instead of the network's default
59
+ * ({@link PORTAL_URLS}).
60
+ */
61
+ POD_CREATOR_PORTAL_URL: z.url().optional(),
45
62
  });
46
63
 
64
+ /**
65
+ * The relayer node each network's Portal names on the domains (and PODs) it
66
+ * creates: its `NEXT_PUBLIC_DAO_DID` (ixo-portal `lib/entity/createEntity.ts`),
67
+ * as read from the Portal's recent "Create domain:" transactions. The
68
+ * mainnet Portal lists only entities of this relayer that are verified.
69
+ * Not the oracles' relayer (`lib/oracle/networkConfig.ts`), which only
70
+ * oracle entities name.
71
+ */
72
+ export const POD_RELAYER_NODE_DIDS: Readonly<Record<Network, string>> = {
73
+ mainnet: 'did:ixo:entity:47d9c64891e77cb8e588d33b83ab3042',
74
+ testnet: 'did:ixo:entity:3d079ebc0b332aad3305bb4a51c72edb',
75
+ devnet: 'did:ixo:entity:ac12c6f8f299071eb24f2a10591d7ab7',
76
+ };
77
+
78
+ /** The Portal each network's PODs are opened in. */
79
+ export const PORTAL_URLS: Readonly<Record<Network, string>> = {
80
+ mainnet: 'https://portal.qi.space',
81
+ testnet: 'https://test.portal.qi.space',
82
+ devnet: 'https://dev.portal.qi.space',
83
+ };
84
+
47
85
  /** Sibling env + plugin flags the tools read at request time. */
48
86
  const configReadSchema = z.object({
49
87
  NETWORK: NetworkSchema.optional(),
50
88
  POD_CREATOR_ALLOW_MAINNET:
51
89
  podCreatorConfigSchema.shape.POD_CREATOR_ALLOW_MAINNET.optional(),
52
90
  ...chainIdConfigSchema.shape,
91
+ POD_CREATOR_PORTAL_URL: podCreatorConfigSchema.shape.POD_CREATOR_PORTAL_URL,
53
92
  });
54
93
 
55
94
  export interface PodCreatorConfig {
@@ -57,8 +96,12 @@ export interface PodCreatorConfig {
57
96
  mainnetAllowed: boolean;
58
97
  /** The chain id each network's signing request names. */
59
98
  chainIds: Readonly<Record<Network, string>>;
99
+ /** The Portal of `network`, without a trailing slash. */
100
+ portalUrl: string;
60
101
  }
61
102
 
103
+ const withoutSlash = (url: string): string => url.replace(/\/+$/, '');
104
+
62
105
  /**
63
106
  * Network routing + the mainnet opt-in, read from the merged config. Falls
64
107
  * back to testnet with mainnet disabled when the config is unreadable — and
@@ -75,11 +118,48 @@ export function readPodCreatorConfig(ctx: RuntimeContext): PodCreatorConfig {
75
118
  network: DEFAULT_NETWORK,
76
119
  mainnetAllowed: false,
77
120
  chainIds: DEFAULT_CHAIN_IDS,
121
+ portalUrl: PORTAL_URLS[DEFAULT_NETWORK],
78
122
  };
79
123
  }
124
+ const network = parsed.data.NETWORK ?? DEFAULT_NETWORK;
80
125
  return {
81
- network: parsed.data.NETWORK ?? DEFAULT_NETWORK,
126
+ network,
82
127
  mainnetAllowed: parsed.data.POD_CREATOR_ALLOW_MAINNET ?? false,
83
128
  chainIds: chainIdsFromConfig(parsed.data),
129
+ portalUrl: withoutSlash(
130
+ parsed.data.POD_CREATOR_PORTAL_URL ?? PORTAL_URLS[network],
131
+ ),
84
132
  };
85
133
  }
134
+
135
+ const relayerReadSchema = z.object({
136
+ POD_CREATOR_RELAYER_NODE_DID:
137
+ podCreatorConfigSchema.shape.POD_CREATOR_RELAYER_NODE_DID,
138
+ });
139
+
140
+ /** The relayer node a POD created on `network` names: the operator's, else the network's Portal's. */
141
+ export function relayerNodeDidFor(
142
+ ctx: RuntimeContext,
143
+ network: Network,
144
+ ): string {
145
+ const parsed = relayerReadSchema.safeParse(ctx.config);
146
+ return (
147
+ (parsed.success ? parsed.data.POD_CREATOR_RELAYER_NODE_DID : undefined) ??
148
+ POD_RELAYER_NODE_DIDS[network]
149
+ );
150
+ }
151
+
152
+ /** `DOMAIN_INDEXER_URL` is the domain-indexer plugin's; read here as a sibling. */
153
+ const indexerReadSchema = z.object({ DOMAIN_INDEXER_URL: z.url().optional() });
154
+
155
+ /** The domain indexer of `network`, without a trailing slash. */
156
+ export function domainIndexerUrlFor(
157
+ ctx: RuntimeContext,
158
+ network: Network,
159
+ ): string {
160
+ const parsed = indexerReadSchema.safeParse(ctx.config);
161
+ return withoutSlash(
162
+ (parsed.success ? parsed.data.DOMAIN_INDEXER_URL : undefined) ??
163
+ DOMAIN_INDEXER_URLS[network],
164
+ );
165
+ }
@@ -1,5 +1,7 @@
1
+ import { ITrxMsgSchema } from '@ixo/ixo-transaction';
1
2
  import { z } from 'zod';
2
3
  import type { RuntimeContext, UserKvSurface } from '../../plugin-api/types';
4
+ import type { SentPodBatch } from './chain-gateway';
3
5
 
4
6
  /**
5
7
  * Per-(user, thread) state machine for the create path's propose → approve →
@@ -16,9 +18,13 @@ import type { RuntimeContext, UserKvSurface } from '../../plugin-api/types';
16
18
  * message after seeing the batch before it can be approved, so a single model
17
19
  * turn — steered, say, by injected text in the brief or a section — cannot run
18
20
  * prepare → approve → sign on its own.
21
+ *
22
+ * After the commit it keeps what was sent for signing (confirmation checks
23
+ * the transaction against it), the POD confirmed from it, and the one domain
24
+ * card step that may follow.
19
25
  */
20
26
  export interface CreateSessionStore {
21
- /** Record a freshly prepared batch; any prior approval is superseded. */
27
+ /** Record a freshly prepared batch; anything before it in the thread is superseded. */
22
28
  prepared(
23
29
  userDid: string,
24
30
  threadId: string,
@@ -37,15 +43,57 @@ export interface CreateSessionStore {
37
43
  requestId: string,
38
44
  ): Promise<ApproveOutcome>;
39
45
  /**
40
- * Spend the approval for a sign dispatch. Returns true only when `blobId`
41
- * is the approved pending batch; the approval is cleared when it matches, so
42
- * a second dispatch needs a fresh approve.
46
+ * Spend the approval for a sign dispatch of `sent`. Returns true only when
47
+ * `blobId` is the approved pending batch; the approval is cleared when it
48
+ * matches (a second dispatch needs a fresh approve) and `sent` is recorded
49
+ * as what the wallet was asked to sign.
43
50
  */
44
- consume(userDid: string, threadId: string, blobId: string): Promise<boolean>;
45
- /** Drop the session (after on-chain confirmation). */
51
+ consume(
52
+ userDid: string,
53
+ threadId: string,
54
+ blobId: string,
55
+ sent?: SentPodBatch,
56
+ ): Promise<boolean>;
57
+ /** The thread's approval, what its last sign request carried, and the POD confirmed from it. */
58
+ read(
59
+ userDid: string,
60
+ threadId: string,
61
+ ): Promise<CreateSessionView | undefined>;
62
+ /** Record the POD confirmed from the sent batch (no-op when nothing was sent). */
63
+ confirmed(
64
+ userDid: string,
65
+ threadId: string,
66
+ pod: { podDid: string; txHash: string },
67
+ ): Promise<void>;
68
+ /**
69
+ * Claim the confirmed POD's one domain card step. `no-pod` when no POD
70
+ * was confirmed in the thread, `claimed` when the card is being added or
71
+ * was added already.
72
+ */
73
+ claimCard(userDid: string, threadId: string): Promise<CardClaim>;
74
+ /** End a claimed card step: `added` keeps it closed, `released` lets it run again. */
75
+ settleCard(
76
+ userDid: string,
77
+ threadId: string,
78
+ outcome: 'added' | 'released',
79
+ ): Promise<void>;
80
+ /** Drop the session (the design was restarted). */
46
81
  clear(userDid: string, threadId: string): Promise<void>;
47
82
  }
48
83
 
84
+ /** The session as the create tools read it. */
85
+ export interface CreateSessionView {
86
+ /** The prepared batch while it holds an approval not spent yet. */
87
+ approvedBlobId?: string;
88
+ sent?: SentPodBatch;
89
+ confirmed?: { podDid: string; txHash: string };
90
+ }
91
+
92
+ export type CardClaim =
93
+ | { status: 'ok'; podDid: string; sent: SentPodBatch }
94
+ | { status: 'no-pod' }
95
+ | { status: 'claimed'; state: 'sending' | 'added' };
96
+
49
97
  /** Resolves the create-session store for one request (it lives in the user's database). */
50
98
  export type CreateSessionStoreFor = (ctx: RuntimeContext) => CreateSessionStore;
51
99
 
@@ -54,11 +102,25 @@ export type ApproveOutcome = 'approved' | 'not-prepared' | 'same-request';
54
102
  /** `ctx.kv` namespace holding one create session per (user, thread). */
55
103
  export const CREATE_SESSION_NAMESPACE = 'pod-creator/create-sessions';
56
104
 
105
+ const sentSchema = z.object({
106
+ messages: z.array(ITrxMsgSchema),
107
+ profile: z.object({
108
+ name: z.string(),
109
+ description: z.string().optional(),
110
+ }),
111
+ });
112
+
57
113
  const createSessionSchema = z.object({
58
114
  preparedBlobId: z.string(),
59
115
  /** The request (turn) that prepared the batch. */
60
116
  preparedRequestId: z.string(),
61
117
  approved: z.boolean(),
118
+ /** What the last sign request of the prepared batch carried. */
119
+ sent: sentSchema.optional(),
120
+ /** The POD confirmed from `sent`. */
121
+ confirmed: z.object({ podDid: z.string(), txHash: z.string() }).optional(),
122
+ /** The confirmed POD's domain card: being added (claimed), or added. */
123
+ card: z.enum(['sending', 'added']).optional(),
62
124
  });
63
125
 
64
126
  type CreateSession = z.infer<typeof createSessionSchema>;
@@ -156,6 +218,7 @@ export class KvCreateSessionStore implements CreateSessionStore {
156
218
  userDid: string,
157
219
  threadId: string,
158
220
  blobId: string,
221
+ sent?: SentPodBatch,
159
222
  ): Promise<boolean> {
160
223
  let spent = false;
161
224
  await this.kv.update(
@@ -167,13 +230,102 @@ export class KvCreateSessionStore implements CreateSessionStore {
167
230
  session !== undefined &&
168
231
  session.preparedBlobId === blobId &&
169
232
  session.approved;
170
- return session && spent ? { ...session, approved: false } : session;
233
+ if (!session || !spent) return session;
234
+ return {
235
+ ...session,
236
+ approved: false,
237
+ ...(sent !== undefined ? { sent } : {}),
238
+ };
171
239
  },
172
240
  this.writeOptions,
173
241
  );
174
242
  return spent;
175
243
  }
176
244
 
245
+ async read(
246
+ userDid: string,
247
+ threadId: string,
248
+ ): Promise<CreateSessionView | undefined> {
249
+ const session = parseSession(
250
+ await this.kv.get(CREATE_SESSION_NAMESPACE, key(userDid, threadId)),
251
+ );
252
+ if (!session) return undefined;
253
+ return {
254
+ ...(session.approved ? { approvedBlobId: session.preparedBlobId } : {}),
255
+ ...(session.sent ? { sent: session.sent } : {}),
256
+ ...(session.confirmed ? { confirmed: session.confirmed } : {}),
257
+ };
258
+ }
259
+
260
+ async confirmed(
261
+ userDid: string,
262
+ threadId: string,
263
+ pod: { podDid: string; txHash: string },
264
+ ): Promise<void> {
265
+ await this.kv.update(
266
+ CREATE_SESSION_NAMESPACE,
267
+ key(userDid, threadId),
268
+ (current) => {
269
+ const session = parseSession(current);
270
+ if (!session?.sent) return session;
271
+ // The card belongs to the POD: another confirmed POD starts afresh.
272
+ const samePod = session.confirmed?.podDid === pod.podDid;
273
+ const { card: _card, ...rest } = session;
274
+ return {
275
+ ...(samePod ? session : rest),
276
+ confirmed: { podDid: pod.podDid, txHash: pod.txHash },
277
+ };
278
+ },
279
+ this.writeOptions,
280
+ );
281
+ }
282
+
283
+ async claimCard(userDid: string, threadId: string): Promise<CardClaim> {
284
+ let claim: CardClaim = { status: 'no-pod' };
285
+ await this.kv.update(
286
+ CREATE_SESSION_NAMESPACE,
287
+ key(userDid, threadId),
288
+ (current) => {
289
+ const session = parseSession(current);
290
+ if (!session?.confirmed || !session.sent) {
291
+ claim = { status: 'no-pod' };
292
+ return session;
293
+ }
294
+ if (session.card) {
295
+ claim = { status: 'claimed', state: session.card };
296
+ return session;
297
+ }
298
+ claim = {
299
+ status: 'ok',
300
+ podDid: session.confirmed.podDid,
301
+ sent: session.sent,
302
+ };
303
+ return { ...session, card: 'sending' };
304
+ },
305
+ this.writeOptions,
306
+ );
307
+ return claim;
308
+ }
309
+
310
+ async settleCard(
311
+ userDid: string,
312
+ threadId: string,
313
+ outcome: 'added' | 'released',
314
+ ): Promise<void> {
315
+ await this.kv.update(
316
+ CREATE_SESSION_NAMESPACE,
317
+ key(userDid, threadId),
318
+ (current) => {
319
+ const session = parseSession(current);
320
+ if (!session || session.card !== 'sending') return session;
321
+ if (outcome === 'added') return { ...session, card: 'added' };
322
+ const { card: _card, ...rest } = session;
323
+ return rest;
324
+ },
325
+ this.writeOptions,
326
+ );
327
+ }
328
+
177
329
  async clear(userDid: string, threadId: string): Promise<void> {
178
330
  await this.kv.delete(CREATE_SESSION_NAMESPACE, key(userDid, threadId));
179
331
  }