@onlyne/lib 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,8 @@ A resource has one binding — the handler and the highest event it has confirme
60
60
 
61
61
  `client.readResources(query)` reads local resources or requests their owner's authenticated endpoint. The `ResourceReadQuery` operations are `list`, `describe`, `log`, `event`, `operations`, and `status`. The `decide` plugin can return `{ kind: 'read', scope: 'resources', query }` to use the same reader through `msg`. Reads create no operation record and do not call a resource driver.
62
62
 
63
+ A queued entry that projects a resource event (`meta.data.resource_ref`, authenticated by that resource's owner) is checked against the binding before it is injected. If the binding names another handler, the row is settled with `settlement.via === 'handover'`; if this node may no longer read the resource, the row is settled as `undeliverable` with `reason: 'resource-unavailable'`. A transient failure leaves the row owed and is traced as `inbound.resource-check.failed`. Settlement closes the local delivery obligation. It records why delivery ended, and it does not assert that the model read the entry, so no consumption record and no todo are written.
64
+
63
65
  `list` accepts concrete `owners`, `page_size`, and `cursor`. Omitted `owners` selects the local node. The result contains `owners: [{ owner, page } | { owner, error }]`, so an unavailable owner remains visible beside successful pages. A cursor selects exactly one owner. The host chooses which owners to include. Other queries name `resource: { owner, id }`. `log` accepts an exclusive `since` event id, `page_size`, and `cursor`. `event` names `event_id`; `status` names `operation_id` and returns the existing operation record. The original actor can read that record after losing resource-read access. `operations` returns only the current actor's records.
64
66
 
65
67
  An operation result is `committed`, `pending`, `aborted`, or `unknown`. `aborted` means the endpoint refused or locally stopped the call and confirmed that no external effect happened; a failure whose side effect cannot be ruled out is `unknown`, keeps the original error in `details`, and stays owed to `node.resources.recover()`, which asks the driver for the outcome by `operation_id`. A caller that stops waiting gets `unknown` while the external call keeps running, and a later driver outcome settles that record. A retried operation is asked for its outcome, never executed a second time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyne/lib",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Decentralized agent messaging: threads, durable admission, attention, reply correlation, resources with handover, and an HTTP binding",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/client.ts CHANGED
@@ -1,22 +1,30 @@
1
- import type { Context } from './core.js';
2
- import { canonical, decimal, fail, key, requireValue, utc } from './core.js';
1
+ import type { Context } from './core.ts';
2
+ import { canonical, decimal, fail, key, page, requireValue, utc } from './core.ts';
3
3
  import type {
4
- Action, Attention, Auth, DeliveryPlan, DomainMembership, DomainMutation, Entry, ErrorCode, Failure, Json, LocalAttention,
5
- InjectOutcome, Member, Message, MessageQuery, MessageRef, MsgInput, Mutation, Outbound, Page, PageOptions, SendRequest,
6
- TraceEvent, Thread, ThreadRef, Transaction, Transport, MaintenanceClock, PreparedSend,
7
- ResourceReadQuery, ResourceReadResult, ResourceInventory, Resource,
8
- } from './types.js';
9
- import type { MessageService } from './messages.js';
10
- import type { ResourceService } from './resources.js';
11
- import type { ThreadService } from './threads.js';
12
- import type { DomainService } from './domains.js';
13
- import type { TrustService } from './keys.js';
4
+ Action, Attention, Auth, DeliveryPlan, DomainMembership, DomainMutation, Entry, ErrorCode, Failure, Grant, Json, LocalAttention,
5
+ InjectOutcome, JoinedThread, Member, Message, MessageQuery, MessageRef, MsgInput, Mutation, NodeId, Outbound, Page, PageOptions, SendRequest,
6
+ TraceEvent, Thread, ThreadDescription, ThreadRef, Transaction, Transport, MaintenanceClock, PreparedSend,
7
+ ResourceReadQuery, ResourceReadResult, ResourceInventory, Resource, AttentionSettlement,
8
+ } from './types.ts';
9
+ import type { MessageService } from './messages.ts';
10
+ import type { ResourceService } from './resources.ts';
11
+ import type { ThreadService } from './threads.ts';
12
+ import type { DomainService } from './domains.ts';
13
+ import type { TrustService } from './keys.ts';
14
14
 
15
15
  /** Result of one delivery: `admission` is what the peer recorded, `wait` is what this caller observed. */
16
16
  export interface SendOutcome {
17
17
  receipt?: Outbound['receipt'];
18
18
  admission: Outbound['state'];
19
19
  reply?: Entry;
20
+ /** The target as the caller spelled it, present only with `resolved` when the two differ. */
21
+ requested?: string;
22
+ /**
23
+ * The node `requested` resolved to. Present whenever the addressee differs from the
24
+ * endpoint that stores the entry — which is this node whenever it started the
25
+ * conversation — so an initiator's receipt never reads as a message the node sent itself.
26
+ */
27
+ resolved?: NodeId;
20
28
  /**
21
29
  * Whether the reply body is safe to render. `withheld` means the same body is already
22
30
  * confirmed consumed in this context through another path (RFC 0008 §5), so the caller
@@ -32,6 +40,12 @@ export interface SendOptions {
32
40
  retry_until?: string;
33
41
  idempotency_key?: string;
34
42
  expected_author?: string;
43
+ /**
44
+ * The caller's own spelling of the addressee. Recorded on the outbound record beside the
45
+ * resolved node id when the two differ, so the record shows both what was asked and what
46
+ * was resolved (RFC 0005 §4: the receipt reports the landing, not the request).
47
+ */
48
+ requested?: string;
35
49
  /**
36
50
  * The sender's own consumption identity for the awaited reply (RFC 0008 §5). It stays
37
51
  * local and never stands in for the wire plan's receiver-side context.
@@ -74,6 +88,10 @@ const TERMINAL: Record<string, true> = {
74
88
  unsupported_delivery: true, ambiguous_target: true, ambiguous_resource: true,
75
89
  };
76
90
  const MAX_CURSOR_PAGES = 10_000;
91
+ /** A binding check must not hold the maintenance pass behind an unreachable resource owner. */
92
+ const RESOURCE_CHECK_MS = 5_000;
93
+ /** One reconcile failure line per (owner, thread, code) per window; a dead owner stays observable. */
94
+ const RECONCILE_FAILURE_WINDOW_MS = 300_000;
77
95
 
78
96
  /** Anything that is not a recorded refusal leaves the admission unknown, never "undelivered". */
79
97
  function admissionOf(failure: Failure): Outbound['state'] {
@@ -177,6 +195,8 @@ export class OnlyneClient {
177
195
  readonly #waiters = new Map<string, Waiter>();
178
196
  /** Replies already returned as tool results, per context, held until consumption confirms them. */
179
197
  readonly #held = new Map<string, Set<string>>();
198
+ /** Last `reconcile.failed` line per (owner, thread, code), and what the window suppressed. */
199
+ readonly #reconcileFailures = new Map<string, { at: number; suppressed: number }>();
180
200
 
181
201
  constructor(
182
202
  ctx: Context, transport: Transport, messages: MessageService, threads: ThreadService,
@@ -303,19 +323,31 @@ export class OnlyneClient {
303
323
  },
304
324
  };
305
325
  if (ref.owner === ctx.options.node_id && to !== ctx.options.node_id) {
306
- await this.#ensureMember(ref, to);
326
+ // The addressee can only ever learn this entry through a signed grant or a signed
327
+ // reconcile, so a send that names a peer this node never paired with is refused before
328
+ // anything is stored: it would otherwise read `accepted` while the peer can never see it.
329
+ if (this.#trust !== undefined && ctx.options.plugins.keys !== undefined) {
330
+ const pairing = await this.#trust.entry(to);
331
+ if (pairing === undefined) {
332
+ fail('not_found', `No trust record names ${to}. Pair the nodes (trust_begin) before addressing them; nothing was stored.`);
333
+ }
334
+ }
335
+ const member = await this.#ensureMember(ref, to);
307
336
  // The participant is told about the thread now, not on a later maintenance pass: this
308
337
  // exchange is also where the participant's own policy answers, so a peer that revoked
309
338
  // this node refuses the conversation at the point the sender can still see it.
310
339
  try {
311
- await this.#pushGrant(ref, to);
340
+ await this.#pushGrant(ref, member);
312
341
  } catch (error) {
313
342
  const failure = failureOf(error);
314
343
  return { admission: admissionOf(failure), error: failure };
315
344
  }
316
345
  }
317
346
 
318
- return this.#deliver(ref.owner, normalized, plan, options);
347
+ const outcome = await this.#deliver(ref.owner, normalized, plan, options, to);
348
+ // The storing endpoint is this node whenever it started the conversation; the outcome
349
+ // still names the peer it addresses, or the receipt reads as a message sent to oneself.
350
+ return to !== ref.owner ? { ...outcome, resolved: to } : outcome;
319
351
  }
320
352
 
321
353
  /** The reply-authority route: the receiver owns the landing, so no thread is named here. */
@@ -324,7 +356,7 @@ export class OnlyneClient {
324
356
  }
325
357
 
326
358
  /** Records one outbound request, then transmits it or waits for its answer. */
327
- async #deliver(target: string, message: Message, plan: DeliveryPlan, options: SendOptions): Promise<SendOutcome> {
359
+ async #deliver(target: string, message: Message, plan: DeliveryPlan, options: SendOptions, addressee?: NodeId): Promise<SendOutcome> {
328
360
  const ctx = this.#ctx;
329
361
  const slot = outboundKey(message.sender, message.id);
330
362
  const request: SendRequest = {
@@ -335,6 +367,15 @@ export class OnlyneClient {
335
367
  plan,
336
368
  };
337
369
  const digest = messageDigest(request);
370
+ // Who the send addresses, beside where the entry is stored: an initiator's own thread
371
+ // stores locally, so the record must still name the peer, or the receipt reads as a
372
+ // message the node sent itself.
373
+ const addressed = {
374
+ ...(addressee !== undefined && addressee !== target ? { addressee } : {}),
375
+ ...(typeof options.requested === 'string' && options.requested !== (addressee ?? target)
376
+ ? { requested: options.requested }
377
+ : {}),
378
+ };
338
379
  // The same message identity with different content is a conflict, never a rewrite:
339
380
  // the slot keeps the first request and the caller sends a new message id instead.
340
381
  const record = await ctx.storage.transaction(async tx => {
@@ -354,6 +395,7 @@ export class OnlyneClient {
354
395
  const fresh: StoredOutbound = {
355
396
  request, expected_author: options.expected_author ?? target, state: 'pending', digest,
356
397
  created_at: new Date(ctx.now()).toISOString(),
398
+ ...addressed,
357
399
  ...(options.context === undefined ? {} : { local_context: options.context }),
358
400
  };
359
401
  await tx.put(slot, fresh);
@@ -469,25 +511,24 @@ export class OnlyneClient {
469
511
  }
470
512
 
471
513
  /**
472
- * Grants the addressee `post` and `read` in a thread this node owns, once. The notice the
473
- * owner issues here is what teaches the participant the ThreadRef it reconciles against.
514
+ * Grants the addressee `post` and `read` in a thread this node owns, once, and returns the
515
+ * member row: the grant notice is keyed by the member revision, so the caller can push that
516
+ * exact notice instead of scanning the outbox for one it might not recognize.
474
517
  */
475
- async #ensureMember(ref: ThreadRef, peer: string): Promise<void> {
518
+ async #ensureMember(ref: ThreadRef, peer: string): Promise<Member> {
476
519
  const existing = await this.#ctx.storage.transaction(tx => tx.get<Member>(key('member', ref.owner, ref.id, peer)));
477
- if (existing !== undefined) return;
520
+ if (existing !== undefined) return existing;
478
521
  await this.#threads.member(this.#auth(), ref, peer, {}, {
479
522
  idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
480
523
  });
524
+ return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<Member>(key('member', ref.owner, ref.id, peer))));
481
525
  }
482
526
 
483
- /** Delivers one outstanding notice to its recipient; a refusal here is the send's answer. */
484
- async #pushGrant(ref: ThreadRef, peer: string): Promise<void> {
485
- const notices = await this.#allPages(cursor =>
486
- this.#threads.notices(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
487
- const notice = notices.find(grant =>
488
- grant.recipient === peer && grant.thread_ref.owner === ref.owner && grant.thread_ref.id === ref.id);
489
- if (notice === undefined) return;
490
- await this.#remote(peer, 'POST', '/grants', { grant: notice }, undefined, this.#ctx.options.wait_ms ?? 60_000);
527
+ /** Delivers the outstanding notice for one member's revision; none outstanding, nothing to push. */
528
+ async #pushGrant(_ref: ThreadRef, member: Member): Promise<void> {
529
+ const notice = await this.#ctx.storage.transaction(tx => tx.get<Grant>(key('notice', member.revision)));
530
+ if (notice === undefined || notice.acked) return;
531
+ await this.#remote(member.peer, 'POST', '/grants', { grant: notice }, undefined, this.#ctx.options.wait_ms ?? 60_000);
491
532
  }
492
533
 
493
534
  // ---- inbound path -----------------------------------------------------------
@@ -543,6 +584,8 @@ export class OnlyneClient {
543
584
  * `owner + thread_ref` pairs recorded when a grant notice was accepted, plus the
544
585
  * threads this node hosts, which need no grant. The owner's authenticated attention
545
586
  * list is the evidence; the ack goes out only after the entry is durable locally.
587
+ * An owner this node cannot reach is reported as a trace record, never thrown: one dead
588
+ * owner must not starve the rest, and a silent skip is the failure nobody can diagnose.
546
589
  */
547
590
  async reconcile(signal?: AbortSignal): Promise<Page<LocalAttention>> {
548
591
  for (const ref of await this.#knownRefs()) {
@@ -551,14 +594,35 @@ export class OnlyneClient {
551
594
  await this.#reconcileThread(ref, signal);
552
595
  } catch (error) {
553
596
  if (ref.owner === this.#ctx.options.node_id) throw error;
554
- // One owner this node cannot reach or resolve must not starve the rest; the next
555
- // reconciliation pass retries it, and the local inbox still reflects what landed.
597
+ this.#reportReconcileFailure(ref, error);
556
598
  continue;
557
599
  }
558
600
  }
559
601
  return this.#messages.inbox(this.#auth(), {});
560
602
  }
561
603
 
604
+ /**
605
+ * Writes at most one `reconcile.failed` line per (owner, thread, code) per window. A failed
606
+ * pull drops nothing, so the next pass retries the same ref; deduplicating here keeps a
607
+ * permanently dead owner from writing the same line every pass while still accounting for
608
+ * every attempt it folded into the line it did write.
609
+ */
610
+ #reportReconcileFailure(ref: ThreadRef, error: unknown): void {
611
+ const failure = failureOf(error);
612
+ const slot = key(ref.owner, ref.id, failure.code);
613
+ const now = this.#ctx.now();
614
+ const held = this.#reconcileFailures.get(slot);
615
+ if (held !== undefined && now - held.at < RECONCILE_FAILURE_WINDOW_MS) {
616
+ held.suppressed += 1;
617
+ return;
618
+ }
619
+ this.#reconcileFailures.set(slot, { at: now, suppressed: 0 });
620
+ this.#trace({
621
+ event: 'reconcile.failed', owner: ref.owner, thread: ref.id,
622
+ code: failure.code, message: failure.message, attempts: (held?.suppressed ?? 0) + 1,
623
+ });
624
+ }
625
+
562
626
  /**
563
627
  * Delivers outstanding owner grant notices, replays owed todos and entries whose durable
564
628
  * record never reached the model, re-drives owed trust statements, then reconciles. Replay
@@ -679,7 +743,9 @@ export class OnlyneClient {
679
743
  const action = await this.#decide(input);
680
744
  if (action.kind !== 'send') return this.#dispatch(action, options);
681
745
  const prepared = this.#prepare(input, action);
682
- return this.send(prepared.to, prepared.message, prepared.plan, options);
746
+ const outcome = await this.send(prepared.to, prepared.message, prepared.plan,
747
+ { ...options, ...(typeof input.meta?.to === 'string' ? { requested: input.meta.to } : {}) });
748
+ return this.#addressed(outcome, input.meta?.to, prepared.to);
683
749
  }
684
750
 
685
751
  async prepare(input: MsgInput): Promise<PreparedSend> {
@@ -756,6 +822,16 @@ export class OnlyneClient {
756
822
  return { to: action.to, message, plan };
757
823
  }
758
824
 
825
+ /**
826
+ * The caller's own spelling beside the node the decision resolved it to. Equal spellings say
827
+ * nothing new; a bare alias that resolved to a peer is the case the receipt cannot show. A
828
+ * `resolved` the send already derived (the addressee of an initiator's own thread) is kept.
829
+ */
830
+ #addressed(outcome: SendOutcome, requested: Json | undefined, resolved: NodeId): SendOutcome {
831
+ const merged: SendOutcome = outcome.resolved === undefined ? { ...outcome, resolved } : outcome;
832
+ return typeof requested !== 'string' || requested === resolved ? merged : { ...merged, requested };
833
+ }
834
+
759
835
  // ---- internals ---------------------------------------------------------------
760
836
 
761
837
  #auth(): Auth {
@@ -853,7 +929,8 @@ export class OnlyneClient {
853
929
  const guarantee = (receipt as { guarantee?: unknown }).guarantee;
854
930
  this.#trace({
855
931
  event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
856
- target: request.target_endpoint, state: 'accepted', ...(typeof guarantee === 'string' ? { guarantee } : {}),
932
+ target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
933
+ state: 'accepted', ...(typeof guarantee === 'string' ? { guarantee } : {}),
857
934
  });
858
935
  return { admission: 'accepted', receipt };
859
936
  } catch (error) {
@@ -862,7 +939,8 @@ export class OnlyneClient {
862
939
  await this.#commit(record, { state: admissionOf(failure), error: failure });
863
940
  this.#trace({
864
941
  event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
865
- target: request.target_endpoint, state: admissionOf(failure), code: failure.code,
942
+ target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
943
+ state: admissionOf(failure), code: failure.code,
866
944
  });
867
945
  const stored = await this.#ctx.storage.transaction(tx => tx.get<StoredOutbound>(outboundKey(request.message.sender, request.message.id)));
868
946
  return {
@@ -919,16 +997,21 @@ export class OnlyneClient {
919
997
  // A row keeps the context the entry landed in; the confirmation context only fills in
920
998
  // for a row that names none.
921
999
  let original: string | undefined;
1000
+ let settled = false;
1001
+ let consumable = false;
922
1002
  for (const row of await tx.scan<LocalAttention>(key('inbox') + '/')) {
923
1003
  const held = row.value.attention.entry;
924
1004
  if (held === undefined) continue;
925
1005
  if (held.thread_ref.owner !== entry.thread_ref.owner || held.thread_ref.id !== entry.thread_ref.id) continue;
926
1006
  if (held.seq !== entry.seq) continue;
1007
+ if (row.value.settlement !== undefined) { settled = true; continue; }
1008
+ consumable = true;
927
1009
  original = original ?? row.value.context;
928
1010
  if (row.value.injected) continue;
929
1011
  const landed = row.value.context ?? context;
930
1012
  await tx.put(row.key, { ...row.value, injected: true, ...(landed === undefined ? {} : { context: landed }) });
931
1013
  }
1014
+ if (settled && !consumable) return undefined;
932
1015
  const where = context ?? original ?? this.#ctx.options.node_id;
933
1016
  const slot = consumptionKey(where, entry.thread_ref, entry.seq);
934
1017
  const stored = await tx.get<ConsumptionRecord>(slot);
@@ -940,6 +1023,10 @@ export class OnlyneClient {
940
1023
  await this.#oweTodo(tx, entry, where);
941
1024
  return where;
942
1025
  });
1026
+ if (consumedIn === undefined) {
1027
+ this.#held.delete(entryKey(entry.thread_ref, entry.seq));
1028
+ return;
1029
+ }
943
1030
  const identity = entryKey(entry.thread_ref, entry.seq);
944
1031
  const held = this.#held.get(identity);
945
1032
  if (held !== undefined) {
@@ -1159,9 +1246,10 @@ export class OnlyneClient {
1159
1246
  * answer with the context it really used, and that is the one the todo follows.
1160
1247
  */
1161
1248
  async #consume(entry: Entry, record: LocalAttention): Promise<void> {
1249
+ if (record.injected) { this.#injectTrace(entry, record, 'skipped', 'injected'); return; }
1250
+ if (await this.#settleResourceAttention(entry, record)) return;
1162
1251
  const runtime = this.#ctx.options.plugins.runtime;
1163
1252
  if (runtime === undefined) { this.#injectTrace(entry, record, 'skipped', 'no-runtime'); return; }
1164
- if (record.injected) { this.#injectTrace(entry, record, 'skipped', 'injected'); return; }
1165
1253
  const related = entry.message.meta?.in_reply_to;
1166
1254
  const outbound = related?.sender === this.#ctx.options.node_id
1167
1255
  ? await this.#ctx.storage.transaction(tx => tx.get<StoredOutbound>(outboundKey(related.sender, related.id)))
@@ -1187,6 +1275,55 @@ export class OnlyneClient {
1187
1275
  await this.confirmConsumption(entry, injected.context, 'fragment');
1188
1276
  }
1189
1277
 
1278
+ async #settleResourceAttention(entry: Entry, record: LocalAttention): Promise<boolean> {
1279
+ const ref = entry.message.meta?.data?.resource_ref;
1280
+ if (typeof ref !== 'object' || ref === null || Array.isArray(ref)
1281
+ || typeof ref.owner !== 'string' || typeof ref.id !== 'string'
1282
+ || ref.owner !== entry.authenticated_author) return false;
1283
+ const resource_ref = { owner: ref.owner, id: ref.id };
1284
+ let settlement: AttentionSettlement;
1285
+ const at = new Date(this.#ctx.now()).toISOString();
1286
+ try {
1287
+ const resource = await this.readResources(
1288
+ { op: 'describe', resource: resource_ref },
1289
+ { signal: AbortSignal.timeout(RESOURCE_CHECK_MS) },
1290
+ ) as Resource;
1291
+ if (resource.binding.handler === this.#ctx.node) return false;
1292
+ settlement = { via: 'handover', resource_ref, handler: resource.binding.handler, revision: resource.revision, at };
1293
+ } catch (error) {
1294
+ const failure = failureOf(error);
1295
+ if (failure.code !== 'not_found') {
1296
+ const attempts = await this.#ctx.storage.transaction(async tx => {
1297
+ const slot = inboxKey(record.attention.obligation_id);
1298
+ const current = await tx.get<LocalAttention>(slot);
1299
+ if (current === undefined || current.injected) return undefined;
1300
+ const attempts = (current.resource_check_failures ?? 0) + 1;
1301
+ await tx.put(slot, { ...current, resource_check_failures: attempts });
1302
+ return attempts;
1303
+ });
1304
+ if (attempts !== undefined) this.#trace({
1305
+ event: 'inbound.resource-check.failed', thread: entry.thread_ref, seq: entry.seq,
1306
+ resource_ref, code: failure.code, attempts,
1307
+ });
1308
+ throw error;
1309
+ }
1310
+ settlement = { via: 'undeliverable', resource_ref, reason: 'resource-unavailable', at };
1311
+ }
1312
+ const changed = await this.#ctx.storage.transaction(async tx => {
1313
+ const slot = inboxKey(record.attention.obligation_id);
1314
+ const current = await tx.get<LocalAttention>(slot);
1315
+ if (current === undefined || current.injected) return false;
1316
+ await tx.put(slot, { ...current, injected: true, settlement });
1317
+ return true;
1318
+ });
1319
+ if (changed) {
1320
+ this.#held.delete(entryKey(entry.thread_ref, entry.seq));
1321
+ this.#trace({ event: 'inbound.settled', thread: entry.thread_ref, seq: entry.seq, settlement });
1322
+ this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
1323
+ }
1324
+ return true;
1325
+ }
1326
+
1190
1327
  /**
1191
1328
  * Records the todo this consumption owes, inside the commit that confirms the consumption.
1192
1329
  * A host without a todo hook owes none, so no record accrues for it.
@@ -1223,6 +1360,58 @@ export class OnlyneClient {
1223
1360
  return stored?.confirmed === true;
1224
1361
  }
1225
1362
 
1363
+ /**
1364
+ * The threads this node was invited into. The rows come from the records it already holds;
1365
+ * each row is then asked of its owner through the same describe read the thread view uses,
1366
+ * which is where the title, the state and the member table live. An owner that cannot be
1367
+ * reached leaves the row in place with the failure attached: an unreachable peer is a
1368
+ * reported outage, never a reason to hide a thread this node belongs to.
1369
+ */
1370
+ async joinedThreads(options: PageOptions = {}, signal?: AbortSignal): Promise<Page<JoinedThread>> {
1371
+ const listed = await this.#threads.joined(this.#auth(), options);
1372
+ const items: JoinedThread[] = [];
1373
+ for (const row of listed.items) items.push(await this.#ownerFacts(row, signal));
1374
+ return { ...listed, items };
1375
+ }
1376
+
1377
+ async #ownerFacts(row: JoinedThread, signal?: AbortSignal): Promise<JoinedThread> {
1378
+ try {
1379
+ const described = await this.#remote<ThreadDescription>(
1380
+ row.thread_ref.owner, 'GET',
1381
+ `/threads/${encodeURIComponent(row.thread_ref.owner)}/${encodeURIComponent(row.thread_ref.id)}`,
1382
+ undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1383
+ return { ...row, title: described.thread.title, state: described.thread.state, members: described.members };
1384
+ } catch (error) {
1385
+ if (signal?.aborted) throw error;
1386
+ return { ...row, unavailable: failureOf(error) };
1387
+ }
1388
+ }
1389
+
1390
+ /** One thread's record and members: local when this node hosts it, from the owner otherwise. */
1391
+ async describeThread(ref: ThreadRef, signal?: AbortSignal): Promise<ThreadDescription> {
1392
+ if (ref.owner === this.#ctx.options.node_id) return this.#threads.describe(this.#auth(), ref);
1393
+ return this.#remote(
1394
+ ref.owner, 'GET', `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`,
1395
+ undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1396
+ }
1397
+
1398
+ /**
1399
+ * One page of a thread's entries, read locally when this node hosts the thread and from the
1400
+ * owner otherwise. A member of a joined thread has no local copy of the entries, so the
1401
+ * owner's message route is the only source that can answer for them.
1402
+ */
1403
+ async threadEntries(ref: ThreadRef, query: MessageQuery = {}, signal?: AbortSignal): Promise<Page<Entry>> {
1404
+ if (ref.owner === this.#ctx.options.node_id) return this.#messages.list(this.#auth(), ref, query);
1405
+ const search = new URLSearchParams();
1406
+ for (const [name, value] of Object.entries(query)) {
1407
+ if (value === undefined) continue;
1408
+ search.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
1409
+ }
1410
+ const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages`
1411
+ + (search.size === 0 ? '' : `?${search}`);
1412
+ return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
1413
+ }
1414
+
1226
1415
  /** Every owner + thread this node knows, from accepted grant notices plus its own threads. */
1227
1416
  async #knownRefs(): Promise<ThreadRef[]> {
1228
1417
  const granted = await this.#ctx.storage.transaction(async tx =>
@@ -1299,6 +1488,8 @@ export class OnlyneClient {
1299
1488
  ? (attention.plan?.context === undefined ? {} : { context: attention.plan.context })
1300
1489
  : { context: previous.context }),
1301
1490
  injected: previous?.injected === true,
1491
+ ...(previous?.settlement === undefined ? {} : { settlement: previous.settlement }),
1492
+ ...(previous?.resource_check_failures === undefined ? {} : { resource_check_failures: previous.resource_check_failures }),
1302
1493
  };
1303
1494
  if (previous !== undefined && canonical(previous.attention) === canonical(merged.attention)) {
1304
1495
  return previous;
@@ -1381,7 +1572,7 @@ export class OnlyneClient {
1381
1572
  if (peers === undefined) fail('unsupported_delivery', 'No neighbor directory is available on this host.');
1382
1573
  return peers();
1383
1574
  }
1384
- case 'threads': return this.#threads.list(auth);
1575
+ case 'threads': return this.#threadsView(action, options);
1385
1576
  case 'inbox':
1386
1577
  // A thread-scoped inbox is the owner's attention list for that thread, so the
1387
1578
  // owner that admitted the messages is asked, exactly like reconciliation does.
@@ -1404,7 +1595,7 @@ export class OnlyneClient {
1404
1595
  return this.#threads.create(auth, { ...mutation(), title: requireValue(action.title, 'invalid_request') });
1405
1596
  case 'close': return this.#threads.close(auth, requireValue(action.thread, 'invalid_request'), mutation());
1406
1597
  case 'list': return this.#threads.list(auth);
1407
- case 'describe': return this.#readThreadRef(requireValue(action.thread, 'invalid_request'), options);
1598
+ case 'describe': return this.describeThread(requireValue(action.thread, 'invalid_request'), options.signal);
1408
1599
  case 'member_add':
1409
1600
  return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), {}, mutation());
1410
1601
  case 'member_remove':
@@ -1440,37 +1631,42 @@ export class OnlyneClient {
1440
1631
  */
1441
1632
  async #readThread(action: Extract<Action, { kind: 'read'; scope: 'directory' | 'neighbors' | 'threads' | 'inbox' | 'messages' | 'message' | 'evidence' }>, options: MsgOptions): Promise<unknown> {
1442
1633
  const ref = requireValue(action.thread, 'invalid_request');
1443
- if (ref.owner === this.#ctx.options.node_id) {
1444
- return this.#messages.list(this.#auth(), ref, {
1445
- attention: action.attention ?? 'thread',
1446
- ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1447
- ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1448
- ...(action.seq === undefined ? {} : { since_seq: action.seq }),
1449
- ...(action.message === undefined ? {} : { related_to: action.message }),
1450
- });
1451
- }
1452
1634
  const query: MessageQuery = {
1635
+ attention: action.attention ?? 'thread',
1453
1636
  ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1454
1637
  ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1455
1638
  ...(action.seq === undefined ? {} : { since_seq: action.seq }),
1456
1639
  ...(action.message === undefined ? {} : { related_to: action.message }),
1457
- attention: action.attention ?? 'thread',
1458
1640
  };
1459
- const search = new URLSearchParams();
1460
- for (const [name, value] of Object.entries(query)) {
1461
- if (value === undefined) continue;
1462
- search.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
1463
- }
1464
- const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages`
1465
- + (search.size === 0 ? '' : `?${search}`);
1466
- return this.#remote(ref.owner, 'GET', path, undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
1641
+ return this.threadEntries(ref, query, options.signal);
1467
1642
  }
1468
1643
 
1469
- async #readThreadRef(ref: ThreadRef, options: MsgOptions): Promise<unknown> {
1470
- if (ref.owner === this.#ctx.options.node_id) return this.#threads.describe(this.#auth(), ref);
1471
- return this.#remote(
1472
- ref.owner, 'GET', `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`,
1473
- undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
1644
+ /**
1645
+ * The threads this node can see in one page: the ones it owns, plus the ones it was invited
1646
+ * into. A joined row carries `read_only: true`; its owner's title, state and member table
1647
+ * come from the describe read, and an unreachable owner leaves the failure on the row
1648
+ * instead of dropping the thread from the list.
1649
+ */
1650
+ async #threadsView(action: PageOptions, options: MsgOptions): Promise<Page<Thread | JoinedThread>> {
1651
+ const [owned, joined] = await Promise.all([
1652
+ this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) })),
1653
+ this.#allPages(cursor => this.joinedThreads({ page_size: 100, ...(cursor === undefined ? {} : { cursor }) }, options.signal)),
1654
+ ]);
1655
+ const position = (row: Thread | JoinedThread): string => key(row.thread_ref.owner, row.thread_ref.id);
1656
+ const rows = [...owned, ...joined].sort((left, right) => {
1657
+ const a = position(left);
1658
+ const b = position(right);
1659
+ return a < b ? -1 : a > b ? 1 : 0;
1660
+ });
1661
+ return page(
1662
+ rows,
1663
+ {
1664
+ ...(action.cursor === undefined ? {} : { cursor: action.cursor }),
1665
+ ...(action.page_size === undefined ? {} : { page_size: action.page_size }),
1666
+ },
1667
+ { kind: 'threads', actor: this.#ctx.options.node_id },
1668
+ position,
1669
+ );
1474
1670
  }
1475
1671
 
1476
1672
  /**
package/src/core.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { AdmissionContext, Auth, ErrorCode, Failure, Mutation, NodeId, NodeOptions, Page, PageOptions, Storage, Transaction, TrustEntry } from './types.js';
2
- import { trustPhase } from './types.js';
1
+ import type { AdmissionContext, Auth, ErrorCode, Failure, Mutation, NodeId, NodeOptions, Page, PageOptions, Storage, Transaction, TrustEntry } from './types.ts';
2
+ import { trustPhase } from './types.ts';
3
3
 
4
4
  const statuses: Record<ErrorCode, number> = {
5
5
  invalid_request: 400, unauthenticated: 401, forbidden: 403, not_a_member: 403,