omp-onlyne 0.1.0 → 0.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.
package/src/decider.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type Action,
9
9
  type DeliveryPlan,
10
10
  type Entry,
11
+ type JoinedThread,
11
12
  type Json,
12
13
  type MessageRef,
13
14
  type MsgInput,
@@ -19,6 +20,7 @@ import {
19
20
  OnlyneError,
20
21
  } from "@onlyne/lib";
21
22
  import { getLocale } from "@onlyne/node-host/i18n";
23
+ import { isTrustThread } from "./handshake.ts";
22
24
  import type { ContextRecord, OnlyneConfig } from "./types.ts";
23
25
  import { canAnswer, type ContextRegistry } from "./store.ts";
24
26
  import { text } from "./decider-text.ts";
@@ -31,14 +33,14 @@ interface ChoiceQuestion {
31
33
  criteria: Record<string, string>;
32
34
  }
33
35
 
34
- type Questions = Record<string, ChoiceQuestion>;
36
+ export type Questions = Record<string, ChoiceQuestion>;
35
37
 
36
38
  interface ChoiceAnswer {
37
39
  type?: string;
38
40
  choice?: string;
39
41
  }
40
42
 
41
- type Answers = Record<string, ChoiceAnswer | undefined>;
43
+ export type Answers = Record<string, ChoiceAnswer | undefined>;
42
44
 
43
45
  /** A decision input that either carries its records or states why it has none. */
44
46
  type Source<T> = { available: true; value: T } | { available: false; reason: string };
@@ -106,6 +108,73 @@ export function currentDecision(): DecisionScope | undefined {
106
108
  return decisionScope.getStore();
107
109
  }
108
110
 
111
+ /**
112
+ * The thread a send continues: the most recently opened open thread this node owns whose members
113
+ * already include the addressee. Derived from local records alone (RFC 0016 §5), so it costs no
114
+ * judgement. A node may hold several open threads with one peer; the newest is the conversation
115
+ * still in use, and a caller that wants a fresh topic names one or plans `execution: new`.
116
+ *
117
+ * Only a thread holding this node and the addressee continues: a thread with a third member is a
118
+ * group conversation, and folding a private message into it would put a body in front of every
119
+ * member. A group is addressed by naming it, which is why `meta.thread` wins over this lookup.
120
+ */
121
+ export async function reuseThread(deps: DeciderDeps, to: NodeId): Promise<ThreadRef | undefined> {
122
+ const self = deps.config.node.id;
123
+ const threads = await deps.listThreads();
124
+ const candidates = threads
125
+ .filter(thread => thread.thread_ref.owner === self && thread.state === "open")
126
+ .sort((left, right) => right.created_at.localeCompare(left.created_at));
127
+ for (const thread of candidates) {
128
+ let members: NodeId[];
129
+ try {
130
+ members = await deps.threadMembers(thread.thread_ref);
131
+ } catch (error) {
132
+ // An unreadable member list is a reason to try the next thread, not to open a new one
133
+ // silently, so it is reported and skipped.
134
+ deps.log?.("onlyne.thread.members_unreadable", { thread: threadKey(thread.thread_ref), error: message(error) });
135
+ continue;
136
+ }
137
+ if (members.includes(to) && members.every(member => member === self || member === to)) return thread.thread_ref;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * Every peer this node can name, from both sources that know one.
144
+ *
145
+ * A trust row is identity: the peer exists whether or not anyone answered a probe recently. A
146
+ * discovery record is an address, and a live one wins over the address a statement declared.
147
+ * Callers that ask "who is this" and callers that ask "who may receive this" must not diverge,
148
+ * or the same peer resolves for `meta.to` and vanishes from the target question — the shape of
149
+ * the reported `No discovered node is named …` failure.
150
+ */
151
+ export async function knownPeers(deps: DeciderDeps): Promise<PeerRecord[]> {
152
+ const byId = new Map<NodeId, PeerRecord>();
153
+ let readable = false;
154
+ let failure = "";
155
+ try {
156
+ for (const row of await deps.listTrust()) {
157
+ readable = true;
158
+ if (row.phase === "revoked") continue;
159
+ byId.set(row.node_id, { node_id: row.node_id, base_url: row.base_url ?? "", instance_id: "trust", last_seen: "" });
160
+ }
161
+ } catch (error) {
162
+ failure = message(error);
163
+ deps.log?.("onlyne.judge.trust_unreadable", { error: failure });
164
+ }
165
+ try {
166
+ for (const peer of await deps.listPeers()) {
167
+ readable = true;
168
+ byId.set(peer.node_id, peer);
169
+ }
170
+ } catch (error) {
171
+ failure = message(error);
172
+ deps.log?.("onlyne.judge.peers_unreadable", { error: failure });
173
+ }
174
+ if (!readable) throw new OnlyneError("unavailable", text("reason.discovery_unreadable", { error: failure }));
175
+ return [...byId.values()].filter(peer => peer.node_id !== deps.config.node.id);
176
+ }
177
+
109
178
  export interface DeciderDeps {
110
179
  config: OnlyneConfig;
111
180
  /** Registry id of this node's main session; children are bound under it. */
@@ -113,12 +182,22 @@ export interface DeciderDeps {
113
182
  contexts: ContextRegistry;
114
183
  log?(message: string, fields: Record<string, unknown>): void;
115
184
  listThreads(): Promise<Thread[]>;
185
+ /** The threads this node was invited into; they live on their owner, never here. */
186
+ listJoinedThreads(): Promise<JoinedThread[]>;
116
187
  listMessages(thread: ThreadRef, cursor?: string): Promise<Page<Entry>>;
188
+ /** The peers one thread already lists as members; a send reuses the thread a peer belongs to. */
189
+ threadMembers(thread: ThreadRef): Promise<NodeId[]>;
117
190
  /** Peers from the library's live discovery records, never a static configuration. */
118
191
  listPeers(): Promise<PeerRecord[]>;
192
+ /** Every trust row this node holds, in any phase. Identity outlives a discovery record. */
193
+ listTrust(): Promise<Array<{ node_id: NodeId; phase: string; base_url?: string }>>;
194
+ /** The inbound records this node holds; O-2A's candidate set reads the entries from them. */
195
+ listInbox(): Promise<Array<{ context?: string; attention: { entry?: Entry; disposition?: string } }>>;
119
196
  /** Contexts the target node publishes for `execution: existing`. */
120
197
  listReceiverContexts(nodeId: NodeId): Promise<ReceiverContext[]>;
121
198
  resourceQuery?(meta: Record<string, Json>): Promise<ResourceReadQuery>;
199
+ /** Test seam: answers the judge's questions without a model. Production never sets it. */
200
+ judge?: (state: Row, questions: Questions) => Promise<Answers>;
122
201
  }
123
202
 
124
203
  const TODO_ENTRY = "mouriya.omp.todo-more";
@@ -139,6 +218,8 @@ const INBOUND_CONTEXT_CAP = 3;
139
218
  /** Threads and entries a target question scans for recent unanswered inbound messages. */
140
219
  const RECENT_INBOUND_THREADS = 3;
141
220
  const RECENT_INBOUND_PER_THREAD = 3;
221
+ /** The O-2A candidate list is capped; the judge reads one line per candidate. */
222
+ const REPLY_CANDIDATES = 5;
142
223
  /** Newest branch rows that may reach the trigger derivation; older work is history, not decision material. */
143
224
  const HISTORY_ROWS = 40;
144
225
  /** One row's text cap: a pasted terminal dump must not crowd out the rest of the state. */
@@ -185,8 +266,9 @@ export class JudgeDecider {
185
266
  async decide(scope: DecisionScope): Promise<Action> {
186
267
  const explicit = await this.#explicit(scope);
187
268
  if (explicit !== undefined) {
188
- await this.#guard(scope, explicit);
189
- return explicit;
269
+ const filled = await this.#threaded(explicit);
270
+ await this.#guard(scope, filled);
271
+ return filled;
190
272
  }
191
273
 
192
274
  // A subagent classifies exactly as the main session does and `#guard` decides what may run.
@@ -204,9 +286,27 @@ export class JudgeDecider {
204
286
  : intent === "manage_domain"
205
287
  ? await this.#domainAction(scope, meta)
206
288
  : await this.#resourceAction(meta);
207
- await this.#guard(scope, action);
208
- this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(action) });
209
- return action;
289
+ const filled = await this.#threaded(action);
290
+ await this.#guard(scope, filled);
291
+ this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
292
+ return filled;
293
+ }
294
+
295
+ /**
296
+ * The thread a send belongs to, decided once for every path that produces one.
297
+ *
298
+ * A reply lands on the thread that holds its original (the client resolves it), and a plan
299
+ * that says `new` opens a topic. Otherwise the message continues the conversation this node
300
+ * already has with its addressee: leaving it unnamed makes the client open a thread per
301
+ * message, and asking the model for an alias it cannot know is the mistake the design already
302
+ * recorded. This is a lookup over local records, not a judgement (RFC 0016 §5), so it sits
303
+ * after both the explicit and the judged paths rather than inside either.
304
+ */
305
+ async #threaded(action: Action): Promise<Action> {
306
+ if (action.kind !== "send" || action.thread !== undefined) return action;
307
+ if (action.in_reply_to !== undefined || action.execution === "new") return action;
308
+ const thread = await reuseThread(this.deps, action.to);
309
+ return thread === undefined ? action : { ...action, thread };
210
310
  }
211
311
 
212
312
  /** Node O-1: one question, five options, no lists of peers, threads, or history. */
@@ -244,8 +344,15 @@ export class JudgeDecider {
244
344
 
245
345
  /** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
246
346
  async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
247
- const reply = await this.#reply(meta);
347
+ let reply = await this.#reply(meta);
248
348
  const explicitRelation = relation(meta);
349
+ if (reply === undefined && explicitRelation === undefined) {
350
+ // Node O-2A: no reference and no relation. The judge still answers one question —
351
+ // whether this call answers one of the inbound messages this context can see — and
352
+ // the plugin copies the exact ref from the record it names. Candidates are evidence
353
+ // for the question only: recency or being the sole pending message never binds one.
354
+ reply = await this.#originalQuestion(scope);
355
+ }
249
356
  const lockedRelation = reply !== undefined
250
357
  ? explicitRelation ?? await this.#relationQuestion(scope, meta)
251
358
  : undefined;
@@ -296,6 +403,102 @@ export class JudgeDecider {
296
403
  };
297
404
  }
298
405
 
406
+ /**
407
+ * Node O-2A: the original this call answers. The judge chooses among the inbound records
408
+ * this context can see and nobody has answered yet, plus `new_message`; the plugin copies
409
+ * the exact MessageRef and the owner-attested reply authority from the record it names, so
410
+ * no message id ever travels from the model to the wire.
411
+ */
412
+ async #originalQuestion(scope: DecisionScope): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
413
+ const candidates = await this.#replyCandidates(scope);
414
+ if (candidates.length === 0) return undefined;
415
+ const started = Date.now();
416
+ const keyOf = (index: number): string => `answer_${index + 1}`;
417
+ const criteria: Record<string, string> = { new_message: text("judge.original.new") };
418
+ const facts = candidates.map((entry, index) => {
419
+ criteria[keyOf(index)] = text("judge.original.candidate", {
420
+ from: entry.message.sender, kind: entry.message.delivery_kind,
421
+ thread: threadKey(entry.thread_ref), seq: entry.seq, body: boundText(entry.message.msg),
422
+ });
423
+ return { key: keyOf(index), from: entry.message.sender, kind: entry.message.delivery_kind, thread: threadKey(entry.thread_ref), seq: entry.seq };
424
+ });
425
+ const answers = await this.#judge(
426
+ scope.ctx,
427
+ { focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
428
+ { original: { type: "choice", instructions: text("judge.original.instructions"), criteria } },
429
+ scope.signal,
430
+ );
431
+ this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
432
+ const picked = choice(answers, "original");
433
+ if (picked === undefined || picked === "new_message") return undefined;
434
+ const entry = candidates[Number(picked.slice("answer_".length)) - 1];
435
+ if (entry === undefined) {
436
+ throw new OnlyneError("decision_unavailable", text("error.reply_relation"));
437
+ }
438
+ return {
439
+ ref: { sender: entry.message.sender, id: entry.message.id },
440
+ authority: entry.reply_authority,
441
+ thread_ref: entry.thread_ref,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * The inbound records this call can see and nobody has answered: rows delivered to this
447
+ * session's context (the main session also sees rows no context claimed), minus the host's
448
+ * handshake bookkeeping, minus entries a local reply already references. This set is what
449
+ * O-2A offers the judge; it is never an answer by itself (RFC 0016 §7).
450
+ */
451
+ async #replyCandidates(scope: DecisionScope): Promise<Entry[]> {
452
+ if (scope.readOnly === true) return [];
453
+ try {
454
+ const here = await this.#decisionContext(scope.ctx);
455
+ if (here === undefined) return [];
456
+ const rows = await this.deps.listInbox();
457
+ const candidates: Entry[] = [];
458
+ for (const row of rows) {
459
+ const entry = row.attention.entry;
460
+ if (entry === undefined || row.attention.disposition === "expired") continue;
461
+ if (entry.message.sender === this.deps.config.node.id) continue;
462
+ if (isTrustThread(this.deps.config.node.id, entry.thread_ref)) continue;
463
+ const deliveredHere = here.main
464
+ ? row.context === undefined || row.context === "main"
465
+ : row.context === here.alias;
466
+ if (!deliveredHere) continue;
467
+ if (await this.#answered(entry)) continue;
468
+ candidates.push(entry);
469
+ if (candidates.length >= REPLY_CANDIDATES) break;
470
+ }
471
+ return candidates;
472
+ } catch (error) {
473
+ // A failed read narrows the question, not the decision: the call proceeds as a new message.
474
+ this.deps.log?.("onlyne.judge.hint_unavailable", { what: "reply_candidates", error: message(error) });
475
+ return [];
476
+ }
477
+ }
478
+
479
+ /** The context this session answers in: the main session, or a registered subagent context. */
480
+ async #decisionContext(ctx: ExtensionContext): Promise<{ alias?: string; main: boolean } | undefined> {
481
+ if (ctx.agent.id === this.deps.mainAgentId) return { main: true };
482
+ const record = (await this.deps.contexts.list()).find(row => row.agent_id === ctx.agent.id);
483
+ return record === undefined ? undefined : { alias: record.alias, main: false };
484
+ }
485
+
486
+ /** Whether this node already answered the entry in the thread that holds it. */
487
+ async #answered(entry: Entry): Promise<boolean> {
488
+ let cursor: string | undefined;
489
+ for (let page = 0; page < MAX_TAIL_PAGES; page++) {
490
+ const result = await this.deps.listMessages(entry.thread_ref, cursor);
491
+ for (const row of result.items) {
492
+ const ref = row.message.meta?.in_reply_to;
493
+ if (row.message.sender === this.deps.config.node.id && ref !== undefined
494
+ && ref.sender === entry.message.sender && ref.id === entry.message.id) return true;
495
+ }
496
+ cursor = result.next_cursor ?? undefined;
497
+ if (cursor === undefined) return false;
498
+ }
499
+ return false;
500
+ }
501
+
299
502
  /** Node O-2A question 1: the reply/followup/new relation, asked only when meta left it open. */
300
503
  async #relationQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<"reply" | "followup"> {
301
504
  const started = Date.now();
@@ -324,19 +527,18 @@ export class JudgeDecider {
324
527
 
325
528
  /** Node O-2A question 2: the delivery target among live peers, asked only when meta.to is absent. */
326
529
  async #targetQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<NodeId> {
327
- const peers = await this.#peers();
328
- if (!peers.available) {
329
- throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
330
- }
331
- // One discovered peer is the answer; none means the caller must name a target.
332
- if (peers.value.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
333
- if (peers.value.length === 1) return peers.value[0]!.node_id;
530
+ // The same set `#peerId` resolves against: a peer this node holds a trust row with is a
531
+ // candidate even when discovery has nothing fresh to say about its address.
532
+ const peers = await knownPeers(this.deps);
533
+ // One known peer is the answer; none means the caller must name a target.
534
+ if (peers.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
535
+ if (peers.length === 1) return peers[0]!.node_id;
334
536
  const started = Date.now();
335
537
  const answers = await this.#judge(
336
538
  scope.ctx,
337
539
  {
338
540
  focus: { ...this.#focus(scope), meta_to: meta.to ?? null },
339
- candidate_peers: peers.value.map(peer => ({
541
+ candidate_peers: peers.map(peer => ({
340
542
  node_id: peer.node_id,
341
543
  alias: lastSegment(peer.node_id),
342
544
  last_seen: peer.last_seen,
@@ -347,15 +549,20 @@ export class JudgeDecider {
347
549
  target: {
348
550
  type: "choice",
349
551
  instructions: text("judge.target.instructions"),
350
- criteria: Object.fromEntries(peers.value.map(peer => [
351
- peer.node_id,
352
- text("judge.target.peer", {
353
- node: peer.node_id,
354
- base_url: peer.base_url,
355
- instance: peer.instance_id,
356
- last_seen: peer.last_seen,
357
- }),
358
- ])),
552
+ criteria: {
553
+ // The abstain exit: an unnamed recipient must surface as ambiguous_target,
554
+ // never as a low-confidence random pick among peers.
555
+ none: text("judge.target.none"),
556
+ ...Object.fromEntries(peers.map(peer => [
557
+ peer.node_id,
558
+ text("judge.target.peer", {
559
+ node: peer.node_id,
560
+ base_url: peer.base_url,
561
+ instance: peer.instance_id,
562
+ last_seen: peer.last_seen,
563
+ }),
564
+ ])),
565
+ },
359
566
  },
360
567
  },
361
568
  scope.signal,
@@ -365,7 +572,7 @@ export class JudgeDecider {
365
572
  if (chosen === undefined || chosen === "none") {
366
573
  throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
367
574
  }
368
- if (!peers.value.some(peer => peer.node_id === chosen)) {
575
+ if (!peers.some(peer => peer.node_id === chosen)) {
369
576
  throw new OnlyneError("decision_unavailable", text("error.target_not_published", { chosen }));
370
577
  }
371
578
  return chosen;
@@ -503,10 +710,11 @@ export class JudgeDecider {
503
710
  if (typeof value !== "string" || value.length === 0) {
504
711
  throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
505
712
  }
506
- const matches = (await this.deps.listThreads()).filter(thread => thread.thread_ref.id === value || thread.title === value);
713
+ const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === value || thread.title === value);
507
714
  if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
508
- if (matches.length === 1) return matches[0]!.thread_ref;
509
- const slash = value.indexOf("/");
715
+ if (matches.length === 1) return matches[0]!.ref;
716
+ // The same slash grammar as #threadByAlias; a bare id names a thread the addressee hosts.
717
+ const slash = value.lastIndexOf("/");
510
718
  if (slash > 0 && slash < value.length - 1) {
511
719
  return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
512
720
  }
@@ -833,6 +1041,7 @@ export class JudgeDecider {
833
1041
  throw new OnlyneError("decision_unavailable", text("error.state_budget", { chars, cap: STATE_BUDGET_CHARS }));
834
1042
  }
835
1043
  if (dropped.length > 0) this.deps.log?.("onlyne.judge.trimmed", { dropped, chars });
1044
+ if (this.deps.judge !== undefined) return await this.deps.judge(fitted as Row, questions);
836
1045
  const settings = (await Settings.current) ?? (await Settings.loadReadOnly({ cwd: scope.cwd }));
837
1046
  const judge = resolveJudge({
838
1047
  settings,
@@ -1247,17 +1456,31 @@ export class JudgeDecider {
1247
1456
  }
1248
1457
 
1249
1458
  async #threadByAlias(alias: string): Promise<ThreadRef> {
1250
- const threads = await this.deps.listThreads();
1251
- const matches = threads.filter(thread => thread.thread_ref.id === alias || thread.title === alias);
1252
- if (matches.length === 1) return matches[0]!.thread_ref;
1459
+ const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === alias || thread.title === alias);
1460
+ if (matches.length === 1) return matches[0]!.ref;
1253
1461
  if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
1254
- if (alias.includes("/")) {
1255
- const [owner, id] = alias.split("/", 2);
1256
- if (owner !== undefined && id !== undefined && owner.length > 0 && id.length > 0) return { owner, id };
1462
+ // One slash grammar for every textual thread reference: the owner is the part before the
1463
+ // LAST slash, which is safe for urn owners and for ids that never carry a slash.
1464
+ const slash = alias.lastIndexOf("/");
1465
+ if (slash > 0 && slash < alias.length - 1) {
1466
+ return { owner: alias.slice(0, slash), id: alias.slice(slash + 1) };
1257
1467
  }
1258
1468
  throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
1259
1469
  }
1260
1470
 
1471
+ /**
1472
+ * Every thread this node can name by id or title: the ones it hosts, plus the ones it was
1473
+ * invited into. A joined thread lives on its owner, so naming it here is a reference, never
1474
+ * a claim that this node holds the thread record.
1475
+ */
1476
+ async #namedThreads(): Promise<Array<{ ref: ThreadRef; title: string | undefined }>> {
1477
+ const [owned, joined] = await Promise.all([this.deps.listThreads(), this.deps.listJoinedThreads()]);
1478
+ return [
1479
+ ...owned.map(thread => ({ ref: thread.thread_ref, title: thread.title as string | undefined })),
1480
+ ...joined.map(thread => ({ ref: thread.thread_ref, title: thread.title })),
1481
+ ];
1482
+ }
1483
+
1261
1484
  async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
1262
1485
  if (meta === undefined) return undefined;
1263
1486
  const value = meta.to;
@@ -1270,15 +1493,22 @@ export class JudgeDecider {
1270
1493
  }
1271
1494
 
1272
1495
  /**
1273
- * Resolve an explicit target against live discovery. A bare alias is accepted
1274
- * only when exactly one known node's last URN segment names it.
1496
+ * RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
1497
+ * resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
1498
+ */
1499
+ async peerId(alias: string): Promise<NodeId> {
1500
+ return this.#peerId(alias);
1501
+ }
1502
+
1503
+ /**
1504
+ * Resolve an explicit target to a node id. Identity comes from the trust table and the
1505
+ * discovery catalog, never from liveness: a peer whose exchange is done is still that peer
1506
+ * when its discovery record goes stale or is deleted. Whether we can reach it is a delivery
1507
+ * question, reported as `unavailable` by the transport, not a reason to claim no such node
1508
+ * exists. A bare alias is accepted only when exactly one known node's last segment names it.
1275
1509
  */
1276
1510
  async #peerId(alias: string): Promise<NodeId> {
1277
- const peers = await this.#peers();
1278
- if (!peers.available) {
1279
- throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
1280
- }
1281
- const ids = [this.deps.config.node.id, ...peers.value.map(peer => peer.node_id)];
1511
+ const ids = await this.#knownIds();
1282
1512
  if (alias === "self") return this.deps.config.node.id;
1283
1513
  if (ids.includes(alias)) return alias;
1284
1514
  const matches = ids.filter(id => lastSegment(id) === alias);
@@ -1289,6 +1519,23 @@ export class JudgeDecider {
1289
1519
  throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
1290
1520
  }
1291
1521
 
1522
+ /**
1523
+ * Every node this one can name: itself, each peer it holds a live trust row with, and each
1524
+ * peer discovery currently reports. The trust rows come first because they are the ones that
1525
+ * survive a peer moving, restarting or dropping out of the catalog for a refresh window.
1526
+ */
1527
+ async #knownIds(): Promise<NodeId[]> {
1528
+ const ids = new Set<NodeId>([this.deps.config.node.id]);
1529
+ try {
1530
+ for (const peer of await knownPeers(this.deps)) ids.add(peer.node_id);
1531
+ } catch (error) {
1532
+ // Both sources failing leaves only this node; an alias then resolves to `not_found`,
1533
+ // which is the honest answer when nothing can be read at all.
1534
+ this.deps.log?.("onlyne.judge.peers_unreadable", { error: message(error) });
1535
+ }
1536
+ return [...ids];
1537
+ }
1538
+
1292
1539
  async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
1293
1540
  if (meta === undefined) return undefined;
1294
1541
  const value = meta.mentions;
@@ -1320,11 +1567,22 @@ export class JudgeDecider {
1320
1567
  const id = stringField(record, "id");
1321
1568
  if (sender !== undefined && id !== undefined) {
1322
1569
  // The authority is an identity, not the caller's spelling: `caerulea` and
1323
- // `urn:onlyne:agent:caerulea` name the same node, and the delivery target is always
1324
- // resolved. Comparing a raw string against a resolved id produced two contradictory
1325
- // errors in a row (cyanea report 1, problem 1).
1570
+ // `urn:onlyne:agent:caerulea` name the same node, so both the ref and the authority
1571
+ // carry the resolved id and every later comparison is like-for-like. Comparing a raw
1572
+ // string against a resolved one produced two contradictory errors in a row
1573
+ // (cyanea report 1, problem 1).
1326
1574
  const authority = await this.#peerId(sender).catch(() => sender);
1327
- return { ref: { sender, id }, authority };
1575
+ // A reference the node can prove wrong is refused here, before the ref reaches the
1576
+ // wire and dies in the peer's not_found: an id that names a different sender locally
1577
+ // is a definite error, and the error names the message the id really belongs to.
1578
+ const elsewhere = (await this.deps.listInbox()).find(row =>
1579
+ row.attention.entry !== undefined && row.attention.entry.message.id === id
1580
+ && row.attention.entry.message.sender !== authority);
1581
+ if (elsewhere !== undefined) {
1582
+ const actual = elsewhere.attention.entry!.message.sender;
1583
+ throw new OnlyneError("invalid_request", text("error.reply_reference_conflict", { named: sender, actual }));
1584
+ }
1585
+ return { ref: { sender: authority, id }, authority };
1328
1586
  }
1329
1587
  const threadAlias = stringField(record, "thread");
1330
1588
  const seq = seqField(record, "seq");
@@ -1484,14 +1742,6 @@ export class JudgeDecider {
1484
1742
  }
1485
1743
 
1486
1744
 
1487
- async #peers(): Promise<Source<PeerRecord[]>> {
1488
- try {
1489
- return ok(await this.deps.listPeers());
1490
- } catch (error) {
1491
- return none(text("reason.discovery_unreadable", { error: message(error) }));
1492
- }
1493
- }
1494
-
1495
1745
  async #receiverContexts(nodeId: NodeId): Promise<Source<ReceiverContext[]>> {
1496
1746
  try {
1497
1747
  return ok(await this.deps.listReceiverContexts(nodeId));
@@ -1663,7 +1913,8 @@ function pageSize(meta: Record<string, Json>): number | undefined {
1663
1913
 
1664
1914
  function attention(meta: Record<string, Json>): "inbox" | "thread" {
1665
1915
  const value = meta.attention;
1666
- if (value === undefined) return "thread";
1916
+ // RFC 0005 §1: reads default to the attention list; `thread` is the switch that widens one.
1917
+ if (value === undefined) return "inbox";
1667
1918
  if (value === "inbox" || value === "thread") return value;
1668
1919
  throw new OnlyneError("invalid_request", text("error.attention"));
1669
1920
  }