@onlyne/lib 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +4 -3
  2. package/dist/awaited.d.ts +43 -0
  3. package/dist/awaited.d.ts.map +1 -0
  4. package/dist/awaited.js +82 -0
  5. package/dist/awaited.js.map +1 -0
  6. package/dist/client.d.ts +140 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +1772 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/core.d.ts +36 -0
  11. package/dist/core.d.ts.map +1 -0
  12. package/dist/core.js +122 -0
  13. package/dist/core.js.map +1 -0
  14. package/dist/delivery.d.ts +120 -0
  15. package/dist/delivery.d.ts.map +1 -0
  16. package/dist/delivery.js +117 -0
  17. package/dist/delivery.js.map +1 -0
  18. package/dist/discovery.d.ts +35 -0
  19. package/dist/discovery.d.ts.map +1 -0
  20. package/dist/discovery.js +306 -0
  21. package/dist/discovery.js.map +1 -0
  22. package/dist/domains.d.ts +29 -0
  23. package/dist/domains.d.ts.map +1 -0
  24. package/dist/domains.js +267 -0
  25. package/dist/domains.js.map +1 -0
  26. package/dist/history.d.ts +13 -0
  27. package/dist/history.d.ts.map +1 -0
  28. package/dist/history.js +143 -0
  29. package/dist/history.js.map +1 -0
  30. package/dist/http.d.ts +20 -0
  31. package/dist/http.d.ts.map +1 -0
  32. package/dist/http.js +88 -0
  33. package/dist/http.js.map +1 -0
  34. package/{src/index.ts → dist/index.d.ts} +2 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +15 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/keys.d.ts +97 -0
  39. package/dist/keys.d.ts.map +1 -0
  40. package/dist/keys.js +679 -0
  41. package/dist/keys.js.map +1 -0
  42. package/dist/memory.d.ts +9 -0
  43. package/dist/memory.d.ts.map +1 -0
  44. package/dist/memory.js +35 -0
  45. package/dist/memory.js.map +1 -0
  46. package/dist/messages.d.ts +64 -0
  47. package/dist/messages.d.ts.map +1 -0
  48. package/dist/messages.js +480 -0
  49. package/dist/messages.js.map +1 -0
  50. package/dist/node.d.ts +26 -0
  51. package/dist/node.d.ts.map +1 -0
  52. package/dist/node.js +59 -0
  53. package/dist/node.js.map +1 -0
  54. package/dist/resolve.d.ts +18 -0
  55. package/dist/resolve.d.ts.map +1 -0
  56. package/dist/resolve.js +37 -0
  57. package/dist/resolve.js.map +1 -0
  58. package/dist/resources.d.ts +25 -0
  59. package/dist/resources.d.ts.map +1 -0
  60. package/dist/resources.js +782 -0
  61. package/dist/resources.js.map +1 -0
  62. package/dist/routes/domains.d.ts +4 -0
  63. package/dist/routes/domains.d.ts.map +1 -0
  64. package/dist/routes/domains.js +42 -0
  65. package/dist/routes/domains.js.map +1 -0
  66. package/dist/routes/grants.d.ts +8 -0
  67. package/dist/routes/grants.d.ts.map +1 -0
  68. package/dist/routes/grants.js +32 -0
  69. package/dist/routes/grants.js.map +1 -0
  70. package/dist/routes/public.d.ts +4 -0
  71. package/dist/routes/public.d.ts.map +1 -0
  72. package/dist/routes/public.js +62 -0
  73. package/dist/routes/public.js.map +1 -0
  74. package/dist/routes/resources.d.ts +8 -0
  75. package/dist/routes/resources.d.ts.map +1 -0
  76. package/dist/routes/resources.js +87 -0
  77. package/dist/routes/resources.js.map +1 -0
  78. package/dist/routes/shared.d.ts +77 -0
  79. package/dist/routes/shared.d.ts.map +1 -0
  80. package/dist/routes/shared.js +349 -0
  81. package/dist/routes/shared.js.map +1 -0
  82. package/dist/routes/threads.d.ts +8 -0
  83. package/dist/routes/threads.d.ts.map +1 -0
  84. package/dist/routes/threads.js +113 -0
  85. package/dist/routes/threads.js.map +1 -0
  86. package/dist/routes/trust.d.ts +4 -0
  87. package/dist/routes/trust.d.ts.map +1 -0
  88. package/dist/routes/trust.js +36 -0
  89. package/dist/routes/trust.js.map +1 -0
  90. package/dist/stream.d.ts +20 -0
  91. package/dist/stream.d.ts.map +1 -0
  92. package/dist/stream.js +141 -0
  93. package/dist/stream.js.map +1 -0
  94. package/dist/threads.d.ts +79 -0
  95. package/dist/threads.d.ts.map +1 -0
  96. package/dist/threads.js +480 -0
  97. package/dist/threads.js.map +1 -0
  98. package/dist/transport.d.ts +45 -0
  99. package/dist/transport.d.ts.map +1 -0
  100. package/dist/transport.js +175 -0
  101. package/dist/transport.js.map +1 -0
  102. package/dist/types.d.ts +809 -0
  103. package/dist/types.d.ts.map +1 -0
  104. package/dist/types.js +9 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +16 -8
  107. package/src/client.ts +0 -1745
  108. package/src/core.ts +0 -114
  109. package/src/discovery.ts +0 -275
  110. package/src/domains.ts +0 -213
  111. package/src/history.ts +0 -139
  112. package/src/http.ts +0 -852
  113. package/src/keys.ts +0 -642
  114. package/src/memory.ts +0 -34
  115. package/src/messages.ts +0 -409
  116. package/src/node.ts +0 -63
  117. package/src/resolve.ts +0 -39
  118. package/src/resources.ts +0 -745
  119. package/src/threads.ts +0 -409
  120. package/src/transport.ts +0 -211
  121. package/src/types.ts +0 -428
package/dist/client.js ADDED
@@ -0,0 +1,1772 @@
1
+ var _a;
2
+ import { canonical, decimal, fail, key, page, requireValue, utc } from "./core.js";
3
+ import { trustPhase } from "./types.js";
4
+ import { FAILURE_WINDOW_MS, GRANT_RESYNC_MS, MAX_ATTEMPTS, MAX_CURSOR_PAGES, RESOURCE_CHECK_MS, admissionOf, attentionOf, backoffMs, consumptionKey, failureOf, grantResyncKey, inboxKey, isReply, messageDigest, outboundKey, reconcileBackoffKey, replayOrder, todoKey, } from "./delivery.js";
5
+ import { AwaitedReplies } from "./awaited.js";
6
+ import { workRef } from "./routes/shared.js";
7
+ export class OnlyneClient {
8
+ #ctx;
9
+ #transport;
10
+ #messages;
11
+ #threads;
12
+ #resources;
13
+ #domains;
14
+ #trust;
15
+ #loop;
16
+ /** The maintenance cadence the loop uses while durable work is pending or the host just woke it. */
17
+ static ACTIVE_INTERVAL_MS = 2_000;
18
+ /** The floor the loop drops to once the durable ledger holds no pending work. */
19
+ static IDLE_INTERVAL_MS = 30_000;
20
+ /** Live awaits and the tool-result holds they place. */
21
+ #awaiting = new AwaitedReplies();
22
+ /** Last failure line per scope, and what its window suppressed; one map for every repeated line. */
23
+ #failureWindows = new Map();
24
+ constructor(ctx, transport, messages, threads, resources, domains, trust) {
25
+ this.#ctx = ctx;
26
+ this.#transport = transport;
27
+ this.#messages = messages;
28
+ this.#threads = threads;
29
+ this.#resources = resources;
30
+ this.#domains = domains;
31
+ this.#trust = trust;
32
+ const clock = ctx.options.plugins.maintenanceClock;
33
+ if (clock !== undefined) {
34
+ this.#loop = { clock, controller: new AbortController(), active: { intervalMs: _a.ACTIVE_INTERVAL_MS }, handle: undefined, lastPending: true, running: false };
35
+ }
36
+ }
37
+ /**
38
+ * RFC 0017 §6: the library-owned maintenance loop. One pass runs `maintain`,
39
+ * `recover`, then re-arms: pending durable work keeps the active cadence, an idle
40
+ * ledger drops to the idle floor. Every clock read goes through the injected
41
+ * `MaintenanceClock`. A pass is never re-entered; a wake during a pass schedules
42
+ * the next tick at the active cadence. `notify` carries every pending-work change.
43
+ */
44
+ runMaintenance() {
45
+ const loop = this.#loop;
46
+ if (loop === undefined)
47
+ fail('unsupported_delivery', 'The host supplied no maintenance clock, so the library cannot own the loop. Pass plugins.maintenanceClock or schedule maintain() yourself.');
48
+ if (loop.handle !== undefined)
49
+ return;
50
+ if (loop.controller.signal.aborted)
51
+ loop.controller = new AbortController();
52
+ this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
53
+ }
54
+ stopMaintenance() {
55
+ const loop = this.#loop;
56
+ if (loop === undefined)
57
+ return;
58
+ loop.controller.abort();
59
+ if (loop.handle === undefined)
60
+ return;
61
+ loop.clock.clearTimer(loop.handle);
62
+ loop.handle = undefined;
63
+ }
64
+ /** @internal Flips the loop to its active cadence: new evidence arrived while it idled. */
65
+ wakeLoop() {
66
+ const loop = this.#loop;
67
+ if (loop === undefined || loop.controller.signal.aborted)
68
+ return;
69
+ if (loop.active.intervalMs !== _a.ACTIVE_INTERVAL_MS)
70
+ this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
71
+ }
72
+ /** @internal Schedules an immediate maintenance pass when new inbound or grant work lands locally. */
73
+ wakeImmediate() {
74
+ const loop = this.#loop;
75
+ if (loop === undefined || loop.controller.signal.aborted || loop.handle === undefined)
76
+ return;
77
+ if (loop.running) {
78
+ loop.rerunImmediate = true;
79
+ return;
80
+ }
81
+ if (loop.active.intervalMs !== 0)
82
+ this.#arm(loop, 0);
83
+ }
84
+ #arm(loop, intervalMs) {
85
+ if (loop.handle !== undefined)
86
+ loop.clock.clearTimer(loop.handle);
87
+ loop.active.intervalMs = intervalMs;
88
+ loop.handle = loop.clock.setTimer(intervalMs, () => this.#tick(loop));
89
+ }
90
+ async #tick(loop) {
91
+ // The timer that fired is spent. Clear it instead of dropping the handle: a host clock
92
+ // that keeps a fired timer registered would otherwise leak one handle per pass, and
93
+ // `stopMaintenance` could only ever clear the newest.
94
+ if (loop.handle !== undefined) {
95
+ loop.clock.clearTimer(loop.handle);
96
+ loop.handle = undefined;
97
+ }
98
+ if (loop.running)
99
+ return this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
100
+ loop.running = true;
101
+ loop.rerunImmediate = false;
102
+ try {
103
+ await this.maintain(loop.controller.signal);
104
+ await this.#recover(loop.controller.signal, true);
105
+ }
106
+ catch {
107
+ // A failed pass keeps its owed records; the next pass retries them.
108
+ }
109
+ finally {
110
+ loop.running = false;
111
+ }
112
+ let pending = true;
113
+ try {
114
+ pending = await this.hasPendingWork();
115
+ }
116
+ catch { /* unknown keeps the active cadence */ }
117
+ if (pending !== loop.lastPending) {
118
+ loop.lastPending = pending;
119
+ this.#ctx.options.plugins.runtime?.notify?.({ event: 'work-pending', pending });
120
+ }
121
+ if (!loop.controller.signal.aborted) {
122
+ const nextMs = loop.rerunImmediate ? 0 : (pending ? _a.ACTIVE_INTERVAL_MS : _a.IDLE_INTERVAL_MS);
123
+ loop.rerunImmediate = false;
124
+ this.#arm(loop, nextMs);
125
+ }
126
+ }
127
+ // ---- delivery ---------------------------------------------------------------
128
+ /**
129
+ * Delivers one message under a caller-supplied plan. The plan is never re-derived
130
+ * here: RFC 0005 §2 forbids the library from choosing a delivery mode itself.
131
+ */
132
+ async send(to, message, plan, options = {}) {
133
+ const ctx = this.#ctx;
134
+ this.wakeLoop();
135
+ if (message.sender !== ctx.options.node_id) {
136
+ fail('forbidden', 'This node may only deliver messages this node authors.');
137
+ }
138
+ if (typeof to !== 'string' || to.length === 0)
139
+ fail('invalid_request', 'A delivery names its target endpoint.');
140
+ if (plan.response === 'await')
141
+ await this.#ensureAwaitable(plan, options.signal);
142
+ // RFC 0005 §3: an await that only this node could answer is a cycle, not a wait.
143
+ if (plan.response === 'await' && (to === ctx.options.node_id || options.expected_author === ctx.options.node_id)) {
144
+ fail('unsupported_delivery', 'This node cannot await a reply only it could author; no message was sent.');
145
+ }
146
+ // `to` is the participant this message addresses. The thread decides where the message is
147
+ // stored: an initiator owns the thread for the whole conversation, so the storing node is
148
+ // the thread owner and not necessarily the addressee.
149
+ const thread = message.meta?.thread ?? await this.#threadOf(message.meta?.in_reply_to);
150
+ // A reply whose original this node cannot see is answered through the reply authority,
151
+ // which resolves the landing from its own record. A reply never opens a conversation.
152
+ if (thread === undefined && message.meta?.in_reply_to !== undefined)
153
+ return this.#sendUntracked(to, message, plan, options);
154
+ if (thread === undefined && to === ctx.options.node_id) {
155
+ // A self-addressed message with no conversation yet lands on this node's own default.
156
+ return this.#admitHere(message, plan);
157
+ }
158
+ const ref = thread ?? await this.#startThread(message);
159
+ const normalized = {
160
+ ...message,
161
+ meta: {
162
+ ...message.meta,
163
+ thread: ref,
164
+ // The addressee of a directed send is who must be told; a reply already notifies the
165
+ // original author through the reply authority, and `@all` covers every member.
166
+ ...(to === ctx.options.node_id || message.meta?.in_reply_to !== undefined
167
+ || message.meta?.mentions?.includes(to) === true || message.meta?.mentions?.includes('@all') === true
168
+ ? {}
169
+ : { mentions: [...(message.meta?.mentions ?? []), to] }),
170
+ },
171
+ };
172
+ if (ref.owner === ctx.options.node_id && to !== ctx.options.node_id) {
173
+ // The addressee can only ever learn this entry through a signed grant or a signed
174
+ // reconcile, so a send that names a peer this node never paired with is refused before
175
+ // anything is stored: it would otherwise read `accepted` while the peer can never see it.
176
+ if (this.#trust !== undefined && ctx.options.plugins.keys !== undefined) {
177
+ const pairing = await this.#trust.entry(to);
178
+ if (pairing === undefined) {
179
+ fail('not_found', `No trust record names ${to}. Pair the nodes (trust_begin) before addressing them; nothing was stored.`);
180
+ }
181
+ }
182
+ const member = await this.#ensureMember(ref, to);
183
+ // The participant is told about the thread now, not on a later maintenance pass: this
184
+ // exchange is also where the participant's own policy answers, so a peer that revoked
185
+ // this node refuses the conversation at the point the sender can still see it.
186
+ try {
187
+ await this.#pushGrant(ref, member);
188
+ }
189
+ catch (error) {
190
+ const failure = failureOf(error);
191
+ return { admission: admissionOf(failure), error: failure };
192
+ }
193
+ }
194
+ const outcome = await this.#deliver(ref.owner, normalized, plan, options, to);
195
+ // The storing endpoint is this node whenever it started the conversation; the outcome
196
+ // still names the peer it addresses, or the receipt reads as a message sent to oneself.
197
+ return to !== ref.owner ? { ...outcome, resolved: to } : outcome;
198
+ }
199
+ /** The reply-authority route: the receiver owns the landing, so no thread is named here. */
200
+ async #sendUntracked(to, message, plan, options) {
201
+ return this.#deliver(to, message, plan, options);
202
+ }
203
+ /** Records one outbound request, then transmits it or waits for its answer. */
204
+ async #deliver(target, message, plan, options, addressee) {
205
+ const ctx = this.#ctx;
206
+ const slot = outboundKey(message.sender, message.id);
207
+ const request = {
208
+ idempotency_key: options.idempotency_key ?? ctx.id(),
209
+ retry_until: this.#window(options.retry_until),
210
+ message,
211
+ target_endpoint: target,
212
+ plan,
213
+ };
214
+ const digest = messageDigest(request);
215
+ // Who the send addresses, beside where the entry is stored: an initiator's own thread
216
+ // stores locally, so the record must still name the peer, or the receipt reads as a
217
+ // message the node sent itself.
218
+ const addressed = {
219
+ ...(addressee !== undefined && addressee !== target ? { addressee } : {}),
220
+ ...(typeof options.requested === 'string' && options.requested !== (addressee ?? target)
221
+ ? { requested: options.requested }
222
+ : {}),
223
+ };
224
+ // The same message identity with different content is a conflict, never a rewrite:
225
+ // the slot keeps the first request and the caller sends a new message id instead.
226
+ const record = await ctx.storage.transaction(async (tx) => {
227
+ const stored = await tx.get(slot);
228
+ if (stored !== undefined) {
229
+ if ((stored.digest ?? messageDigest(stored.request)) !== digest) {
230
+ fail('id_conflict', 'This message identity is already bound to different content.');
231
+ }
232
+ // A re-drive of the same message keeps the first request identity, which the peer
233
+ // dedupes on; the consumption identity belongs to the caller waiting now, not to
234
+ // the delivery, so a caller that names another one replaces the stale slot.
235
+ // Acceptance is final; anything else is a case the caller is asking again, which is
236
+ // the one signal that starts a fresh attempt series after a dead letter or a backoff.
237
+ const recontexted = options.context === undefined || stored.local_context === options.context
238
+ ? {} : { local_context: options.context };
239
+ if (stored.state === 'accepted') {
240
+ if (Object.keys(recontexted).length === 0)
241
+ return stored;
242
+ const updated = { ...stored, ...recontexted };
243
+ await tx.put(slot, updated);
244
+ return updated;
245
+ }
246
+ const updated = { ...stored, ...recontexted, state: 'pending', attempt: undefined, next_attempt: undefined };
247
+ await tx.put(slot, updated);
248
+ return updated;
249
+ }
250
+ const fresh = {
251
+ request, expected_author: options.expected_author ?? target, state: 'pending', digest,
252
+ created_at: new Date(ctx.now()).toISOString(),
253
+ ...addressed,
254
+ ...(options.context === undefined ? {} : { local_context: options.context }),
255
+ };
256
+ await tx.put(slot, fresh);
257
+ return fresh;
258
+ });
259
+ if (plan.response !== 'await')
260
+ return this.#transmit(record, options.signal);
261
+ const waiter = this.#awaiting.register(record);
262
+ try {
263
+ return await this.#await(record, waiter, options.signal);
264
+ }
265
+ finally {
266
+ this.#awaiting.release(waiter);
267
+ }
268
+ }
269
+ /**
270
+ * Re-drives outbound records still open inside their window (RFC 0003 §3). `unknown`
271
+ * is terminal in meaning but the window still owns another attempt, so the window
272
+ * decides when to stop, not the last recorded state. A record whose window closed is
273
+ * settled as `unknown`, never left looking pending. Each record's own attempt series
274
+ * bounds the re-drive: a scheduled next attempt is respected instead of retried every
275
+ * pass, and a series that reached its limit stays a dead letter until an explicit
276
+ * re-send starts a new one. A caller invoking this directly is asking for a retry now,
277
+ * so it ignores the schedule; the maintenance loop's own pass respects it.
278
+ */
279
+ async recover(signal) {
280
+ return this.#recover(signal, false);
281
+ }
282
+ async #recover(signal, scheduled) {
283
+ const now = this.#ctx.now();
284
+ const rows = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('outbound') + '/'))
285
+ .map(row => row.value)
286
+ .filter(row => row.digest !== undefined && (row.state === 'pending' || row.state === 'unknown'))
287
+ // `pending` is a request that never left; only a failed one carries a schedule.
288
+ .filter(row => !scheduled || row.state === 'pending' || row.next_attempt === undefined || utc(row.next_attempt) <= now));
289
+ for (const row of rows) {
290
+ if (signal?.aborted)
291
+ return;
292
+ if (utc(row.request.retry_until) <= this.#ctx.now()) {
293
+ if (row.state !== 'unknown') {
294
+ await this.#commit(row, {
295
+ state: 'unknown',
296
+ error: { code: 'retry_window_expired', message: 'The window closed before this node learned the admission.', retryable: false },
297
+ });
298
+ this.#trace({ event: 'outbound.expired', sender: row.request.message.sender, message: row.request.message.id, code: 'retry_window_expired' });
299
+ }
300
+ continue;
301
+ }
302
+ // A recovered record has no caller left to consume a reply, so recovery re-drives the
303
+ // delivery and lets reconciliation surface any answer through the local inbox.
304
+ this.#trace({ event: 'outbound.redriven', sender: row.request.message.sender, message: row.request.message.id, target: row.request.target_endpoint });
305
+ await this.#transmit({ ...row, state: 'pending' }, signal);
306
+ }
307
+ }
308
+ /** One structured line per lifecycle transition; the host decides where the line lands. */
309
+ #trace(event) {
310
+ try {
311
+ this.#ctx.options.plugins.runtime?.trace?.(event);
312
+ }
313
+ catch { /* a tracing fault never breaks delivery */ }
314
+ }
315
+ #injectTrace(entry, record, outcome, reason, context) {
316
+ this.#trace({
317
+ event: 'inbound.inject', thread: entry.thread_ref, seq: entry.seq,
318
+ context: context ?? record.context ?? this.#ctx.options.node_id, outcome,
319
+ ...(reason === undefined ? {} : { reason }),
320
+ });
321
+ }
322
+ /** Appends to a thread this node hosts: no outbound record, no peer, no network. */
323
+ async #admitHere(message, plan) {
324
+ // A local append cannot await: the answer lands here as another local admit, which no
325
+ // waiter on an outbound delivery observes. Refusing is the only honest answer.
326
+ if (plan.response === 'await') {
327
+ fail('unsupported_delivery', 'A local append cannot await its answer; no message was sent.');
328
+ }
329
+ const receipt = await this.#messages.admit(this.#auth(), {
330
+ idempotency_key: this.#ctx.id(),
331
+ retry_until: this.#window(undefined),
332
+ message,
333
+ target_endpoint: this.#ctx.options.node_id,
334
+ plan,
335
+ });
336
+ return { admission: 'accepted', receipt };
337
+ }
338
+ /**
339
+ * The thread that holds a message this node can see. A reply names its original, and the
340
+ * original's thread is where the whole conversation lives (RFC 0002 §2): the entry this
341
+ * node received, or the receipt of the message this node sent.
342
+ */
343
+ async #threadOf(original) {
344
+ if (original === undefined)
345
+ return undefined;
346
+ return this.#ctx.storage.transaction(async (tx) => {
347
+ const entry = await tx.get(key('entry-ref', original.sender, original.id));
348
+ if (entry !== undefined)
349
+ return entry.thread_ref;
350
+ const sent = await tx.get(outboundKey(original.sender, original.id));
351
+ if (sent?.receipt !== undefined)
352
+ return sent.receipt.thread_ref;
353
+ // A member's copy of an owner-held message lives in its inbox, not in `entry-ref`.
354
+ const inbox = await tx.scan(key('inbox') + '/');
355
+ const held = inbox.find(row => row.value.attention.message_ref.sender === original.sender && row.value.attention.message_ref.id === original.id);
356
+ return held?.value.attention.thread_ref;
357
+ });
358
+ }
359
+ /**
360
+ * The thread a conversation starts in belongs to the node that started it (RFC 0002 §1),
361
+ * so the initiator records the thread and admits its own message there. The id is the
362
+ * message id: a retried first send resolves the same thread instead of opening a second.
363
+ */
364
+ async #startThread(message) {
365
+ const owner = this.#ctx.options.node_id;
366
+ const ref = { owner, id: message.id };
367
+ const existing = await this.#ctx.storage.transaction(tx => tx.get(key('thread', owner, ref.id)));
368
+ if (existing === undefined) {
369
+ const title = message.delivery_kind.replace(/\s+/g, ' ').trim().slice(0, 100) || message.msg.slice(0, 100);
370
+ await this.#threads.create(this.#auth(), {
371
+ idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined), title, id: ref.id,
372
+ });
373
+ }
374
+ return ref;
375
+ }
376
+ /**
377
+ * Grants the addressee `post` and `read` in a thread this node owns, once, and returns the
378
+ * member row: the grant notice is keyed by the member revision, so the caller can push that
379
+ * exact notice instead of scanning the outbox for one it might not recognize.
380
+ */
381
+ async #ensureMember(ref, peer) {
382
+ const existing = await this.#ctx.storage.transaction(tx => tx.get(key('member', ref.owner, ref.id, peer)));
383
+ if (existing !== undefined)
384
+ return existing;
385
+ await this.#threads.member(this.#auth(), ref, peer, {}, {
386
+ idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
387
+ });
388
+ return this.#ctx.storage.transaction(async (tx) => requireValue(await tx.get(key('member', ref.owner, ref.id, peer))));
389
+ }
390
+ /** Delivers the outstanding notice for one member's revision; none outstanding, nothing to push. */
391
+ async #pushGrant(_ref, member) {
392
+ const notice = await this.#ctx.storage.transaction(tx => tx.get(key('notice', member.revision)));
393
+ if (notice === undefined || notice.acked)
394
+ return;
395
+ await this.#remote(member.peer, 'POST', '/grants', { grant: notice }, undefined, this.#ctx.options.wait_ms ?? 60_000);
396
+ }
397
+ // ---- inbound path -----------------------------------------------------------
398
+ /**
399
+ * Records one inbound attention item, then acts. The durable entry and the local
400
+ * reply record commit before the ack (RFC 0003 §2); only then may a waiter be
401
+ * released, and only by an entry whose author the owner authenticated and whose
402
+ * author is the one expected. Invalid evidence is still acked but resolves nothing.
403
+ *
404
+ * The entry is trusted as-is: this is the runtime binding's own receive path, where
405
+ * the owner's authenticated response is the evidence, not a caller argument. No
406
+ * HTTP route accepts an `Entry` from a peer, so no peer can supply one.
407
+ */
408
+ async receive(entry, obligation_id, context) {
409
+ this.wakeLoop();
410
+ const attention = attentionOf(entry, obligation_id, this.#ctx.options.node_id);
411
+ const valid = this.#attest(attention, entry.thread_ref.owner);
412
+ let duplicate = false;
413
+ const queued = valid
414
+ ? await this.#ctx.storage.transaction(async (tx) => {
415
+ const stored = await tx.get(inboxKey(obligation_id));
416
+ if (stored !== undefined) {
417
+ duplicate = true;
418
+ return stored;
419
+ }
420
+ const record = { attention, ...(context === undefined ? {} : { context }), injected: false };
421
+ await tx.put(inboxKey(obligation_id), record);
422
+ return record;
423
+ })
424
+ : { attention, ...(context === undefined ? {} : { context }), injected: false };
425
+ const relation = entry.message.meta?.relation;
426
+ this.#trace(duplicate
427
+ ? { event: 'inbound.duplicate', sender: entry.message.sender, message: entry.message.id }
428
+ : {
429
+ event: 'inbound.received', obligation: obligation_id, thread: entry.thread_ref, seq: entry.seq,
430
+ sender: entry.message.sender, valid, injected: queued.injected,
431
+ ...(relation === 'reply' || relation === 'followup' ? { relation } : {}),
432
+ ...(queued.context === undefined ? {} : { context: queued.context }),
433
+ });
434
+ // `receipt` and `followup` never release a waiter; only a reply to the original does.
435
+ if (valid)
436
+ this.#settle(entry);
437
+ if (!queued.attention.acked && valid)
438
+ await this.#ack(entry.thread_ref, obligation_id);
439
+ if (valid)
440
+ await this.#consume(entry, queued);
441
+ return queued;
442
+ }
443
+ /**
444
+ * Reconciles unacked attention for every owner this node knows (RFC 0003 §2): the
445
+ * `owner + thread_ref` pairs recorded when a grant notice was accepted, plus the
446
+ * threads this node hosts, which need no grant. The owner's authenticated attention
447
+ * list is the evidence; the ack goes out only after the entry is durable locally.
448
+ * An owner this node cannot reach is reported as a trace record, never thrown: one dead
449
+ * owner must not starve the rest, and a silent skip is the failure nobody can diagnose.
450
+ * Each (owner, thread) carries its own attempt series: a call from the maintenance pass
451
+ * respects the scheduled next attempt, and an exhausted series stays a dead letter so an
452
+ * unreachable owner costs one probe per window instead of one storm per pass. A caller
453
+ * invoking this directly is asking for a retry now, so it ignores the schedule — and still
454
+ * counts the attempt, so a caller that retries in a loop reaches the same dead letter.
455
+ */
456
+ async reconcile(signal) {
457
+ return this.#reconcile(signal, false);
458
+ }
459
+ async #reconcile(signal, scheduled) {
460
+ const now = this.#ctx.now();
461
+ for (const ref of await this.#knownRefs()) {
462
+ if (signal?.aborted)
463
+ break;
464
+ const backoff = await this.#ctx.storage.transaction(tx => tx.get(reconcileBackoffKey(ref)));
465
+ if (backoff?.dead === true)
466
+ continue;
467
+ if (scheduled && backoff !== undefined && utc(backoff.next_attempt) > now)
468
+ continue;
469
+ try {
470
+ await this.#reconcileThread(ref, signal);
471
+ if (backoff !== undefined)
472
+ await this.#ctx.storage.transaction(tx => tx.delete(reconcileBackoffKey(ref)));
473
+ }
474
+ catch (error) {
475
+ if (ref.owner === this.#ctx.options.node_id)
476
+ throw error;
477
+ await this.#reportReconcileFailure(ref, error);
478
+ continue;
479
+ }
480
+ }
481
+ return this.#messages.inbox(this.#auth(), {});
482
+ }
483
+ /**
484
+ * Records one failed attempt in the thread's series and writes at most one line per window.
485
+ * A failed pull drops nothing, so the series owns the retry; an attempt that reaches the
486
+ * limit parks the thread as unreachable and writes one terminal line instead of the
487
+ * per-window one.
488
+ */
489
+ async #reportReconcileFailure(ref, error) {
490
+ const failure = failureOf(error);
491
+ const attempt = await this.#ctx.storage.transaction(async (tx) => {
492
+ const slot = reconcileBackoffKey(ref);
493
+ const previous = await tx.get(slot);
494
+ const attempt = (previous?.attempt ?? 0) + 1;
495
+ const next = {
496
+ owner: ref.owner, thread: ref.id, attempt,
497
+ next_attempt: new Date(this.#ctx.now() + backoffMs(attempt)).toISOString(),
498
+ code: failure.code, message: failure.message,
499
+ ...(attempt >= MAX_ATTEMPTS ? { dead: true } : {}),
500
+ };
501
+ await tx.put(slot, next);
502
+ return attempt;
503
+ });
504
+ if (attempt >= MAX_ATTEMPTS) {
505
+ this.#trace({ event: 'reconcile.unreachable', owner: ref.owner, thread: ref.id, code: failure.code, attempts: attempt });
506
+ return;
507
+ }
508
+ this.#windowedFailure(key(ref.owner, ref.id, failure.code), attempts => ({
509
+ event: 'reconcile.failed', owner: ref.owner, thread: ref.id,
510
+ code: failure.code, message: failure.message, attempts,
511
+ }));
512
+ }
513
+ /**
514
+ * Writes at most one failure line per scope per window: a permanently dead peer stays
515
+ * observable without writing the same line every pass, and the line reports how many
516
+ * suppressed attempts it accounts for.
517
+ */
518
+ #windowedFailure(slot, line) {
519
+ const now = this.#ctx.now();
520
+ const held = this.#failureWindows.get(slot);
521
+ if (held !== undefined && now - held.at < FAILURE_WINDOW_MS) {
522
+ held.suppressed += 1;
523
+ return;
524
+ }
525
+ this.#failureWindows.set(slot, { at: now, suppressed: 0 });
526
+ this.#trace(line((held?.suppressed ?? 0) + 1));
527
+ }
528
+ /**
529
+ * Delivers outstanding owner grant notices, replays owed todos and entries whose durable
530
+ * record never reached the model, re-asks trusted owners for the memberships they hold for
531
+ * this node, re-drives owed trust statements, then reconciles. Replay follows each thread's
532
+ * own order: `owner`, `id`, then `seq` as a number. One record that fails never starves the
533
+ * rest, and the failures are thrown after the pass with their records still owed for the
534
+ * next one. Caller-scheduled, and the only entry a host without a maintenance clock needs
535
+ * besides `recover`.
536
+ */
537
+ async maintain(signal) {
538
+ await this.#deliverGrants(signal);
539
+ if (signal?.aborted)
540
+ return;
541
+ await this.#confirmGrants(signal);
542
+ if (signal?.aborted)
543
+ return;
544
+ // Before reconciliation, so rows this repairs are pulled in the same pass.
545
+ await this.#resyncGrants(signal);
546
+ if (signal?.aborted)
547
+ return;
548
+ const failures = [];
549
+ // Owed todos replay before the inbox pass, so a hook that fails now waits for the next
550
+ // pass instead of being retried inside this one.
551
+ await this.#replayTodos(signal, failures);
552
+ if (signal?.aborted)
553
+ return;
554
+ await this.#replayInbox(signal, failures);
555
+ if (signal?.aborted)
556
+ return;
557
+ try {
558
+ await this.#reconcile(signal, true);
559
+ }
560
+ catch (error) {
561
+ failures.push(error);
562
+ }
563
+ // Trust re-drive belongs to the pass, not to the loop: a host that schedules `maintain`
564
+ // itself must still deliver owed statements (RFC 0017 §3.5).
565
+ try {
566
+ await this.#trust?.redrive(signal);
567
+ }
568
+ catch (error) {
569
+ failures.push(error);
570
+ }
571
+ if (failures.length === 1)
572
+ throw failures[0];
573
+ if (failures.length > 1)
574
+ throw new AggregateError(failures, 'Some owed records could not be settled; the rest were.');
575
+ }
576
+ /**
577
+ * Whether any durable record still needs a local pass: an unacked grant notice, an
578
+ * owed todo, an uninjected inbox row, or an outbound still owed an attempt. A scheduler
579
+ * consults this before running `maintain`, so an idle node costs no rounds; anything
580
+ * that flips this on (an admission, an ack, a failed delivery) also wakes the host.
581
+ * A dead-lettered send is terminal, and a scheduled retry counts only once it is due, so
582
+ * neither holds the active cadence for a peer that is simply away.
583
+ */
584
+ async hasPendingWork() {
585
+ return this.#ctx.storage.transaction(async (tx) => {
586
+ const now = this.#ctx.now();
587
+ const inbox = await tx.scan(key('inbox') + '/');
588
+ if (inbox.some(row => !row.value.attention.acked || !row.value.injected && row.value.attention.entry !== undefined && row.value.attention.disposition !== 'expired'))
589
+ return true;
590
+ const todos = await tx.scan(key('todo') + '/');
591
+ if (todos.some(row => !row.value.done))
592
+ return true;
593
+ const outbound = await tx.scan(key('outbound') + '/');
594
+ if (outbound.some(row => row.value.digest !== undefined && (row.value.state === 'pending'
595
+ || (row.value.state === 'unknown' && (row.value.next_attempt === undefined || utc(row.value.next_attempt) <= now)))))
596
+ return true;
597
+ // A grant notice is how a participant learns the thread it must reconcile, so an
598
+ // unacked notice is work: skipping it leaves the thread undiscoverable.
599
+ const notices = await tx.scan(key('notice') + '/');
600
+ if (notices.some(row => row.value.acked !== true))
601
+ return true;
602
+ const grants = await tx.scan(key('grant') + '/');
603
+ if (grants.some(row => row.value.acked !== true))
604
+ return true;
605
+ // An approval whose statement the peer has not confirmed is owed work too: the loop
606
+ // re-drives it once the retry window opens (RFC 0017 §3.5).
607
+ // A backed-off obligation still counts once its delay has passed; until then the idle
608
+ // cadence is enough to notice it, so it does not force the active one.
609
+ const exchanges = await tx.scan(key('trust-outbound') + '/');
610
+ if (exchanges.some(row => row.value.blocked === undefined || row.value.blocked <= now))
611
+ return true;
612
+ return false;
613
+ });
614
+ }
615
+ /** Replays todos that are owed but whose hook has not succeeded, in thread order. */
616
+ async #replayTodos(signal, failures) {
617
+ const owed = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('todo') + '/')).map(row => row.value).filter(row => !row.done));
618
+ owed.sort((a, b) => replayOrder(a.entry, b.entry));
619
+ for (const row of owed) {
620
+ if (signal?.aborted)
621
+ return;
622
+ try {
623
+ await this.#deliverTodo(row.entry, row.context);
624
+ }
625
+ catch (error) {
626
+ failures.push(error);
627
+ }
628
+ }
629
+ }
630
+ /**
631
+ * Replays inbox records the model has not seen. A record already delivered is skipped, but
632
+ * an entry durable here still answers a live await, and its owed todo replays above even
633
+ * when the record is already injected.
634
+ */
635
+ async #replayInbox(signal, failures) {
636
+ const queued = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('inbox') + '/')).map(row => row.value));
637
+ queued.sort((a, b) => replayOrder(a.attention.entry, b.attention.entry));
638
+ for (const record of queued) {
639
+ if (signal?.aborted)
640
+ return;
641
+ const entry = record.attention.entry;
642
+ if (entry === undefined)
643
+ continue;
644
+ // An entry already durable here answers a live await even when the runtime never saw it.
645
+ this.#settle(entry);
646
+ if (record.injected || record.attention.disposition === 'expired')
647
+ continue;
648
+ try {
649
+ await this.#consume(entry, record);
650
+ }
651
+ catch (error) {
652
+ failures.push(error);
653
+ }
654
+ }
655
+ }
656
+ // ---- the single agent entry --------------------------------------------------
657
+ /**
658
+ * The one call a model makes. SystemOne turns the intent into a typed action, the
659
+ * decision is persisted with its input, then exactly that action runs. With no
660
+ * usable decision the request fails: the library never picks a plan itself.
661
+ */
662
+ async msg(input, options = {}) {
663
+ if (input == null || typeof input.delivery_kind !== 'string' || typeof input.msg !== 'string') {
664
+ fail('invalid_request', 'msg needs string delivery_kind and msg fields.');
665
+ }
666
+ const action = await this.#decide(input);
667
+ if (action.kind !== 'send')
668
+ return this.#dispatch(action, options);
669
+ const prepared = this.#prepare(input, action);
670
+ const outcome = await this.send(prepared.to, prepared.message, prepared.plan, { ...options, ...(typeof input.meta?.to === 'string' ? { requested: input.meta.to } : {}) });
671
+ return this.#addressed(outcome, input.meta?.to, prepared.to);
672
+ }
673
+ async prepare(input) {
674
+ const action = await this.#decide(input);
675
+ if (action.kind !== 'send')
676
+ fail('invalid_request', 'A draft must describe a message to send.');
677
+ return this.#prepare(input, action);
678
+ }
679
+ async readResources(query, options = {}) {
680
+ const auth = this.#auth();
681
+ const search = new URLSearchParams();
682
+ if ('cursor' in query && query.cursor !== undefined)
683
+ search.set('cursor', query.cursor);
684
+ if ('page_size' in query && query.page_size !== undefined)
685
+ search.set(query.op === 'log' ? 'limit' : 'page_size', String(query.page_size));
686
+ if (query.op === 'log' && query.since !== undefined)
687
+ search.set('since', query.since);
688
+ const suffix = search.size === 0 ? '' : `?${search}`;
689
+ const remote = (owner, path) => this.#remote(owner, 'GET', path + suffix, undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
690
+ if (query.op === 'list') {
691
+ const owners = [...new Set(query.owners ?? [this.#ctx.node])];
692
+ if (query.cursor !== undefined && owners.length !== 1)
693
+ fail('invalid_request', 'A resource cursor names exactly one owner.');
694
+ const rows = await Promise.all(owners.map(async (owner) => {
695
+ try {
696
+ const result = owner === this.#ctx.node
697
+ ? await this.#resources.list(auth, query)
698
+ : await remote(owner, '/resources');
699
+ return { owner, page: result };
700
+ }
701
+ catch (error) {
702
+ if (options.signal?.aborted)
703
+ throw error;
704
+ return { owner, error: failureOf(error) };
705
+ }
706
+ }));
707
+ return { owners: rows };
708
+ }
709
+ const ref = query.resource;
710
+ const local = ref.owner === this.#ctx.node;
711
+ const path = `/resources/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`;
712
+ switch (query.op) {
713
+ case 'describe': return local ? this.#resources.describe(auth, ref) : remote(ref.owner, path);
714
+ case 'log': return local ? this.#resources.log(auth, ref, query) : remote(ref.owner, path + '/log');
715
+ case 'event': return local ? this.#resources.event(auth, ref, query.event_id) : remote(ref.owner, path + `/log/${encodeURIComponent(query.event_id)}`);
716
+ case 'operations': return local ? this.#resources.operations(auth, ref, query) : remote(ref.owner, path + '/operations');
717
+ case 'status': return local ? this.#resources.operation(auth, ref, query.operation_id) : remote(ref.owner, path + `/operations/${encodeURIComponent(query.operation_id)}`);
718
+ }
719
+ }
720
+ #prepare(input, action) {
721
+ if (action.response === undefined || action.execution === undefined) {
722
+ fail('decision_unavailable', 'A send needs response and execution; no plan was chosen.');
723
+ }
724
+ const meta = input.meta;
725
+ // RFC 0003 §1: the decision may carry the work unit, and a caller may name it in the
726
+ // input meta; either way it becomes the first-class field a receiver dedupes on.
727
+ const work = action.work !== undefined
728
+ ? workRef(action.work, 'action.work')
729
+ : meta?.work === undefined ? undefined : workRef(meta.work, 'message.meta.work');
730
+ const relation = action.in_reply_to === undefined ? action.relation : action.relation ?? 'reply';
731
+ const message = {
732
+ id: this.#ctx.id(),
733
+ sender: this.#ctx.options.node_id,
734
+ delivery_kind: input.delivery_kind,
735
+ msg: input.msg,
736
+ ...(action.thread === undefined && action.in_reply_to === undefined && action.mentions === undefined
737
+ && work === undefined && meta === undefined
738
+ ? {}
739
+ : {
740
+ meta: {
741
+ ...(work === undefined ? {} : { work }),
742
+ ...(action.thread === undefined ? {} : { thread: action.thread }),
743
+ ...(action.in_reply_to === undefined ? {} : { in_reply_to: action.in_reply_to }),
744
+ ...(relation === undefined ? {} : { relation }),
745
+ ...(action.mentions === undefined ? {} : { mentions: action.mentions }),
746
+ ...(meta === undefined ? {} : { data: meta }),
747
+ },
748
+ }),
749
+ };
750
+ const plan = {
751
+ response: action.response, execution: action.execution,
752
+ ...(action.context === undefined ? {} : { context: action.context }),
753
+ };
754
+ // A thread this node owns stores the entry locally; `action.to` is the participant the
755
+ // message addresses, and the two differ whenever an initiator writes to a member.
756
+ return { to: action.to, message, plan };
757
+ }
758
+ /**
759
+ * The caller's own spelling beside the node the decision resolved it to. Equal spellings say
760
+ * nothing new; a bare alias that resolved to a peer is the case the receipt cannot show. A
761
+ * `resolved` the send already derived (the addressee of an initiator's own thread) is kept.
762
+ */
763
+ #addressed(outcome, requested, resolved) {
764
+ const merged = outcome.resolved === undefined ? { ...outcome, resolved } : outcome;
765
+ return typeof requested !== 'string' || requested === resolved ? merged : { ...merged, requested };
766
+ }
767
+ // ---- internals ---------------------------------------------------------------
768
+ #auth() {
769
+ return { actor: this.#ctx.options.node_id };
770
+ }
771
+ /** Persists the decision and its outcome. RFC 0005 §2: a decision the library cannot act on is a recorded failure. */
772
+ async #decide(input) {
773
+ const decision_id = this.#ctx.id();
774
+ const at = new Date(this.#ctx.now()).toISOString();
775
+ let action;
776
+ try {
777
+ action = await this.#ctx.options.plugins.decide(input);
778
+ }
779
+ catch (error) {
780
+ const failure = failureOf(error);
781
+ await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), { input, at, error: failure }));
782
+ if (failure.code !== 'decision_unavailable' && failure.code !== 'unavailable')
783
+ throw error;
784
+ fail('decision_unavailable', failure.message);
785
+ }
786
+ if (action == null || typeof action !== 'object' || !['send', 'read', 'thread', 'resource', 'domain'].includes(action.kind)) {
787
+ await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), {
788
+ input, at,
789
+ error: { code: 'decision_unavailable', message: 'The decision is not an executable action.', retryable: true },
790
+ }));
791
+ fail('decision_unavailable', 'The decision is not an executable action.');
792
+ }
793
+ await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), { input, action, at }));
794
+ return action;
795
+ }
796
+ /**
797
+ * Only `await` needs the host capability, and it is checked before sending (RFC 0005 §5).
798
+ * `async` ends at the peer and never holds the call open, so it needs nothing here.
799
+ */
800
+ async #ensureAwaitable(plan, signal) {
801
+ if (signal?.aborted)
802
+ throw signal.reason ?? new Error('The send was cancelled.');
803
+ const runtime = this.#ctx.options.plugins.runtime;
804
+ if (runtime === undefined || !(await runtime.supports(plan))) {
805
+ fail('unsupported_delivery', 'The host cannot hold this await, so no message was sent.');
806
+ }
807
+ }
808
+ /** RFC 0003 §8: the declared window never exceeds the node's supported maximum. */
809
+ #window(requested) {
810
+ const ceiling = new Date(this.#ctx.now() + (this.#ctx.options.retry_max_ms ?? 86_400_000)).toISOString();
811
+ if (requested === undefined)
812
+ return ceiling;
813
+ return utc(requested) < utc(ceiling) ? requested : ceiling;
814
+ }
815
+ /** One attempt's budget: the wait bound, never past the admission window. */
816
+ #attempt(record) {
817
+ const wait = this.#ctx.options.wait_ms ?? 60_000;
818
+ const remaining = utc(record.request.retry_until) - this.#ctx.now();
819
+ return Math.max(1, Math.min(wait, remaining));
820
+ }
821
+ /**
822
+ * A network call that cannot outlive its attempt: the caller's signal cancels it, and
823
+ * even with no signal the attempt ends on its own deadline, so an unreachable peer or
824
+ * a hanging resolver cannot pin a delivery forever (RFC 0005 §3).
825
+ */
826
+ async #remote(target, method, path, body, signal, budget) {
827
+ const controller = new AbortController();
828
+ const timer = setTimeout(() => controller.abort(new Error('The attempt exceeded its deadline.')), budget);
829
+ const onAbort = () => controller.abort(signal?.reason);
830
+ if (signal !== undefined) {
831
+ if (signal.aborted)
832
+ controller.abort(signal.reason);
833
+ else
834
+ signal.addEventListener('abort', onAbort, { once: true });
835
+ }
836
+ try {
837
+ return await this.#transport.request(target, method, path, body, controller.signal);
838
+ }
839
+ finally {
840
+ clearTimeout(timer);
841
+ signal?.removeEventListener('abort', onAbort);
842
+ }
843
+ }
844
+ async #transmit(record, signal) {
845
+ const request = record.request;
846
+ try {
847
+ const thread = request.message.meta?.thread;
848
+ // The thread owner stores the entry. When that owner is this node the admission is
849
+ // local: the same service, the same idempotency and the same receipt, no loopback call.
850
+ const receipt = request.target_endpoint === this.#ctx.options.node_id
851
+ ? await this.#messages.admit(this.#auth(), request)
852
+ : await this.#remote(request.target_endpoint, 'POST', thread === undefined
853
+ ? '/messages'
854
+ : `/threads/${encodeURIComponent(thread.owner)}/${encodeURIComponent(thread.id)}/messages`, request, signal, this.#attempt(record));
855
+ await this.#commit(record, { state: 'accepted', receipt });
856
+ const guarantee = receipt.guarantee;
857
+ this.#trace({
858
+ event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
859
+ target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
860
+ state: 'accepted', ...(typeof guarantee === 'string' ? { guarantee } : {}),
861
+ });
862
+ return { admission: 'accepted', receipt };
863
+ }
864
+ catch (error) {
865
+ if (signal?.aborted)
866
+ throw error;
867
+ const failure = failureOf(error);
868
+ const state = admissionOf(failure);
869
+ // A retryable failure is one attempt of a bounded series: the next attempt is scheduled,
870
+ // and an exhausted series becomes a dead letter instead of being re-driven on every pass.
871
+ // A recorded refusal is final and carries no series at all.
872
+ const previous = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(request.message.sender, request.message.id)));
873
+ const attempt = state === 'refused' ? undefined : (previous?.attempt ?? 0) + 1;
874
+ const terminal = attempt !== undefined && attempt >= MAX_ATTEMPTS;
875
+ await this.#commit(record, {
876
+ state: terminal ? 'unreachable' : state,
877
+ error: failure,
878
+ ...(attempt === undefined ? {} : { attempt }),
879
+ ...(attempt === undefined || terminal ? {} : { next_attempt: new Date(this.#ctx.now() + backoffMs(attempt)).toISOString() }),
880
+ });
881
+ this.#trace({
882
+ event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
883
+ target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
884
+ state: terminal ? 'unreachable' : state, code: failure.code,
885
+ });
886
+ if (terminal)
887
+ this.#trace({
888
+ event: 'outbound.unreachable', sender: request.message.sender, message: request.message.id,
889
+ target: request.target_endpoint, attempts: attempt, code: failure.code,
890
+ });
891
+ const stored = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(request.message.sender, request.message.id)));
892
+ return {
893
+ admission: stored?.state ?? (terminal ? 'unreachable' : state),
894
+ ...(stored?.state === 'accepted' ? {} : { error: failure }),
895
+ ...(stored?.receipt === undefined ? (record.receipt === undefined ? {} : { receipt: record.receipt }) : { receipt: stored.receipt }),
896
+ };
897
+ }
898
+ }
899
+ /**
900
+ * Deliver, then wait on the one thing that can answer: a reply whose author the owner
901
+ * authenticated. A timeout ends only this wait; it never invents a state for the
902
+ * delivery, so a still-open record stays open for recovery.
903
+ */
904
+ async #await(record, waiter, signal) {
905
+ const reply = await this.#awaitOutcome(record, waiter, signal);
906
+ if (reply.reply === undefined)
907
+ return reply;
908
+ // A body already confirmed consumed in this context is rendered as arrival facts only
909
+ // (RFC 0008 §5). The sender's own consumption identity decides; the plan's context
910
+ // belongs to the receiver and is never this caller's. A generic caller that names none
911
+ // is the node itself.
912
+ const context = record.local_context ?? this.#ctx.options.node_id;
913
+ const consumed = await this.#alreadyConsumed(reply.reply, context);
914
+ // The body is now in the caller's hands as a tool result: hold it until the host confirms
915
+ // consumption, so no automatic fragment presents the same body in that context again. A
916
+ // wait that is cancelled or times out returns no reply, so it never places a hold.
917
+ if (!consumed) {
918
+ this.#awaiting.hold(reply.reply, context);
919
+ }
920
+ // Returning the body does not by itself write the record: RFC 0008 §5 forbids advancing
921
+ // consumption on a submitted tool result. The host calls `confirmConsumption` once it has
922
+ // observed the model consume the result.
923
+ return { ...reply, ...(consumed ? { reply_body: 'withheld' } : {}) };
924
+ }
925
+ /**
926
+ * `confirmConsumption(entry, context?, via = 'fragment'): Promise<void>`
927
+ *
928
+ * The host's confirmation that this entry's body was consumed by the path it names
929
+ * (RFC 0008 §5). One commit writes the shared consumption slot, marks every matching inbox
930
+ * record delivered, and records the todo this consumption owes; the host's todo hook runs
931
+ * outside that commit, and the todo is filed where the model saw the body. `context` names
932
+ * that place; with none, the context the entry landed in decides, then this node. A failing
933
+ * hook leaves the todo owed, so `maintain` replays it, and replaying is safe because the
934
+ * host commits the todo it creates. This also releases the hold an awaited tool result
935
+ * placed on the entry for that context.
936
+ */
937
+ async confirmConsumption(entry, context, via = 'fragment') {
938
+ const at = new Date(this.#ctx.now()).toISOString();
939
+ const consumedIn = await this.#ctx.storage.transaction(async (tx) => {
940
+ // A row keeps the context the entry landed in; the confirmation context only fills in
941
+ // for a row that names none.
942
+ let original;
943
+ let settled = false;
944
+ let consumable = false;
945
+ for (const row of await tx.scan(key('inbox') + '/')) {
946
+ const held = row.value.attention.entry;
947
+ if (held === undefined)
948
+ continue;
949
+ if (held.thread_ref.owner !== entry.thread_ref.owner || held.thread_ref.id !== entry.thread_ref.id)
950
+ continue;
951
+ if (held.seq !== entry.seq)
952
+ continue;
953
+ if (row.value.settlement !== undefined) {
954
+ settled = true;
955
+ continue;
956
+ }
957
+ consumable = true;
958
+ original = original ?? row.value.context;
959
+ if (row.value.injected)
960
+ continue;
961
+ const landed = row.value.context ?? context;
962
+ await tx.put(row.key, { ...row.value, injected: true, ...(landed === undefined ? {} : { context: landed }) });
963
+ }
964
+ if (settled && !consumable)
965
+ return undefined;
966
+ const where = context ?? original ?? this.#ctx.options.node_id;
967
+ const slot = consumptionKey(where, entry.thread_ref, entry.seq);
968
+ const stored = await tx.get(slot);
969
+ if (stored?.confirmed !== true) {
970
+ await tx.put(slot, {
971
+ context: where, thread_ref: entry.thread_ref, seq: entry.seq, confirmed: true, via, at,
972
+ });
973
+ }
974
+ await this.#oweTodo(tx, entry, where);
975
+ return where;
976
+ });
977
+ if (consumedIn === undefined) {
978
+ this.#awaiting.forget(entry);
979
+ return;
980
+ }
981
+ this.#awaiting.releaseContext(entry, consumedIn);
982
+ this.#trace({ event: 'consumption.confirmed', thread: entry.thread_ref, seq: entry.seq, context: consumedIn, via });
983
+ this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
984
+ await this.#deliverTodo(entry, consumedIn);
985
+ }
986
+ /** `confirmToolResult(entry, context?): Promise<void>`: the same settlement for an awaited tool result. */
987
+ async confirmToolResult(entry, context) {
988
+ await this.confirmConsumption(entry, context, 'tool_result');
989
+ }
990
+ /**
991
+ * `isWaitingReply(entry, context?): boolean`
992
+ *
993
+ * Whether this entry answers an `await` that owns its body here, optionally only one whose
994
+ * sender-local context is `context`: a waiter still running, or a reply already returned as
995
+ * a tool result and not yet confirmed consumed. A host defers the automatic fragment on
996
+ * `true`, because the tool result either holds or already presented the same body. A
997
+ * cancelled wait returns no reply, so it holds nothing.
998
+ */
999
+ isWaitingReply(entry, context) {
1000
+ return this.#awaiting.waiting(entry, context);
1001
+ }
1002
+ async #awaitOutcome(record, waiter, signal) {
1003
+ // The request and the wait start together: a reply that is already durable here, or that
1004
+ // lands while the request is still in flight, answers this call without waiting for the
1005
+ // response. The request keeps running and records its admission when it lands.
1006
+ const budget = this.#attempt(record);
1007
+ const deadline = Date.now() + budget;
1008
+ const sending = this.#transmit(record, signal);
1009
+ const first = await this.#watch(record, waiter, signal, budget, sending);
1010
+ if ('failed' in first) {
1011
+ if (signal?.aborted)
1012
+ return { admission: record.state, wait: 'cancelled' };
1013
+ throw first.failed;
1014
+ }
1015
+ if ('replied' in first)
1016
+ return { admission: record.state, ...(record.receipt === undefined ? {} : { receipt: record.receipt }), reply: first.replied, wait: 'answered' };
1017
+ if ('evidence' in first)
1018
+ return { admission: record.state, ...(record.receipt === undefined ? {} : { receipt: record.receipt }), reply: first.evidence, wait: 'answered' };
1019
+ if ('cancelled' in first)
1020
+ return { admission: record.state, wait: 'cancelled' };
1021
+ // The wait bound can pass just before the request's own deadline fires. Its outcome still
1022
+ // belongs to this call, so collect it rather than leave the attempt unobserved.
1023
+ const delivered = 'delivered' in first ? first.delivered : await sending;
1024
+ if (delivered.admission !== 'accepted')
1025
+ return delivered;
1026
+ // Admitted but unanswered: only a reply or the remaining deadline can end this.
1027
+ const receipt = delivered.receipt !== undefined
1028
+ ? { receipt: delivered.receipt }
1029
+ : record.receipt === undefined ? {} : { receipt: record.receipt };
1030
+ const remaining = Math.max(1, deadline - Date.now());
1031
+ const second = await this.#watch(record, waiter, signal, remaining, undefined);
1032
+ if ('failed' in second) {
1033
+ if (signal?.aborted)
1034
+ return { admission: 'accepted', ...receipt, wait: 'cancelled' };
1035
+ throw second.failed;
1036
+ }
1037
+ if ('replied' in second)
1038
+ return { admission: 'accepted', ...receipt, reply: second.replied, wait: 'answered' };
1039
+ if ('evidence' in second)
1040
+ return { admission: 'accepted', ...receipt, reply: second.evidence, wait: 'answered' };
1041
+ if ('cancelled' in second)
1042
+ return { admission: 'accepted', ...receipt, wait: 'cancelled' };
1043
+ // The budget passed: one last look at durable evidence, then stop asking.
1044
+ const late = await this.#evidence(record);
1045
+ if (late !== undefined)
1046
+ return { admission: 'accepted', ...receipt, reply: late, wait: 'answered' };
1047
+ return { admission: 'accepted', ...receipt, wait: 'timed_out' };
1048
+ }
1049
+ /**
1050
+ * One bounded watch over every signal that can end a wait: a live reply, durable reply
1051
+ * evidence, the request settling, cancellation, or the deadline. Exactly one timer and one
1052
+ * abort listener exist, and both are released whichever signal wins.
1053
+ */
1054
+ async #watch(record, waiter, signal, budget, sending) {
1055
+ const evidence = this.#evidence(record);
1056
+ return new Promise(resolve => {
1057
+ let done = false;
1058
+ let timer;
1059
+ const onAbort = () => finish({ cancelled: true });
1060
+ const finish = (outcome) => {
1061
+ if (done)
1062
+ return;
1063
+ done = true;
1064
+ clearTimeout(timer);
1065
+ signal?.removeEventListener('abort', onAbort);
1066
+ resolve(outcome);
1067
+ };
1068
+ timer = setTimeout(() => finish({ elapsed: true }), budget);
1069
+ if (signal?.aborted)
1070
+ finish({ cancelled: true });
1071
+ else
1072
+ signal?.addEventListener('abort', onAbort, { once: true });
1073
+ void waiter.outcome.then(entry => finish({ replied: entry }));
1074
+ void evidence.then(entry => { if (entry !== undefined)
1075
+ finish({ evidence: entry }); }, error => finish({ failed: error }));
1076
+ if (sending !== undefined) {
1077
+ void sending.then(delivered => finish({ delivered }), error => finish({ failed: error }));
1078
+ }
1079
+ });
1080
+ }
1081
+ /**
1082
+ * Acceptance is final. The digest is written with every state change, so recovery and
1083
+ * replay keep recognising the message identity instead of losing it to a bare record.
1084
+ */
1085
+ async #commit(record, change) {
1086
+ const request = record.request;
1087
+ const digest = record.digest ?? messageDigest(request);
1088
+ await this.#ctx.storage.transaction(async (tx) => {
1089
+ const slot = outboundKey(request.message.sender, request.message.id);
1090
+ const current = await tx.get(slot);
1091
+ if (current?.state === 'accepted')
1092
+ return;
1093
+ const next = { ...record, ...change, digest };
1094
+ if (next.state === 'accepted')
1095
+ delete next.error;
1096
+ // A settled record owes no further attempt, and a dead letter must not carry a schedule
1097
+ // a later pass would read as permission to try again.
1098
+ if (next.state === 'accepted' || next.state === 'refused' || next.state === 'unreachable') {
1099
+ delete next.next_attempt;
1100
+ if (next.state !== 'unreachable')
1101
+ delete next.attempt;
1102
+ }
1103
+ await tx.put(slot, next);
1104
+ });
1105
+ }
1106
+ /** Legal reply evidence already on this node for the given original (RFC 0005 §3). */
1107
+ async #evidence(record) {
1108
+ const expected = record.expected_author;
1109
+ const found = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('inbox') + '/'))
1110
+ .map(row => row.value.attention.entry)
1111
+ .filter((entry) => entry !== undefined && isReply(record.request.message, entry) && entry.authenticated_author === expected));
1112
+ return found.sort((a, b) => (decimal(a.seq) < decimal(b.seq) ? -1 : decimal(a.seq) > decimal(b.seq) ? 1 : 0))[0];
1113
+ }
1114
+ /** Releases every live await whose original this entry answers, in this process. */
1115
+ #settle(entry) {
1116
+ const settled = this.#awaiting.settle(entry);
1117
+ if (settled > 0)
1118
+ this.#trace({ event: 'await.settled', thread: entry.thread_ref, seq: entry.seq, waiters: settled });
1119
+ }
1120
+ /**
1121
+ * Owner-authenticated evidence only: the entry's own claims grant nothing. `owner` is
1122
+ * the node the evidence came from, so a remote owner's attention list attests just as
1123
+ * strongly as this node's own threads do, and nothing else does. `local` is the copy this
1124
+ * node already holds, used when the owner's row carries no body.
1125
+ */
1126
+ #attest(attention, owner, local) {
1127
+ if (attention.recipient !== this.#ctx.options.node_id)
1128
+ return false;
1129
+ if (attention.thread_ref?.owner !== owner)
1130
+ return false;
1131
+ const entry = attention.entry ?? local;
1132
+ if (entry === undefined)
1133
+ return attention.disposition === 'expired';
1134
+ const ref = attention.thread_ref;
1135
+ return entry.thread_ref.owner === ref.owner
1136
+ && entry.thread_ref.id === ref.id
1137
+ && entry.seq === attention.seq
1138
+ && entry.message.sender === attention.message_ref.sender
1139
+ && entry.message.id === attention.message_ref.id
1140
+ && entry.authenticated_author === attention.authenticated_author
1141
+ && entry.message.sender === entry.authenticated_author
1142
+ && typeof entry.authenticated_author === 'string' && entry.authenticated_author.length > 0
1143
+ && /^(0|[1-9][0-9]*)$/.test(entry.seq);
1144
+ }
1145
+ async #ack(ref, obligation_id) {
1146
+ const mutation = { idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) };
1147
+ const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/attention/${encodeURIComponent(obligation_id)}/ack`;
1148
+ let acked;
1149
+ try {
1150
+ if (ref.owner === this.#ctx.options.node_id)
1151
+ await this.#messages.ack(this.#auth(), ref, obligation_id, mutation);
1152
+ else
1153
+ await this.#remote(ref.owner, 'POST', path, mutation, undefined, this.#ctx.options.wait_ms ?? 60_000);
1154
+ }
1155
+ catch {
1156
+ // Ack is idempotent and reconciliation re-sends it; the entry stays durable either way.
1157
+ return;
1158
+ }
1159
+ await this.#ctx.storage.transaction(async (tx) => {
1160
+ const stored = await tx.get(inboxKey(obligation_id));
1161
+ if (stored !== undefined && !stored.attention.acked) {
1162
+ await tx.put(inboxKey(obligation_id), { ...stored, attention: { ...stored.attention, acked: true } });
1163
+ acked = { thread: stored.attention.thread_ref, seq: stored.attention.seq };
1164
+ }
1165
+ });
1166
+ if (acked !== undefined)
1167
+ this.#trace({ event: 'inbound.acked', obligation: obligation_id, thread: acked.thread, seq: acked.seq });
1168
+ }
1169
+ /**
1170
+ * Consumed is recorded only after the host observed the entry in the context. The
1171
+ * context is the obligation's own, then the plan's, then this node; the host may
1172
+ * answer with the context it really used, and that is the one the todo follows.
1173
+ */
1174
+ async #consume(entry, record) {
1175
+ if (record.injected) {
1176
+ this.#injectTrace(entry, record, 'skipped', 'injected');
1177
+ return;
1178
+ }
1179
+ if (await this.#settleResourceAttention(entry, record))
1180
+ return;
1181
+ const runtime = this.#ctx.options.plugins.runtime;
1182
+ if (runtime === undefined) {
1183
+ this.#injectTrace(entry, record, 'skipped', 'no-runtime');
1184
+ return;
1185
+ }
1186
+ const related = entry.message.meta?.in_reply_to;
1187
+ const outbound = related?.sender === this.#ctx.options.node_id
1188
+ ? await this.#ctx.storage.transaction(tx => tx.get(outboundKey(related.sender, related.id)))
1189
+ : undefined;
1190
+ const replyContext = outbound !== undefined && outbound.expected_author === entry.authenticated_author
1191
+ && isReply(outbound.request.message, entry) ? outbound.local_context ?? this.#ctx.options.node_id : undefined;
1192
+ const requested = replyContext ?? record.context ?? record.attention.plan?.context ?? this.#ctx.options.node_id;
1193
+ // An await that owns this reply's body in that context either still holds the call or
1194
+ // already returned it as a tool result. Presenting the same body as a fragment here
1195
+ // would duplicate it; the await's confirmation releases the hold.
1196
+ if (this.isWaitingReply(entry, requested)) {
1197
+ this.#injectTrace(entry, record, 'waiting', 'await-hold', requested);
1198
+ return;
1199
+ }
1200
+ // The entry may already be confirmed consumed in that context through an awaited tool
1201
+ // result; presenting it again as a fragment is the duplicate RFC 0008 §5 forbids. The
1202
+ // shared record and the todo it owes are then all that is left to write.
1203
+ if (await this.#alreadyConsumed(entry, requested)) {
1204
+ this.#injectTrace(entry, record, 'consumed', 'already-consumed', requested);
1205
+ await this.confirmConsumption(entry, requested, 'tool_result');
1206
+ return;
1207
+ }
1208
+ const injected = await runtime.inject(entry, requested, record.attention.plan);
1209
+ this.#injectTrace(entry, record, injected.outcome ?? (injected.consumed ? 'consumed' : 'delivered'), undefined, injected.context);
1210
+ if (!injected.consumed)
1211
+ return;
1212
+ await this.confirmConsumption(entry, injected.context, 'fragment');
1213
+ }
1214
+ async #settleResourceAttention(entry, record) {
1215
+ const ref = entry.message.meta?.data?.resource_ref;
1216
+ if (typeof ref !== 'object' || ref === null || Array.isArray(ref)
1217
+ || typeof ref.owner !== 'string' || typeof ref.id !== 'string'
1218
+ || ref.owner !== entry.authenticated_author)
1219
+ return false;
1220
+ const resource_ref = { owner: ref.owner, id: ref.id };
1221
+ let settlement;
1222
+ const at = new Date(this.#ctx.now()).toISOString();
1223
+ try {
1224
+ const resource = await this.readResources({ op: 'describe', resource: resource_ref }, { signal: AbortSignal.timeout(RESOURCE_CHECK_MS) });
1225
+ if (resource.binding.handler === this.#ctx.node)
1226
+ return false;
1227
+ settlement = { via: 'handover', resource_ref, handler: resource.binding.handler, revision: resource.revision, at };
1228
+ }
1229
+ catch (error) {
1230
+ const failure = failureOf(error);
1231
+ if (failure.code !== 'not_found') {
1232
+ const attempts = await this.#ctx.storage.transaction(async (tx) => {
1233
+ const slot = inboxKey(record.attention.obligation_id);
1234
+ const current = await tx.get(slot);
1235
+ if (current === undefined || current.injected)
1236
+ return undefined;
1237
+ const attempts = (current.resource_check_failures ?? 0) + 1;
1238
+ await tx.put(slot, { ...current, resource_check_failures: attempts });
1239
+ return attempts;
1240
+ });
1241
+ if (attempts !== undefined)
1242
+ this.#trace({
1243
+ event: 'inbound.resource-check.failed', thread: entry.thread_ref, seq: entry.seq,
1244
+ resource_ref, code: failure.code, attempts,
1245
+ });
1246
+ throw error;
1247
+ }
1248
+ settlement = { via: 'undeliverable', resource_ref, reason: 'resource-unavailable', at };
1249
+ }
1250
+ const changed = await this.#ctx.storage.transaction(async (tx) => {
1251
+ const slot = inboxKey(record.attention.obligation_id);
1252
+ const current = await tx.get(slot);
1253
+ if (current === undefined || current.injected)
1254
+ return false;
1255
+ await tx.put(slot, { ...current, injected: true, settlement });
1256
+ return true;
1257
+ });
1258
+ if (changed) {
1259
+ this.#awaiting.forget(entry);
1260
+ this.#trace({ event: 'inbound.settled', thread: entry.thread_ref, seq: entry.seq, settlement });
1261
+ this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
1262
+ }
1263
+ return true;
1264
+ }
1265
+ /**
1266
+ * Records the todo this consumption owes, inside the commit that confirms the consumption.
1267
+ * A host without a todo hook owes none, so no record accrues for it.
1268
+ */
1269
+ async #oweTodo(tx, entry, context) {
1270
+ if (this.#ctx.options.plugins.runtime?.todo === undefined)
1271
+ return;
1272
+ const slot = todoKey(context, entry.thread_ref, entry.seq);
1273
+ if (await tx.get(slot) === undefined) {
1274
+ await tx.put(slot, { entry, context, done: false });
1275
+ }
1276
+ }
1277
+ /**
1278
+ * Runs the host's todo hook for one owed todo, then marks it done. A failing hook leaves
1279
+ * the record pending for `maintain`; delivering twice is safe because the host commits
1280
+ * the todo it creates.
1281
+ */
1282
+ async #deliverTodo(entry, context) {
1283
+ const runtime = this.#ctx.options.plugins.runtime;
1284
+ if (runtime?.todo === undefined)
1285
+ return;
1286
+ const slot = todoKey(context, entry.thread_ref, entry.seq);
1287
+ const stored = await this.#ctx.storage.transaction(tx => tx.get(slot));
1288
+ if (stored === undefined || stored.done)
1289
+ return;
1290
+ await runtime.todo(entry, context);
1291
+ await this.#ctx.storage.transaction(async (tx) => {
1292
+ const current = await tx.get(slot);
1293
+ if (current !== undefined)
1294
+ await tx.put(slot, { ...current, done: true });
1295
+ });
1296
+ }
1297
+ /** Whether the named context already confirmed this entry through any delivery path. */
1298
+ async #alreadyConsumed(entry, context) {
1299
+ const stored = await this.#ctx.storage.transaction(tx => tx.get(consumptionKey(context, entry.thread_ref, entry.seq)));
1300
+ return stored?.confirmed === true;
1301
+ }
1302
+ /**
1303
+ * The threads this node was invited into. The rows come from the records it already holds;
1304
+ * each row is then asked of its owner through the same describe read the thread view uses,
1305
+ * which is where the title, the state and the member table live. An owner that cannot be
1306
+ * reached leaves the row in place with the failure attached: an unreachable peer is a
1307
+ * reported outage, never a reason to hide a thread this node belongs to.
1308
+ */
1309
+ async joinedThreads(options = {}, signal) {
1310
+ const listed = await this.#threads.joined(this.#auth(), options);
1311
+ const items = [];
1312
+ for (const row of listed.items)
1313
+ items.push(await this.#ownerFacts(row, signal));
1314
+ return { ...listed, items };
1315
+ }
1316
+ async #ownerFacts(row, signal) {
1317
+ try {
1318
+ const described = await this.#remote(row.thread_ref.owner, 'GET', `/threads/${encodeURIComponent(row.thread_ref.owner)}/${encodeURIComponent(row.thread_ref.id)}`, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1319
+ return { ...row, title: described.thread.title, state: described.thread.state, members: described.members };
1320
+ }
1321
+ catch (error) {
1322
+ if (signal?.aborted)
1323
+ throw error;
1324
+ return { ...row, unavailable: failureOf(error) };
1325
+ }
1326
+ }
1327
+ /** One thread's record and members: local when this node hosts it, from the owner otherwise. */
1328
+ async describeThread(ref, signal) {
1329
+ if (ref.owner === this.#ctx.options.node_id)
1330
+ return this.#threads.describe(this.#auth(), ref);
1331
+ return this.#remote(ref.owner, 'GET', `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1332
+ }
1333
+ /**
1334
+ * One page of a thread's entries, read locally when this node hosts the thread and from the
1335
+ * owner otherwise. A member of a joined thread has no local copy of the entries, so the
1336
+ * owner's message route is the only source that can answer for them.
1337
+ */
1338
+ async threadEntries(ref, query = {}, signal) {
1339
+ if (ref.owner === this.#ctx.options.node_id)
1340
+ return this.#messages.list(this.#auth(), ref, query);
1341
+ const search = new URLSearchParams();
1342
+ for (const [name, value] of Object.entries(query)) {
1343
+ if (value === undefined)
1344
+ continue;
1345
+ search.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
1346
+ }
1347
+ const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages`
1348
+ + (search.size === 0 ? '' : `?${search}`);
1349
+ return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1350
+ }
1351
+ /** Every owner + thread this node knows, from accepted grant notices plus its own threads. */
1352
+ async #knownRefs() {
1353
+ const granted = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('known') + '/')).map(row => row.value.thread_ref));
1354
+ const refs = new Map();
1355
+ for (const ref of granted)
1356
+ refs.set(key(ref.owner, ref.id), ref);
1357
+ const owned = await this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
1358
+ for (const thread of owned)
1359
+ refs.set(key(thread.thread_ref.owner, thread.thread_ref.id), thread.thread_ref);
1360
+ return [...refs.values()];
1361
+ }
1362
+ /** Every item of a paged listing, or a reported failure when the list never terminates. */
1363
+ async #allPages(load) {
1364
+ const all = [];
1365
+ let cursor;
1366
+ for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
1367
+ const page = await load(cursor);
1368
+ all.push(...page.items);
1369
+ cursor = page.next_cursor ?? undefined;
1370
+ if (cursor === undefined)
1371
+ return all;
1372
+ }
1373
+ return fail('cursor_expired', 'The list did not terminate.');
1374
+ }
1375
+ /** The owner's authenticated attention list: remote when the owner is another node. */
1376
+ async #attentionOf(ref, cursor, signal) {
1377
+ if (ref.owner === this.#ctx.options.node_id) {
1378
+ const options = { page_size: 100, ...(cursor === undefined ? {} : { cursor }) };
1379
+ return this.#messages.attention(this.#auth(), ref, options);
1380
+ }
1381
+ const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/attention`
1382
+ + (cursor === undefined ? '' : `?cursor=${encodeURIComponent(cursor)}`);
1383
+ return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1384
+ }
1385
+ async #reconcileThread(ref, signal) {
1386
+ let cursor;
1387
+ for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
1388
+ if (signal?.aborted)
1389
+ return;
1390
+ const page = await this.#attentionOf(ref, cursor, signal);
1391
+ for (const attention of page.items) {
1392
+ if (signal?.aborted)
1393
+ return;
1394
+ // The list answers for the thread it was asked about; anything else is not evidence
1395
+ // for this thread, however well-formed it looks.
1396
+ if (attention.thread_ref?.id !== ref.id)
1397
+ continue;
1398
+ await this.#store(ref, attention);
1399
+ }
1400
+ cursor = page.next_cursor ?? undefined;
1401
+ if (cursor === undefined)
1402
+ return;
1403
+ }
1404
+ fail('cursor_expired', 'The attention list did not terminate.');
1405
+ }
1406
+ /**
1407
+ * Merges an owner's attention row into the local record, then acks and consumes it.
1408
+ * An owner that strips the body never takes back a copy this node already holds
1409
+ * (RFC 0003 §2), and an `expired` disposition marks that copy without deleting it.
1410
+ */
1411
+ async #store(ref, attention) {
1412
+ const obligation_id = typeof attention.obligation_id === 'string' && attention.obligation_id.length > 0
1413
+ ? attention.obligation_id
1414
+ : key(ref.owner, ref.id, attention.message_ref.sender, attention.message_ref.id, this.#ctx.options.node_id);
1415
+ const stored = await this.#ctx.storage.transaction(async (tx) => {
1416
+ const previous = await tx.get(inboxKey(obligation_id));
1417
+ // An owner may strip the body from its list; the copy this node already holds is then
1418
+ // the evidence to check the row against, and it is never discarded (RFC 0003 §2).
1419
+ const local = previous?.attention.entry;
1420
+ if (!this.#attest(attention, ref.owner, local))
1421
+ return undefined;
1422
+ const merged = {
1423
+ attention: { ...attention, obligation_id, entry: attention.entry ?? local },
1424
+ // The owner's plan names the context this delivery was addressed to; a row that
1425
+ // arrived through reconciliation must keep it, or the host cannot route it back.
1426
+ ...(previous?.context === undefined
1427
+ ? (attention.plan?.context === undefined ? {} : { context: attention.plan.context })
1428
+ : { context: previous.context }),
1429
+ injected: previous?.injected === true,
1430
+ ...(previous?.settlement === undefined ? {} : { settlement: previous.settlement }),
1431
+ ...(previous?.resource_check_failures === undefined ? {} : { resource_check_failures: previous.resource_check_failures }),
1432
+ };
1433
+ if (previous !== undefined && canonical(previous.attention) === canonical(merged.attention)) {
1434
+ return previous;
1435
+ }
1436
+ await tx.put(inboxKey(obligation_id), merged);
1437
+ return merged;
1438
+ });
1439
+ if (stored === undefined)
1440
+ return;
1441
+ if (!stored.attention.acked)
1442
+ await this.#ack(ref, obligation_id);
1443
+ if (stored.attention.entry === undefined)
1444
+ return;
1445
+ this.#settle(stored.attention.entry);
1446
+ if (stored.attention.disposition === 'expired')
1447
+ return;
1448
+ await this.#consume(stored.attention.entry, stored);
1449
+ }
1450
+ /**
1451
+ * Delivers each outstanding owner notice to its recipient, authenticated as the owner
1452
+ * that issued it. The notice stays unacked here until the recipient's own confirmation
1453
+ * round trip reaches this node's `ackGrant`.
1454
+ */
1455
+ async #deliverGrants(signal) {
1456
+ const budget = this.#ctx.options.wait_ms ?? 60_000;
1457
+ const notices = await this.#allPages(cursor => this.#threads.notices(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
1458
+ for (const grant of notices) {
1459
+ if (signal?.aborted)
1460
+ return;
1461
+ try {
1462
+ if (grant.recipient === this.#ctx.options.node_id) {
1463
+ // A node already knows the threads it hosts, so its own notice needs no delivery.
1464
+ // The recipient is this node, which is the identity `ackGrant` checks.
1465
+ await this.#threads.ackGrant(this.#auth(), grant.id, {
1466
+ idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
1467
+ });
1468
+ continue;
1469
+ }
1470
+ await this.#remote(grant.recipient, 'POST', '/grants', { grant }, signal, budget);
1471
+ }
1472
+ catch {
1473
+ // The next pass retries; the notice stays outstanding until the recipient confirms.
1474
+ }
1475
+ }
1476
+ }
1477
+ /**
1478
+ * Confirms each grant this node accepted back to the owner that issued it. The owner's
1479
+ * `ackGrant` is the one that closes its notice, so the round trip ends there.
1480
+ */
1481
+ async #confirmGrants(signal) {
1482
+ const budget = this.#ctx.options.wait_ms ?? 60_000;
1483
+ const received = await this.#allPages(cursor => this.#threads.grants(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
1484
+ for (const grant of received) {
1485
+ if (signal?.aborted)
1486
+ return;
1487
+ if (grant.acked)
1488
+ continue;
1489
+ const mutation = { idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) };
1490
+ try {
1491
+ if (grant.thread_ref.owner === this.#ctx.options.node_id) {
1492
+ await this.#threads.ackGrant(this.#auth(), grant.id, mutation);
1493
+ }
1494
+ else {
1495
+ await this.#remote(grant.thread_ref.owner, 'POST', `/grants/${encodeURIComponent(grant.id)}/ack`, mutation, signal, budget);
1496
+ }
1497
+ // The owner's notice is closed; the local copy records that so this pass stops
1498
+ // re-confirming a grant the owner already acknowledged.
1499
+ await this.#threads.ackGrantLocally(this.#auth(), grant.thread_ref, grant.id);
1500
+ }
1501
+ catch {
1502
+ // Idempotent; the next pass sends the confirmation again.
1503
+ }
1504
+ }
1505
+ }
1506
+ /**
1507
+ * Asks every trusted owner, at most once per window, for the memberships it still holds for
1508
+ * this node, and re-records the grants this store no longer has. An owner's notice stays
1509
+ * acknowledged forever, so a participant whose own store was rebuilt never learns those
1510
+ * threads again from a push; the owner's signed answer is the evidence that repairs them.
1511
+ * Only a missing `known` or `grant` row is written, so a healthy thread costs no traffic and
1512
+ * a repeated pass is idempotent. An owner older than this route answers not_found once, and
1513
+ * that answer is the watermark: asking it again inside the window teaches nothing.
1514
+ */
1515
+ async #resyncGrants(signal) {
1516
+ const peers = await this.#ctx.storage.transaction(async (tx) => {
1517
+ const exchanges = (await tx.scan(key('trust') + '/'))
1518
+ .map(row => row.value)
1519
+ .filter(entry => entry.node_id !== this.#ctx.node && trustPhase(entry) === 'exchanged');
1520
+ const rows = [];
1521
+ for (const entry of exchanges) {
1522
+ rows.push({ node_id: entry.node_id, at: (await tx.get(grantResyncKey(entry.node_id)))?.at });
1523
+ }
1524
+ return rows;
1525
+ });
1526
+ const now = this.#ctx.now();
1527
+ // One probe per peer costs the same budget as a resource binding check, never the full
1528
+ // delivery wait: a slow owner must not hold the pass behind it.
1529
+ const budget = Math.min(this.#ctx.options.wait_ms ?? 60_000, RESOURCE_CHECK_MS);
1530
+ for (const peer of peers) {
1531
+ if (signal?.aborted)
1532
+ return;
1533
+ if (peer.at !== undefined && now - peer.at < GRANT_RESYNC_MS)
1534
+ continue;
1535
+ try {
1536
+ const issued = await this.#allPages(cursor => {
1537
+ const search = new URLSearchParams({ page_size: '100' });
1538
+ if (cursor !== undefined)
1539
+ search.set('cursor', cursor);
1540
+ return this.#remote(peer.node_id, 'GET', `/grants/issued?${search}`, undefined, signal, budget);
1541
+ });
1542
+ // Only a membership this node is the named recipient of, on an owner thread, is the
1543
+ // owner's answer about this node; anything else is not evidence for a local repair.
1544
+ const offered = issued.filter(grant => grant.thread_ref.owner === peer.node_id && grant.recipient === this.#ctx.node);
1545
+ const repair = await this.#ctx.storage.transaction(async (tx) => {
1546
+ const missing = new Set();
1547
+ for (const grant of offered) {
1548
+ const known = await tx.get(key('known', grant.thread_ref.owner, grant.thread_ref.id));
1549
+ const held = await tx.get(key('grant', grant.thread_ref.owner, grant.id));
1550
+ if (known === undefined || held === undefined)
1551
+ missing.add(grant.id);
1552
+ }
1553
+ return missing;
1554
+ });
1555
+ for (const grant of offered) {
1556
+ if (!repair.has(grant.id))
1557
+ continue;
1558
+ const restored = await this.#threads.acceptGrant({ actor: peer.node_id }, grant);
1559
+ this.#trace({ event: 'grant.restored', owner: peer.node_id, thread: restored.thread_ref, grant: restored.id });
1560
+ }
1561
+ await this.#peerAnswered(peer.node_id);
1562
+ }
1563
+ catch (error) {
1564
+ if (signal?.aborted)
1565
+ return;
1566
+ const failure = failureOf(error);
1567
+ if (failure.code === 'not_found') {
1568
+ await this.#peerAnswered(peer.node_id);
1569
+ continue;
1570
+ }
1571
+ this.#windowedFailure(key('grant-resync', peer.node_id, failure.code), attempts => ({
1572
+ event: 'grant.resync.failed', owner: peer.node_id, code: failure.code, message: failure.message, attempts,
1573
+ }));
1574
+ }
1575
+ }
1576
+ }
1577
+ /**
1578
+ * Records that one trusted owner answered. That answer is the new peer signal a parked
1579
+ * thread waits for, so its dead-lettered retry series starts over.
1580
+ */
1581
+ async #peerAnswered(peer) {
1582
+ await this.#ctx.storage.transaction(async (tx) => {
1583
+ await tx.put(grantResyncKey(peer), { peer, at: this.#ctx.now() });
1584
+ for (const row of await tx.scan(key('reconcile-backoff') + '/')) {
1585
+ if (row.value.owner === peer)
1586
+ await tx.delete(row.key);
1587
+ }
1588
+ });
1589
+ }
1590
+ async #dispatch(action, options) {
1591
+ const auth = this.#auth();
1592
+ const mutation = () => ({ idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) });
1593
+ if (action.kind === 'read') {
1594
+ if (action.scope === 'resources')
1595
+ return this.readResources(action.query, options);
1596
+ switch (action.scope) {
1597
+ case 'directory': return this.#directory();
1598
+ case 'neighbors': {
1599
+ // Same rows onlyne://neighbors renders: observed nodes with trust state attached.
1600
+ const peers = this.#ctx.options.plugins.neighbors;
1601
+ if (peers === undefined)
1602
+ fail('unsupported_delivery', 'No neighbor directory is available on this host.');
1603
+ return peers();
1604
+ }
1605
+ case 'threads': return this.#threadsView(action, options);
1606
+ case 'inbox':
1607
+ // A thread-scoped inbox is the owner's attention list for that thread, so the
1608
+ // owner that admitted the messages is asked, exactly like reconciliation does.
1609
+ if (action.thread !== undefined)
1610
+ return this.#attentionOf(action.thread, action.cursor, options.signal);
1611
+ return this.#messages.inbox(auth, {
1612
+ ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1613
+ ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1614
+ });
1615
+ case 'messages':
1616
+ return this.#readThread(action, options);
1617
+ case 'message':
1618
+ case 'evidence':
1619
+ return this.#readMessage(action, options);
1620
+ }
1621
+ return fail('invalid_request', 'Unknown read scope.');
1622
+ }
1623
+ if (action.kind === 'thread') {
1624
+ switch (action.op) {
1625
+ case 'create':
1626
+ return this.#threads.create(auth, { ...mutation(), title: requireValue(action.title, 'invalid_request') });
1627
+ case 'close': return this.#threads.close(auth, requireValue(action.thread, 'invalid_request'), mutation());
1628
+ case 'list': return this.#threads.list(auth);
1629
+ case 'describe': return this.describeThread(requireValue(action.thread, 'invalid_request'), options.signal);
1630
+ case 'member_add':
1631
+ return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), {}, mutation());
1632
+ case 'member_remove':
1633
+ return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), { state: 'revoked' }, mutation());
1634
+ }
1635
+ return fail('invalid_request', 'Unknown thread operation.');
1636
+ }
1637
+ if (action.kind === 'resource')
1638
+ return this.#operateResource(action, options);
1639
+ if (action.kind === 'domain') {
1640
+ const domainMutation = () => ({
1641
+ idempotency_key: this.#ctx.id(),
1642
+ retry_until: this.#window(undefined),
1643
+ ...(action.expected_revision === undefined ? {} : { expected_revision: action.expected_revision }),
1644
+ });
1645
+ switch (action.op) {
1646
+ case 'create': return this.#domains.create(auth, mutation());
1647
+ case 'join': return this.#domains.join(auth, requireValue(action.domain_id, 'invalid_request'), domainMutation());
1648
+ case 'leave': return this.#domains.leave(auth, requireValue(action.domain_id, 'invalid_request'), domainMutation());
1649
+ case 'list': return this.#domains.list(auth);
1650
+ case 'describe': return this.#domains.describe(auth, requireValue(action.domain_id, 'invalid_request'));
1651
+ case 'discover':
1652
+ return this.#domains.discover(auth, requireValue(action.domain_id, 'invalid_request'), options.signal);
1653
+ }
1654
+ return fail('invalid_request', 'Unknown domain operation.');
1655
+ }
1656
+ return fail('invalid_request', 'Unknown action.');
1657
+ }
1658
+ /**
1659
+ * Reads a thread over the owner's authenticated endpoint; this node's own threads are
1660
+ * read locally. A remote owner that will not serve the read is a reported failure,
1661
+ * never a silent local fallback.
1662
+ */
1663
+ async #readThread(action, options) {
1664
+ const ref = requireValue(action.thread, 'invalid_request');
1665
+ const query = {
1666
+ attention: action.attention ?? 'thread',
1667
+ ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1668
+ ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1669
+ ...(action.seq === undefined ? {} : { since_seq: action.seq }),
1670
+ ...(action.message === undefined ? {} : { related_to: action.message }),
1671
+ };
1672
+ return this.threadEntries(ref, query, options.signal);
1673
+ }
1674
+ /**
1675
+ * The threads this node can see in one page: the ones it owns, plus the ones it was invited
1676
+ * into. A joined row carries `read_only: true`; its owner's title, state and member table
1677
+ * come from the describe read, and an unreachable owner leaves the failure on the row
1678
+ * instead of dropping the thread from the list.
1679
+ */
1680
+ async #threadsView(action, options) {
1681
+ const [owned, joined] = await Promise.all([
1682
+ this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) })),
1683
+ this.#allPages(cursor => this.joinedThreads({ page_size: 100, ...(cursor === undefined ? {} : { cursor }) }, options.signal)),
1684
+ ]);
1685
+ const position = (row) => key(row.thread_ref.owner, row.thread_ref.id);
1686
+ const rows = [...owned, ...joined].sort((left, right) => {
1687
+ const a = position(left);
1688
+ const b = position(right);
1689
+ return a < b ? -1 : a > b ? 1 : 0;
1690
+ });
1691
+ return page(rows, {
1692
+ ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1693
+ ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1694
+ }, { kind: 'threads', actor: this.#ctx.options.node_id }, position);
1695
+ }
1696
+ /**
1697
+ * One message by reference. `evidence` is this node's own delivery record; a message this
1698
+ * node appended to a thread it hosts never creates one, so its committed entry is the
1699
+ * evidence. Otherwise the owner that admitted the message is the only source, and its
1700
+ * message list is where a referenced entry is found (RFC 0004 §2).
1701
+ */
1702
+ async #readMessage(action, options) {
1703
+ const ref = requireValue(action.message, 'invalid_request');
1704
+ if (action.scope === 'evidence') {
1705
+ const record = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(ref.sender, ref.id)));
1706
+ if (record !== undefined)
1707
+ return record;
1708
+ // A reply appended to a hosted thread is committed locally and delivered by that
1709
+ // commit; reporting "no evidence" for it reads as a failed send and invites a
1710
+ // duplicate. A message this node received has another sender, so it stays not_found.
1711
+ const hosted = ref.sender === this.#ctx.options.node_id
1712
+ ? await this.#ctx.storage.transaction(tx => tx.get(key('entry-ref', ref.sender, ref.id)))
1713
+ : undefined;
1714
+ if (hosted !== undefined) {
1715
+ return { admission: 'accepted', local: true, entry: hosted, thread_ref: hosted.thread_ref, seq: hosted.seq };
1716
+ }
1717
+ fail('not_found', 'This node holds no outbound evidence for that message.');
1718
+ }
1719
+ const hosted = await this.#ctx.storage.transaction(tx => tx.get(key('entry-ref', ref.sender, ref.id)));
1720
+ const thread = action.thread ?? hosted?.thread_ref;
1721
+ if (thread === undefined)
1722
+ fail('not_found', 'This node holds no evidence for that message.');
1723
+ if (thread.owner === this.#ctx.options.node_id)
1724
+ return this.#messages.getByRef(this.#auth(), ref);
1725
+ let cursor;
1726
+ for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
1727
+ if (options.signal?.aborted)
1728
+ break;
1729
+ const search = new URLSearchParams({ attention: 'thread' });
1730
+ if (cursor !== undefined)
1731
+ search.set('cursor', cursor);
1732
+ const path = `/threads/${encodeURIComponent(thread.owner)}/${encodeURIComponent(thread.id)}/messages?${search}`;
1733
+ const page = await this.#remote(thread.owner, 'GET', path, undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
1734
+ const found = page.items.find(entry => entry.message.sender === ref.sender && entry.message.id === ref.id);
1735
+ if (found !== undefined)
1736
+ return found;
1737
+ cursor = page.next_cursor ?? undefined;
1738
+ if (cursor === undefined)
1739
+ break;
1740
+ }
1741
+ return fail('not_found', 'The owning node did not return that message.');
1742
+ }
1743
+ /** Resource operations route to the resource owner; this node's own resources answer locally. */
1744
+ async #operateResource(action, options) {
1745
+ const request = {
1746
+ idempotency_key: this.#ctx.id(),
1747
+ retry_until: this.#window(undefined),
1748
+ operation_id: this.#ctx.id(),
1749
+ operation: action.operation,
1750
+ ...(action.expected_revision === undefined ? {} : { expected_revision: action.expected_revision }),
1751
+ ...(action.data === undefined ? {} : { data: action.data }),
1752
+ };
1753
+ if (action.resource.owner === this.#ctx.options.node_id) {
1754
+ return this.#resources.operate(this.#auth(), action.resource, request, options.signal);
1755
+ }
1756
+ const path = `/resources/${encodeURIComponent(action.resource.owner)}/${encodeURIComponent(action.resource.id)}/operations`;
1757
+ return this.#remote(action.resource.owner, 'POST', path, request, options.signal, this.#ctx.options.wait_ms ?? 60_000);
1758
+ }
1759
+ /** Local facts read without a peer: this node's identity, description, threads and inbox. */
1760
+ async #directory() {
1761
+ const plugins = this.#ctx.options.plugins;
1762
+ return {
1763
+ node: this.#ctx.options.node_id,
1764
+ description: plugins.directory === undefined ? null : await plugins.directory(),
1765
+ threads: await this.#threads.list(this.#auth(), { page_size: 100 }),
1766
+ inbox: await this.#messages.inbox(this.#auth(), { page_size: 100 }),
1767
+ domains: await this.#domains.list(this.#auth(), { page_size: 100 }),
1768
+ };
1769
+ }
1770
+ }
1771
+ _a = OnlyneClient;
1772
+ //# sourceMappingURL=client.js.map