@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/src/threads.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { admissionView, fail, key, page, requireValue, utc } from './core.js';
2
- import type { Context } from './core.js';
3
- import type { Auth, Entry, Event, Grant, Json, Member, MessageRef, Mutation, Page, PageOptions, ReadScope, ReplyPermit, Thread, ThreadRef, Transaction } from './types.js';
1
+ import { admissionView, decimal, fail, key, page, requireValue, utc } from './core.ts';
2
+ import type { Context } from './core.ts';
3
+ import type { Attention, Auth, Entry, Event, Grant, JoinedThread, Json, LocalAttention, Member, MessageRef, Mutation, Outbound, Page, PageOptions, ReadScope, ReplyPermit, Thread, ThreadDescription, ThreadRef, Transaction } from './types.ts';
4
4
 
5
5
  /** One reserved default-thread id per owner: omitted targets land here, and the id is never reused. */
6
6
  export const DEFAULT_THREAD = 'thr-default';
@@ -113,18 +113,22 @@ interface Known {
113
113
  }
114
114
 
115
115
  export class ThreadService {
116
- constructor(private readonly ctx: Context) {}
116
+ readonly #ctx: Context;
117
+
118
+ constructor(ctx: Context) {
119
+ this.#ctx = ctx;
120
+ }
117
121
 
118
122
  async create(auth: Auth, mutation: Mutation & { title: string; id?: string; default?: boolean }): Promise<Thread> {
119
123
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
120
124
  const owner = auth.actor;
121
- this.ctx.owner(owner);
125
+ this.#ctx.owner(owner);
122
126
  if (typeof mutation.title !== 'string' || mutation.title.length === 0) fail('invalid_request', 'A thread needs a title.');
123
127
  if (mutation.id !== undefined && (typeof mutation.id !== 'string' || mutation.id.length === 0)) fail('invalid_request', 'Thread id must be a non-empty string.');
124
128
  const wantsDefault = mutation.default === true || mutation.id === DEFAULT_THREAD;
125
129
  const body = { title: mutation.title, id: mutation.id ?? null, default: wantsDefault };
126
- return this.ctx.mutate(auth, 'thread.create', mutation, body, async tx => {
127
- const at = new Date(this.ctx.now()).toISOString();
130
+ return this.#ctx.mutate(auth, 'thread.create', mutation, body, async tx => {
131
+ const at = new Date(this.#ctx.now()).toISOString();
128
132
  const existingDefault = await tx.get<Thread>(key('thread', owner, DEFAULT_THREAD));
129
133
  if (wantsDefault) {
130
134
  if (existingDefault) {
@@ -158,29 +162,67 @@ export class ThreadService {
158
162
  async close(auth: Auth, ref: ThreadRef, mutation: Mutation): Promise<Thread> {
159
163
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
160
164
  requireThreadRef(ref);
161
- this.ctx.owner(ref.owner);
165
+ this.#ctx.owner(ref.owner);
162
166
  if (auth.actor !== ref.owner) fail('forbidden', 'Only the owner may close a thread.');
163
167
  const body = { ref };
164
- return this.ctx.mutate(auth, `thread.close/${ref.owner}/${ref.id}`, mutation, body, async tx => {
165
- const thread = await loadThread(tx, this.ctx, ref);
168
+ return this.#ctx.mutate(auth, `thread.close/${ref.owner}/${ref.id}`, mutation, body, async tx => {
169
+ const thread = await loadThread(tx, this.#ctx, ref);
166
170
  if (thread.state === 'closed') fail('thread_closed', 'The thread is already closed.');
167
- return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this.ctx.now()).toISOString() }, 'thread_closed', {});
171
+ return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this.#ctx.now()).toISOString() }, 'thread_closed', {});
168
172
  });
169
173
  }
170
174
 
171
175
  async list(auth: Auth, options: PageOptions = {}): Promise<Page<Thread>> {
172
176
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
173
- this.ctx.owner(auth.actor);
174
- return this.ctx.storage.transaction(async tx => {
177
+ this.#ctx.owner(auth.actor);
178
+ return this.#ctx.storage.transaction(async tx => {
175
179
  const threads = sortedBy((await tx.scan<Thread>(key('thread', auth.actor) + '/')).map(row => row.value), thread => thread.thread_ref.id);
176
180
  return page(threads, options, { kind: 'threads', owner: auth.actor }, thread => thread.thread_ref.id);
177
181
  });
178
182
  }
179
183
 
180
- async describe(auth: Auth, ref: ThreadRef): Promise<{ thread: Thread; members: Member[] }> {
184
+ /**
185
+ * Participant view: the threads this node was invited into and does not own, derived only
186
+ * from the records it already holds — the known-owner rows and grant notices `acceptGrant`
187
+ * wrote, the attention and inbox rows that name a thread, and the receipts of messages it
188
+ * sent there. The title, the state and the member table belong to the owner, so they are
189
+ * absent here; a caller that can reach the owner fills them in. This listing never invents
190
+ * a member it could not read.
191
+ */
192
+ async joined(auth: Auth, options: PageOptions = {}): Promise<Page<JoinedThread>> {
181
193
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
182
- return this.ctx.storage.transaction(async tx => {
183
- const thread = await loadThread(tx, this.ctx, ref);
194
+ if (auth.actor !== this.#ctx.node) fail('forbidden', 'The joined-thread view belongs to this node.');
195
+ return this.#ctx.storage.transaction(async tx => {
196
+ const rows = new Map<string, { ref: ThreadRef; seq: string; messages: Set<string> }>();
197
+ const hold = (ref: ThreadRef, seq?: string, message?: MessageRef): void => {
198
+ // A thread this node owns is already in `list()`; this view is the other side of it.
199
+ if (ref.owner === this.#ctx.node) return;
200
+ const slot = key(ref.owner, ref.id);
201
+ const held = rows.get(slot) ?? { ref: { owner: ref.owner, id: ref.id }, seq: '0', messages: new Set<string>() };
202
+ rows.set(slot, held);
203
+ if (seq !== undefined && decimal(seq) > decimal(held.seq)) held.seq = seq;
204
+ if (message !== undefined) held.messages.add(key(message.sender, message.id));
205
+ };
206
+ for (const row of await tx.scan<Known>(key('known') + '/')) hold(row.value.thread_ref);
207
+ for (const row of await tx.scan<Grant>(key('grant') + '/')) hold(row.value.thread_ref);
208
+ for (const row of await tx.scan<Attention>(key('attention') + '/')) hold(row.value.thread_ref, row.value.seq, row.value.message_ref);
209
+ for (const row of await tx.scan<LocalAttention>(key('inbox') + '/')) hold(row.value.attention.thread_ref, row.value.attention.seq, row.value.attention.message_ref);
210
+ for (const row of await tx.scan<Outbound>(key('outbound') + '/')) {
211
+ const receipt = row.value.receipt;
212
+ if (receipt !== undefined) hold(receipt.thread_ref, receipt.seq, receipt.message_ref);
213
+ }
214
+ const items = sortedBy([...rows.values()], row => key(row.ref.owner, row.ref.id))
215
+ .map((row): JoinedThread => ({
216
+ thread_ref: row.ref, last_seq: row.seq, message_count: row.messages.size, read_only: true,
217
+ }));
218
+ return page(items, options, { kind: 'joined', owner: auth.actor }, item => key(item.thread_ref.owner, item.thread_ref.id));
219
+ });
220
+ }
221
+
222
+ async describe(auth: Auth, ref: ThreadRef): Promise<ThreadDescription> {
223
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
224
+ return this.#ctx.storage.transaction(async tx => {
225
+ const thread = await loadThread(tx, this.#ctx, ref);
184
226
  if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
185
227
  const members = (await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value);
186
228
  return { thread, members };
@@ -198,19 +240,19 @@ export class ThreadService {
198
240
  fail('forbidden', 'A peer may only join or withdraw itself.');
199
241
  }
200
242
  const body = { ref, peer, patch };
201
- return this.ctx.mutate(auth, `thread.member/${ref.owner}/${ref.id}/${peer}`, mutation, body, async tx => {
202
- const thread = await loadThread(tx, this.ctx, ref);
243
+ return this.#ctx.mutate(auth, `thread.member/${ref.owner}/${ref.id}/${peer}`, mutation, body, async tx => {
244
+ const thread = await loadThread(tx, this.#ctx, ref);
203
245
  const current = await tx.get<Member>(key('member', ref.owner, ref.id, peer));
204
246
  let next: Member;
205
247
  if (!isOwner) {
206
248
  if (current === undefined) {
207
249
  if (patch.state === 'revoked') fail('not_found', 'No membership record to withdraw.');
208
250
  if (thread.state === 'closed') fail('thread_closed', 'A closed thread does not accept joins.');
209
- const admitted = (await this.ctx.options.plugins.mayAdmit?.(auth.actor, ref, admissionView(tx))) === true;
210
- next = { peer, state: admitted ? 'active' : 'requested', access: [...DEFAULT_ACCESS], read_scope: {}, revision: this.ctx.id() };
251
+ const admitted = (await this.#ctx.options.plugins.mayAdmit?.(auth.actor, ref, admissionView(tx))) === true;
252
+ next = { peer, state: admitted ? 'active' : 'requested', access: [...DEFAULT_ACCESS], read_scope: {}, revision: this.#ctx.id() };
211
253
  } else if (patch.state === 'revoked') {
212
254
  if (current.state !== 'active' && current.state !== 'requested') fail('conflict', 'This membership is already stopped.');
213
- next = { ...current, state: 'revoked', access: [], revision: this.ctx.id() };
255
+ next = { ...current, state: 'revoked', access: [], revision: this.#ctx.id() };
214
256
  } else {
215
257
  fail('conflict', 'This peer already holds a membership record; only the owner changes it.');
216
258
  }
@@ -219,7 +261,7 @@ export class ThreadService {
219
261
  if (state !== 'requested' && state !== 'active' && state !== 'revoked' && state !== 'denied') fail('invalid_request', 'Unknown member state.');
220
262
  const stopping = state === 'revoked' || state === 'denied';
221
263
  const access = stopping ? [] : patch.access === undefined ? (current?.state === 'active' ? current.access : [...DEFAULT_ACCESS]) : requireAccess(patch.access);
222
- next = { peer, state, access: [...access], read_scope: patch.read_scope === undefined ? (current?.read_scope ?? {}) : requireScope(patch.read_scope), revision: this.ctx.id() };
264
+ next = { peer, state, access: [...access], read_scope: patch.read_scope === undefined ? (current?.read_scope ?? {}) : requireScope(patch.read_scope), revision: this.#ctx.id() };
223
265
  }
224
266
  await tx.put(key('member', ref.owner, ref.id, peer), next);
225
267
  if (next.state === 'active' && current?.state !== 'active' && peer !== ref.owner) await issueGrant(tx, ref, peer, next.revision);
@@ -230,8 +272,8 @@ export class ThreadService {
230
272
 
231
273
  async members(auth: Auth, ref: ThreadRef, options: PageOptions = {}): Promise<Page<Member>> {
232
274
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
233
- return this.ctx.storage.transaction(async tx => {
234
- await loadThread(tx, this.ctx, ref);
275
+ return this.#ctx.storage.transaction(async tx => {
276
+ await loadThread(tx, this.#ctx, ref);
235
277
  if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
236
278
  const members = sortedBy((await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value), member => member.peer);
237
279
  return page(members, options, { kind: 'members', ref }, member => member.peer);
@@ -240,8 +282,8 @@ export class ThreadService {
240
282
 
241
283
  async defaultThread(auth: Auth): Promise<ThreadRef> {
242
284
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
243
- this.ctx.owner(auth.actor);
244
- return this.ctx.storage.transaction(async tx => requireValue(await tx.get<ThreadRef>(key('default', auth.actor))));
285
+ this.#ctx.owner(auth.actor);
286
+ return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<ThreadRef>(key('default', auth.actor))));
245
287
  }
246
288
 
247
289
  /**
@@ -251,8 +293,8 @@ export class ThreadService {
251
293
  */
252
294
  async notices(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
253
295
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
254
- this.ctx.owner(auth.actor);
255
- return this.ctx.storage.transaction(async tx => {
296
+ this.#ctx.owner(auth.actor);
297
+ return this.#ctx.storage.transaction(async tx => {
256
298
  const held = sortedBy((await tx.scan<Grant>(key('notice') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
257
299
  return page(held, options, { kind: 'notices', owner: auth.actor }, grant => grant.id);
258
300
  });
@@ -265,7 +307,7 @@ export class ThreadService {
265
307
  */
266
308
  async grants(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
267
309
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
268
- return this.ctx.storage.transaction(async tx => {
310
+ return this.#ctx.storage.transaction(async tx => {
269
311
  const held = sortedBy((await tx.scan<Grant>(key('grant') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
270
312
  return page(held, options, { kind: 'grants', recipient: auth.actor }, grant => grant.id);
271
313
  });
@@ -274,7 +316,7 @@ export class ThreadService {
274
316
  /** Recipient side: records that the owner confirmed this grant, so it is not confirmed twice. */
275
317
  async ackGrantLocally(auth: Auth, ref: ThreadRef, id: string): Promise<void> {
276
318
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
277
- await this.ctx.storage.transaction(async tx => {
319
+ await this.#ctx.storage.transaction(async tx => {
278
320
  const slot = key('grant', ref.owner, id);
279
321
  const stored = await tx.get<Grant>(slot);
280
322
  if (stored !== undefined && !stored.acked) await tx.put(slot, { ...stored, acked: true });
@@ -285,6 +327,10 @@ export class ThreadService {
285
327
  * Recipient side of grant acceptance: an owner pushes us a notice we received during
286
328
  * a prior join. We store both the notice and the known-owner record keyed owner+threadid.
287
329
  * Caller is the owner the notice names, notice is addressed to our node, thread stays on owner.
330
+ *
331
+ * A notice that names `previous_thread_ref` is a re-issue after its owner changed identity:
332
+ * the ref it names is the one this node learned before, so that known row is dropped. Two
333
+ * rows for one thread would make every reconciliation pass pull the dead owner.
288
334
  */
289
335
  async acceptGrant(auth: Auth, grant: Grant): Promise<Grant> {
290
336
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
@@ -293,18 +339,31 @@ export class ThreadService {
293
339
  }
294
340
  requireThreadRef(grant.thread_ref);
295
341
  if (grant.thread_ref.owner !== auth.actor) fail('forbidden', 'A grant notice must come from the thread owner it names.');
296
- if (grant.recipient !== this.ctx.node) fail('forbidden', 'The grant notice is addressed to another node.');
342
+ if (grant.recipient !== this.#ctx.node) fail('forbidden', 'The grant notice is addressed to another node.');
343
+ const previous = grant.previous_thread_ref;
344
+ if (previous !== undefined) requireThreadRef(previous);
297
345
  const ref: ThreadRef = { owner: grant.thread_ref.owner, id: grant.thread_ref.id };
298
- return this.ctx.storage.transaction(async tx => {
346
+ return this.#ctx.storage.transaction(async tx => {
299
347
  const slot = key('grant', ref.owner, grant.id);
300
348
  const stored = await tx.get<Grant>(slot);
301
- if (stored !== undefined && (stored.recipient !== this.ctx.node || stored.revision !== grant.revision || stored.thread_ref.id !== ref.id)) {
349
+ if (stored !== undefined && (stored.recipient !== this.#ctx.node || stored.revision !== grant.revision || stored.thread_ref.id !== ref.id)) {
302
350
  fail('id_conflict', 'The grant id is bound to another notice.');
303
351
  }
304
352
  const known: Known = { owner: ref.owner, thread_ref: ref, revision: grant.revision };
305
353
  await tx.put(key('known', ref.owner, ref.id), known);
354
+ if (previous !== undefined && (previous.owner !== ref.owner || previous.id !== ref.id)) {
355
+ await tx.delete(key('known', previous.owner, previous.id));
356
+ // The copied grant rows under the old owner are what the confirmation pass walks: left
357
+ // in place they keep confirming a notice the dead owner issued and keep the dead thread
358
+ // in this node's joined list. A re-issue replaces them, so drop every row that names it.
359
+ for (const row of await tx.scan<Grant>(key('grant') + '/')) {
360
+ if (row.value.thread_ref.owner === previous.owner && row.value.thread_ref.id === previous.id) {
361
+ await tx.delete(row.key);
362
+ }
363
+ }
364
+ }
306
365
  if (stored !== undefined) return stored;
307
- const next: Grant = { id: grant.id, recipient: this.ctx.node, thread_ref: ref, revision: grant.revision, acked: false };
366
+ const next: Grant = { id: grant.id, recipient: this.#ctx.node, thread_ref: ref, revision: grant.revision, acked: false };
308
367
  await tx.put(slot, next);
309
368
  return next;
310
369
  });
@@ -318,7 +377,7 @@ export class ThreadService {
318
377
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
319
378
  if (typeof id !== 'string' || id.length === 0) fail('invalid_request', 'A grant id is required.');
320
379
  const body = { id };
321
- return this.ctx.mutate(auth, `thread.grant.ack/${id}`, mutation, body, async tx => {
380
+ return this.#ctx.mutate(auth, `thread.grant.ack/${id}`, mutation, body, async tx => {
322
381
  const grant = requireValue(await tx.get<Grant>(key('notice', id)));
323
382
  if (grant.recipient !== auth.actor) fail('forbidden', 'The grant notice is addressed to another recipient.');
324
383
  if (grant.acked) return grant;
@@ -335,12 +394,12 @@ export class ThreadService {
335
394
  if (original.sender !== auth.actor) fail('forbidden', 'Only the original message sender may issue a reply permit.');
336
395
  if (typeof permit.grantee !== 'string' || !permit.grantee || permit.grantee === auth.actor) fail('invalid_request', 'A permit names one grantee other than its issuer.');
337
396
  requireThreadRef(permit.landing);
338
- this.ctx.owner(permit.landing.owner);
397
+ this.#ctx.owner(permit.landing.owner);
339
398
  if (!Number.isInteger(permit.remaining) || permit.remaining < 1) fail('invalid_request', 'A permit needs a bounded positive reply count.');
340
- if (utc(permit.expires_at) <= this.ctx.now()) fail('invalid_request', 'A permit needs a future expiry.');
399
+ if (utc(permit.expires_at) <= this.#ctx.now()) fail('invalid_request', 'A permit needs a future expiry.');
341
400
  const body = { message_ref: { sender: original.sender, id: original.id }, grantee: permit.grantee, landing: permit.landing, expires_at: permit.expires_at, remaining: permit.remaining, followup: permit.followup === true };
342
- return this.ctx.mutate(auth, `thread.permit/${original.sender}/${original.id}/${permit.grantee}`, mutation, body, async tx => {
343
- const thread = await loadThread(tx, this.ctx, permit.landing);
401
+ return this.#ctx.mutate(auth, `thread.permit/${original.sender}/${original.id}/${permit.grantee}`, mutation, body, async tx => {
402
+ const thread = await loadThread(tx, this.#ctx, permit.landing);
344
403
  if (thread.state === 'closed') fail('thread_closed', 'A permit cannot land on a closed thread.');
345
404
  const stored: ReplyPermit = { ...body, revoked: permit.revoked === true };
346
405
  await tx.put(key('permit', original.sender, original.id, permit.grantee), stored);
package/src/transport.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { OnlyneError } from './core.js';
2
- import type { ErrorCode, Failure, NodeId, Target, Transport, TrustEntry } from './types.js';
3
- import type { TrustService } from './keys.js';
1
+ import { OnlyneError } from './core.ts';
2
+ import type { ErrorCode, Failure, NodeId, Target, Transport, TrustEntry } from './types.ts';
3
+ import type { TrustService } from './keys.ts';
4
4
 
5
5
  /** The trust reads and signing steps a transport performs; a host that builds its own node wraps the real service. */
6
6
  export type SigningTrust = Pick<TrustService, 'entry' | 'signRequest' | 'verifyResponse'>;
@@ -64,11 +64,15 @@ function failureRecord(parsed: unknown): unknown {
64
64
  /**
65
65
  * RFC 0017 §3: what a signed request hits when this node holds no trusted key for the peer.
66
66
  * Both node ids travel in the text and in `details`, because a missing key here says nothing
67
- * about whether the peer runs. The agent cannot pair the nodes — only the node's holder can,
68
- * through the host surface — and the runtime re-sends the record that stays open, so the code
69
- * is `unavailable` and the message keeps that retry apart from an agent repeating the send.
70
- * The failure names the responsible holder and the act; the host's `guidance` adds the menu
71
- * entry, so every trust case ends at one concrete surface instead of a search.
67
+ * about whether the peer runs. The runtime re-sends the record that stays open, so the code is
68
+ * `unavailable` and the message keeps that retry apart from an agent repeating the send.
69
+ *
70
+ * A pending exchange is not a task for the reader: the invitation is already recorded on the
71
+ * side that owes the approval, and the acquisition policy there submits the approval on the
72
+ * next attempt, or that node's holder confirms it (RFC 0018 §4). The text says that instead of
73
+ * prescribing a menu step, because with policy `all` or `segment` no one has to act. The
74
+ * `no_record` and `revoked` cases keep the host's `guidance` menu, since nothing records them
75
+ * yet and only a holder can start or restore the exchange.
72
76
  */
73
77
  function missingTrust(
74
78
  self: NodeId | null, target: NodeId, peer: TrustEntry | undefined,
@@ -85,8 +89,11 @@ function missingTrust(
85
89
  const surface = guide === undefined ? '' : `: ${guide.entry.join(' → ')}`;
86
90
  const failure = guide === undefined ? details : { ...details, entry: guide.entry };
87
91
  if (reason === 'no_record') {
92
+ // The exchange has a sanctioned start that is not a human step: `trust_begin` on this
93
+ // node, judged by this node's acquisition policy, and the peer's policy judges the
94
+ // statement that arrives. Naming only the panel hid that path (tern host report).
88
95
  return new OnlyneError('unavailable',
89
- `${here} holds no trust record for ${target}, so this request cannot be signed. This agent cannot grant trust; the holder of ${here} completes the pairing through this node's Onlyne surface${surface}. ${retry}`,
96
+ `${here} holds no trust record for ${target}, so this request cannot be signed. Start the exchange with trust_begin, which the acquisition policy on ${here} judges; ${target} can also start one, and this node's policy judges the arriving statement. A holder can complete the pairing through this node's Onlyne surface${surface}. ${retry}`,
90
97
  failure);
91
98
  }
92
99
  if (reason === 'revoked') {
@@ -95,8 +102,8 @@ function missingTrust(
95
102
  failure);
96
103
  }
97
104
  const message = peer!.local_approved
98
- ? `${target} has not answered the exchange ${here} requested, so this request cannot be signed yet. This agent cannot complete the pairing; the holder of ${target} confirms it through that node's Onlyne surface${surface}. ${retry}`
99
- : `${target} requested an exchange with ${here} and this node has not approved it, so this request cannot be signed yet. This agent cannot approve it; the holder of ${here} confirms the fingerprint through this node's Onlyne surface${surface}. ${retry}`;
105
+ ? `${target} has not answered the exchange ${here} requested, so this request cannot be signed yet. The invitation is recorded at ${target} as pending; the acquisition policy on that node submits the approval on the next attempt, or its holder confirms it${surface}. ${retry}`
106
+ : `${target} requested an exchange with ${here} and this node has not approved it, so this request cannot be signed yet. The invitation is recorded here as pending; this node's acquisition policy submits the approval on the next attempt, or its holder confirms it${surface}. ${retry}`;
100
107
  return new OnlyneError('unavailable', message, failure);
101
108
  }
102
109
 
package/src/types.ts CHANGED
@@ -77,6 +77,28 @@ export interface Thread {
77
77
  event_seq: string; created_at: string; closed_at: string | null;
78
78
  messages_until?: string;
79
79
  }
80
+ /**
81
+ * A thread this node was invited into and does not own. Every field here comes from a record
82
+ * this node already holds; the owner's title, state and member table are filled in only by a
83
+ * caller that could ask the owner, and a row that could not ask keeps the failure instead of
84
+ * inventing them.
85
+ */
86
+ export interface JoinedThread {
87
+ thread_ref: ThreadRef;
88
+ /** The highest sequence this node holds a local record for; `0` when it holds none. */
89
+ last_seq: string;
90
+ /** Distinct messages this node holds for the thread. */
91
+ message_count: number;
92
+ /** A participant never owns the member table, so this view is read-only by construction. */
93
+ read_only: true;
94
+ title?: string;
95
+ state?: Thread['state'];
96
+ members?: Member[];
97
+ /** The owner could not be asked; the row stays listed with what the local records prove. */
98
+ unavailable?: Failure;
99
+ }
100
+ /** One thread record with its member table: what the describe read answers, here or at the owner. */
101
+ export interface ThreadDescription { thread: Thread; members: Member[] }
80
102
  export interface Attention {
81
103
  obligation_id: string; recipient: NodeId; thread_ref: ThreadRef; seq: string;
82
104
  message_ref: MessageRef; authenticated_author: NodeId; acked: boolean;
@@ -86,6 +108,12 @@ export interface Attention {
86
108
  }
87
109
  export interface Grant {
88
110
  id: string; recipient: NodeId; thread_ref: ThreadRef; revision: string; acked: boolean;
111
+ /**
112
+ * Set on a re-issued notice: the ref the recipient learned under the previous owner. The
113
+ * recipient stores the new known row and drops the one this names, so a renamed owner does
114
+ * not leave a second row pointing at an endpoint that no longer exists.
115
+ */
116
+ previous_thread_ref?: ThreadRef;
89
117
  }
90
118
  export interface Event {
91
119
  id: string; type: 'message' | 'membership' | 'thread_closed' | 'resource_event' | 'operation';
@@ -139,11 +167,16 @@ export interface OperationRecord {
139
167
  export interface Outbound {
140
168
  request: SendRequest; expected_author: NodeId; state: 'pending' | 'accepted' | 'refused' | 'unknown';
141
169
  receipt?: Receipt; error?: Failure; created_at?: string;
170
+ /** The peer this send addresses when it differs from the endpoint that stores the entry. */
171
+ addressee?: NodeId;
172
+ /** The caller's own spelling of the addressee, when it differs from the resolved node id. */
173
+ requested?: string;
142
174
  }
143
175
  export interface HistoryMessage {
144
176
  message_ref: MessageRef; message: Message; direction: 'in' | 'out';
145
177
  thread_ref?: ThreadRef; seq?: string; at: string | null;
146
178
  delivery: Outbound['state'] | 'received'; consumption?: 'pending' | 'consumed';
179
+ settlement?: AttentionSettlement;
147
180
  guarantee?: Storage['guarantee']; error?: Failure;
148
181
  }
149
182
  export interface Conversation {
@@ -151,15 +184,17 @@ export interface Conversation {
151
184
  last_at: string | null;
152
185
  }
153
186
  export interface HistoryQuery extends PageOptions { thread?: ThreadRef; message?: MessageRef }
187
+ export type AttentionSettlement =
188
+ | { via: 'handover'; resource_ref: ResourceRef; handler: NodeId; revision: string; at: string }
189
+ | { via: 'undeliverable'; resource_ref: ResourceRef; reason: 'resource-unavailable'; at: string };
154
190
  /**
155
- * One queued obligation and what became of it. `context` is where the delivery was sent;
156
- * `injected` is **observed**, not submitted: it flips only in `confirmConsumption`, once the
157
- * host has seen the entry in a provider request. A message submitted into a session that never
158
- * reads it therefore stays `injected: false` and keeps its place in unread and alerts
159
- * (caerulea report 3, problem 1). The name predates that rule and is kept because renaming it
160
- * would read every stored row as unobserved and re-inject what was already read.
191
+ * `injected` closes the local delivery obligation after observed consumption or an explicit
192
+ * settlement. A settlement records why delivery ended without claiming model consumption.
161
193
  */
162
- export interface LocalAttention { attention: Attention; context?: string; injected: boolean }
194
+ export interface LocalAttention {
195
+ attention: Attention; context?: string; injected: boolean; settlement?: AttentionSettlement;
196
+ resource_check_failures?: number;
197
+ }
163
198
  export interface Target { node_id: NodeId; base_url: string; headers?: Record<string, string> }
164
199
  export interface Transport {
165
200
  request<T>(target: NodeId, method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
@@ -193,11 +228,19 @@ export type TraceEvent =
193
228
  | { event: 'inbound.duplicate'; sender: NodeId; message: string }
194
229
  | { event: 'inbound.acked'; obligation: string; thread: ThreadRef; seq: string }
195
230
  | { event: 'inbound.inject'; thread: ThreadRef; seq: string; context: string; outcome: InjectOutcome; reason?: string }
231
+ | { event: 'inbound.settled'; thread: ThreadRef; seq: string; settlement: AttentionSettlement }
232
+ | { event: 'inbound.resource-check.failed'; thread: ThreadRef; seq: string; resource_ref: ResourceRef; code: ErrorCode; attempts: number }
196
233
  | { event: 'consumption.confirmed'; thread: ThreadRef; seq: string; context: string; via: 'fragment' | 'tool_result' }
197
- | { event: 'outbound.admitted'; sender: NodeId; message: string; target: string; state: string; guarantee?: string; code?: string }
234
+ | { event: 'outbound.admitted'; sender: NodeId; message: string; target: string; state: string; guarantee?: string; code?: string; resolved?: NodeId }
198
235
  | { event: 'outbound.redriven'; sender: NodeId; message: string; target: string }
199
236
  | { event: 'outbound.expired'; sender: NodeId; message: string; code: string }
200
- | { event: 'await.settled'; thread: ThreadRef; seq: string; waiters: number };
237
+ | { event: 'await.settled'; thread: ThreadRef; seq: string; waiters: number }
238
+ /**
239
+ * A known owner this node could not reconcile, written at most once per (owner, thread, code)
240
+ * per window. `attempts` is how many failed passes that one line accounts for, the suppressed
241
+ * duplicates included; the local rows a failed pull never reached are still owed, not dropped.
242
+ */
243
+ | { event: 'reconcile.failed'; owner: NodeId; thread: string; code: ErrorCode; message: string; attempts: number };
201
244
  /** What the host did with one inbound entry, reported back with the injection result. */
202
245
  export type InjectOutcome = 'delivered' | 'queued' | 'waiting' | 'skipped' | 'consumed';
203
246
  export interface Runtime {
@@ -331,6 +374,8 @@ export interface Plugins {
331
374
  resolve(node: NodeId): Promise<Target>;
332
375
  decide(input: MsgInput): Promise<Action>;
333
376
  runtime?: Runtime;
377
+ /** When true, candidate exchange allows loopback addresses (useful in local dev & testing). */
378
+ allow_loopback?: boolean;
334
379
  /** Supplied when the host wants the library to own the maintenance loop (RFC 0017 §6). */
335
380
  maintenanceClock?: MaintenanceClock;
336
381
  resources?: ResourceDriver;