omp-onlyne 0.2.0 → 0.3.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
@@ -10,7 +10,6 @@ import {
10
10
  type Entry,
11
11
  type JoinedThread,
12
12
  type Json,
13
- type MessageRef,
14
13
  type MsgInput,
15
14
  type NodeId,
16
15
  type Page,
@@ -21,26 +20,41 @@ import {
21
20
  } from "@onlyne/lib";
22
21
  import { getLocale } from "@onlyne/node-host/i18n";
23
22
  import { isTrustThread } from "./handshake.ts";
24
- import type { ContextRecord, OnlyneConfig } from "./types.ts";
25
- import { canAnswer, type ContextRegistry } from "./store.ts";
23
+ import type { ContextRecord, OnlyneConfig } from "@onlyne/node-host/types";
24
+ import { canAnswer, type ContextRegistry } from "@onlyne/node-host/store";
26
25
  import { text } from "./decider-text.ts";
26
+ import { ActionGuard, settleIntent, type OutboundIntent } from "./guard.ts";
27
+ import { continueThread, inheritedModes, resolvedModes, resourceAction, resourceFields, sendAction } from "./plan.ts";
28
+ import {
29
+ choice,
30
+ contextQuestions,
31
+ domainOpQuestions,
32
+ insertionQuestions,
33
+ intentQuestions,
34
+ needsAnswerQuestions,
35
+ originalQuestions,
36
+ planQuestions,
37
+ readScopeQuestions,
38
+ relationQuestions,
39
+ targetQuestions,
40
+ threadOpQuestions,
41
+ threadQuestions,
42
+ type Answers,
43
+ type Questions,
44
+ } from "./questions.ts";
45
+ import {
46
+ References,
47
+ knownPeers,
48
+ lastSegment,
49
+ threadKey,
50
+ type PeerRecord,
51
+ type ReplyReference,
52
+ } from "./resolve.ts";
27
53
 
28
54
  type Row = Record<string, unknown>;
29
55
 
30
- interface ChoiceQuestion {
31
- type: "choice";
32
- instructions: string;
33
- criteria: Record<string, string>;
34
- }
35
-
36
- export type Questions = Record<string, ChoiceQuestion>;
37
-
38
- interface ChoiceAnswer {
39
- type?: string;
40
- choice?: string;
41
- }
42
-
43
- export type Answers = Record<string, ChoiceAnswer | undefined>;
56
+ export type { Answers, Questions } from "./questions.ts";
57
+ export type { PeerRecord } from "./resolve.ts";
44
58
 
45
59
  /** A decision input that either carries its records or states why it has none. */
46
60
  type Source<T> = { available: true; value: T } | { available: false; reason: string };
@@ -59,14 +73,6 @@ function valueOf<T>(source: unknown): T | undefined {
59
73
  return row.value as T;
60
74
  }
61
75
 
62
- /** One discovered peer, as the library's discovery records it. */
63
- export interface PeerRecord {
64
- node_id: NodeId;
65
- base_url: string;
66
- instance_id: string;
67
- last_seen: string;
68
- }
69
-
70
76
  /** One execution context a receiving node publishes for a target-qualified delivery. */
71
77
  export interface ReceiverContext {
72
78
  alias: string;
@@ -108,72 +114,7 @@ export function currentDecision(): DecisionScope | undefined {
108
114
  return decisionScope.getStore();
109
115
  }
110
116
 
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
- }
117
+ export { knownPeers, reuseThread } from "./resolve.ts";
177
118
 
178
119
  export interface DeciderDeps {
179
120
  config: OnlyneConfig;
@@ -261,13 +202,20 @@ export function fitState(state: Row): { fitted: Row; dropped: string[]; chars: n
261
202
 
262
203
  /** Decisions go through OMP's `judge` role; the plugin never picks a target or a plan itself. */
263
204
  export class JudgeDecider {
264
- constructor(private readonly deps: DeciderDeps) {}
205
+ /** Deterministic reference resolution: one rule per reference, shared by every entry point. */
206
+ readonly refs: References;
207
+ private readonly guard: ActionGuard;
208
+
209
+ constructor(private readonly deps: DeciderDeps) {
210
+ this.refs = new References(this.deps);
211
+ this.guard = new ActionGuard({ contexts: deps.contexts, nodeId: deps.config.node.id });
212
+ }
265
213
 
266
214
  async decide(scope: DecisionScope): Promise<Action> {
267
215
  const explicit = await this.#explicit(scope);
268
216
  if (explicit !== undefined) {
269
- const filled = await this.#threaded(explicit);
270
- await this.#guard(scope, filled);
217
+ const filled = await continueThread(this.deps, explicit);
218
+ await this.#allow(scope, filled);
271
219
  return filled;
272
220
  }
273
221
 
@@ -276,7 +224,13 @@ export class JudgeDecider {
276
224
  // asked for its own slice was told it had no rights at all (caerulea report 4, problem 1).
277
225
  // Classification costs one local judge call; a write is refused either way.
278
226
  const meta = scope.input.meta ?? {};
279
- const intent = await this.#intent(scope);
227
+ const settlement = settleIntent(await this.#intent(scope), meta);
228
+ if (settlement.override !== undefined) {
229
+ // The judge named an intent whose fields the call does not carry. Naming the fallback
230
+ // in the record is what makes a misread visible instead of a silent rewrite.
231
+ this.deps.log?.("onlyne.judge.overridden", { direction: "outbound", ...settlement.override });
232
+ }
233
+ const intent = settlement.intent;
280
234
  const action = intent === "communicate"
281
235
  ? await this.#communicate(scope, meta)
282
236
  : intent === "inspect"
@@ -285,32 +239,28 @@ export class JudgeDecider {
285
239
  ? await this.#threadAction(scope, meta)
286
240
  : intent === "manage_domain"
287
241
  ? await this.#domainAction(scope, meta)
288
- : await this.#resourceAction(meta);
289
- const filled = await this.#threaded(action);
290
- await this.#guard(scope, filled);
242
+ : await resourceAction(meta);
243
+ const filled = await continueThread(this.deps, action);
244
+ await this.#allow(scope, filled);
291
245
  this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
292
246
  return filled;
293
247
  }
294
248
 
249
+ /** The one guard every decided action passes before the client commits it. */
250
+ async #allow(scope: DecisionScope, action: Action): Promise<void> {
251
+ await this.guard.assert({ readOnly: scope.readOnly === true, agentId: scope.ctx.agent.id }, action);
252
+ }
253
+
295
254
  /**
296
- * The thread a send belongs to, decided once for every path that produces one.
255
+ * Node O-1: one question, only the options this call can execute, no lists of peers,
256
+ * threads, or history.
297
257
  *
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.
258
+ * `operate_resource` is offered only when the call names a complete resource action. The
259
+ * criterion that says a message mentioning a file is not this intent is still prose the
260
+ * model may misread; withholding the option makes the misread unanswerable rather than
261
+ * lethal (issue: outbound-intent-resource-false-positive).
304
262
  */
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 };
310
- }
311
-
312
- /** Node O-1: one question, five options, no lists of peers, threads, or history. */
313
- async #intent(scope: DecisionScope): Promise<"communicate" | "inspect" | "manage_thread" | "manage_domain" | "operate_resource"> {
263
+ async #intent(scope: DecisionScope): Promise<OutboundIntent> {
314
264
  const started = Date.now();
315
265
  const answers = await this.#judge(
316
266
  scope.ctx,
@@ -319,19 +269,7 @@ export class JudgeDecider {
319
269
  trigger: this.#trigger(scope.ctx),
320
270
  host_env: { session_type: scope.ctx.agent.kind, read_only: scope.readOnly === true },
321
271
  },
322
- {
323
- intent: {
324
- type: "choice",
325
- instructions: text("judge.o1.instructions"),
326
- criteria: {
327
- communicate: text("judge.o1.communicate"),
328
- inspect: text("judge.o1.inspect"),
329
- manage_thread: text("judge.o1.manage_thread"),
330
- manage_domain: text("judge.o1.manage_domain"),
331
- operate_resource: text("judge.o1.operate_resource"),
332
- },
333
- },
334
- },
272
+ intentQuestions({ resource: resourceFields(scope.input.meta) !== undefined }),
335
273
  scope.signal,
336
274
  );
337
275
  const intent = choice(answers, "intent");
@@ -344,7 +282,12 @@ export class JudgeDecider {
344
282
 
345
283
  /** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
346
284
  async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
347
- let reply = await this.#reply(meta);
285
+ // Every reference this send names — recipient, thread, mentions, the message it answers —
286
+ // is resolved here, once, before the delivery plan is chosen. A plan built on a
287
+ // half-resolved reference is how the same spelling came to mean two things on two paths
288
+ // (audit §2.7).
289
+ const refs = await this.refs.forSend(meta);
290
+ let reply = refs.reply;
348
291
  const explicitRelation = relation(meta);
349
292
  if (reply === undefined && explicitRelation === undefined) {
350
293
  // Node O-2A: no reference and no relation. The judge still answers one question —
@@ -363,23 +306,21 @@ export class JudgeDecider {
363
306
  if (reply !== undefined) {
364
307
  // O-2A pre-pruning: the reply authority is the only target; meta.to and the judge's
365
308
  // answer must not move a reply to another node (caerulea report 3).
366
- const explicitTarget = await this.#target(meta);
367
- if (explicitTarget !== undefined && explicitTarget !== reply.authority) {
309
+ if (refs.target !== undefined && refs.target !== reply.authority) {
368
310
  throw new OnlyneError(
369
311
  "invalid_request",
370
- text("error.reply_authority", { authority: reply.authority, target: explicitTarget }),
312
+ text("error.reply_authority", { authority: reply.authority, target: refs.target }),
371
313
  );
372
314
  }
373
315
  to = reply.authority;
374
316
  } else {
375
- to = await this.#target(meta) ?? await this.#targetQuestion(scope, meta);
317
+ to = refs.target ?? await this.#targetQuestion(scope, meta);
376
318
  if (to.length === 0) {
377
319
  throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
378
320
  }
379
321
  }
380
322
 
381
323
  const plan = await this.#deliveryPlan(scope, meta, lockedRelation, reply !== undefined);
382
- const thread = await this.#sendThread(meta, to);
383
324
  const context = plan.execution === "new" ? undefined : await this.#receiverContext(scope, to, meta);
384
325
  if (plan.execution === "existing" && context === undefined) {
385
326
  // RFC 0005 §1: `existing` disambiguates or refuses. Quietly opening a new context made
@@ -389,18 +330,16 @@ export class JudgeDecider {
389
330
  text("error.execution_context_missing", { to }),
390
331
  );
391
332
  }
392
- const mentions = await this.#mentions(meta);
393
- return {
394
- kind: "send",
333
+ return sendAction({
395
334
  to,
396
- ...(thread === undefined ? {} : { thread }),
335
+ ...(refs.thread === undefined ? {} : { thread: refs.thread }),
397
336
  ...(context === undefined ? {} : { context }),
398
- ...(reply === undefined ? {} : { in_reply_to: reply.ref }),
337
+ ...(reply === undefined ? {} : { reply }),
399
338
  ...(lockedRelation === undefined ? {} : { relation: lockedRelation }),
400
- ...(mentions === undefined ? {} : { mentions }),
339
+ ...(refs.mentions === undefined ? {} : { mentions: refs.mentions }),
401
340
  response: plan.response,
402
341
  execution: plan.execution,
403
- };
342
+ });
404
343
  }
405
344
 
406
345
  /**
@@ -409,23 +348,25 @@ export class JudgeDecider {
409
348
  * the exact MessageRef and the owner-attested reply authority from the record it names, so
410
349
  * no message id ever travels from the model to the wire.
411
350
  */
412
- async #originalQuestion(scope: DecisionScope): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
351
+ async #originalQuestion(scope: DecisionScope): Promise<ReplyReference | undefined> {
413
352
  const candidates = await this.#replyCandidates(scope);
414
353
  if (candidates.length === 0) return undefined;
415
354
  const started = Date.now();
416
355
  const keyOf = (index: number): string => `answer_${index + 1}`;
417
- const criteria: Record<string, string> = { new_message: text("judge.original.new") };
418
356
  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 };
357
+ return {
358
+ key: keyOf(index),
359
+ from: entry.message.sender,
360
+ kind: entry.message.delivery_kind,
361
+ thread: threadKey(entry.thread_ref),
362
+ seq: entry.seq,
363
+ body: boundText(entry.message.msg),
364
+ };
424
365
  });
425
366
  const answers = await this.#judge(
426
367
  scope.ctx,
427
368
  { focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
428
- { original: { type: "choice", instructions: text("judge.original.instructions"), criteria } },
369
+ originalQuestions(facts),
429
370
  scope.signal,
430
371
  );
431
372
  this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
@@ -505,16 +446,7 @@ export class JudgeDecider {
505
446
  const answers = await this.#judge(
506
447
  scope.ctx,
507
448
  { focus: this.#focus(scope), in_reply_to: meta.in_reply_to ?? null },
508
- {
509
- relation: {
510
- type: "choice",
511
- instructions: text("judge.relation.instructions"),
512
- criteria: {
513
- reply: text("judge.relation.reply"),
514
- followup: text("judge.relation.followup"),
515
- },
516
- },
517
- },
449
+ relationQuestions(),
518
450
  scope.signal,
519
451
  );
520
452
  this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
@@ -545,26 +477,7 @@ export class JudgeDecider {
545
477
  })),
546
478
  recent_inbound_unanswered: await this.#unanswered(scope.ctx),
547
479
  },
548
- {
549
- target: {
550
- type: "choice",
551
- instructions: text("judge.target.instructions"),
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
- },
566
- },
567
- },
480
+ targetQuestions(peers),
568
481
  scope.signal,
569
482
  );
570
483
  this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
@@ -585,63 +498,25 @@ export class JudgeDecider {
585
498
  relation: "reply" | "followup" | undefined,
586
499
  isReply: boolean,
587
500
  ): Promise<{ response: "await" | "async" | "none"; execution: "existing" | "new" }> {
588
- const response = this.#inherited(meta, "response");
589
- const execution = this.#inherited(meta, "execution");
590
- if (response !== undefined && execution !== undefined) {
591
- return { response: response as "await" | "async" | "none", execution: execution as "existing" | "new" };
501
+ const inherited = inheritedModes(meta);
502
+ // A plan the caller stated completely is not a question: asking it would offer to
503
+ // overrule the one party that knows what the call wants.
504
+ if (inherited.response !== undefined && inherited.execution !== undefined) {
505
+ return resolvedModes(inherited, {});
592
506
  }
593
507
  const started = Date.now();
594
508
  const answers = await this.#judge(
595
509
  scope.ctx,
596
510
  { focus: this.#focus(scope), relation: relation ?? "new" },
597
- {
598
- ...(response === undefined
599
- ? {
600
- response: {
601
- type: "choice",
602
- instructions: text("judge.response.instructions"),
603
- criteria: {
604
- none: text("judge.response.none"),
605
- async: text("judge.response.async"),
606
- await: text("judge.response.await"),
607
- },
608
- },
609
- }
610
- : {}),
611
- ...(execution === undefined
612
- ? {
613
- execution: {
614
- type: "choice",
615
- instructions: text("judge.execution.instructions"),
616
- criteria: {
617
- existing: isReply
618
- ? text("judge.execution.existing.reply")
619
- : text("judge.execution.existing.new"),
620
- new: text("judge.execution.new"),
621
- },
622
- },
623
- }
624
- : {}),
625
- },
511
+ planQuestions({
512
+ response: inherited.response === undefined,
513
+ execution: inherited.execution === undefined,
514
+ reply: isReply,
515
+ }),
626
516
  scope.signal,
627
517
  );
628
518
  this.#record("outbound.o3a", scope.input, answers, Date.now() - started);
629
- const chosenResponse = response ?? choice(answers, "response");
630
- const chosenExecution = execution ?? choice(answers, "execution");
631
- if (chosenResponse !== "none" && chosenResponse !== "async" && chosenResponse !== "await") {
632
- throw new OnlyneError("decision_unavailable", text("error.response_mode"));
633
- }
634
- if (chosenExecution !== "existing" && chosenExecution !== "new") {
635
- throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
636
- }
637
- return { response: chosenResponse, execution: chosenExecution };
638
- }
639
-
640
- #inherited(meta: Record<string, Json>, key: string): "await" | "async" | "none" | "existing" | "new" | undefined {
641
- const value = meta[key];
642
- if (value === "await" || value === "async" || value === "none" || value === "existing" || value === "new") return value;
643
- if (value !== undefined) throw new OnlyneError("invalid_request", text("error.meta_mode", { key }));
644
- return undefined;
519
+ return resolvedModes(inherited, answers);
645
520
  }
646
521
 
647
522
  /**
@@ -672,19 +547,7 @@ export class JudgeDecider {
672
547
  const answers = await this.#judge(
673
548
  scope.ctx,
674
549
  { focus: this.#focus(scope), target_published_contexts: published },
675
- {
676
- context: {
677
- type: "choice",
678
- instructions: text("judge.context.instructions"),
679
- criteria: {
680
- ...Object.fromEntries(published.map(context => [
681
- context.alias,
682
- text("judge.context.published", { summary: context.summary, state: context.state }),
683
- ])),
684
- none: text("judge.context.none"),
685
- },
686
- },
687
- },
550
+ contextQuestions(published),
688
551
  scope.signal,
689
552
  );
690
553
  this.#record("outbound.o3a-ctx", scope.input, answers, Date.now() - started);
@@ -695,32 +558,6 @@ export class JudgeDecider {
695
558
  return alias;
696
559
  }
697
560
 
698
- /**
699
- * The thread a send names. The thread decides where the message is stored; `to` decides who
700
- * is addressed. An initiator owns its thread for the whole conversation, so every member
701
- * writes into it and the client routes each message to that owner. A bare id that names no
702
- * local thread is the target's thread id, as a receipt reports it.
703
- */
704
- async #sendThread(meta: Record<string, Json> | undefined, to: NodeId): Promise<ThreadRef | undefined> {
705
- if (meta?.thread === undefined) return undefined;
706
- const value = meta.thread;
707
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
708
- return this.#thread(meta);
709
- }
710
- if (typeof value !== "string" || value.length === 0) {
711
- throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
712
- }
713
- const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === value || thread.title === value);
714
- if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
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("/");
718
- if (slash > 0 && slash < value.length - 1) {
719
- return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
720
- }
721
- return { owner: to, id: value };
722
- }
723
-
724
561
  /**
725
562
  * One inbound entry's route. `plan` is the sender's delivery plan when the delivery named
726
563
  * one: an explicit `plan.context` is binding, `execution: new` is only a preference.
@@ -848,16 +685,7 @@ export class JudgeDecider {
848
685
  current_work: this.#trigger(scope),
849
686
  thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
850
687
  },
851
- {
852
- needs_answer: {
853
- type: "choice",
854
- instructions: text("judge.needs_answer.instructions"),
855
- criteria: {
856
- yes: text("judge.needs_answer.yes"),
857
- no: text("judge.needs_answer.no"),
858
- },
859
- },
860
- },
688
+ needsAnswerQuestions(),
861
689
  undefined,
862
690
  );
863
691
  this.#record("inbound.i0", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
@@ -898,11 +726,6 @@ export class JudgeDecider {
898
726
  const started = Date.now();
899
727
  // `fragment` never starts a turn, so a message that needs an answer can never be one;
900
728
  // offering it would be an option whose choice guarantees the obligation is missed.
901
- const criteria: Record<string, string> = {
902
- steer: text("judge.insertion.steer"),
903
- follow_up: text("judge.insertion.follow_up"),
904
- ...(needsAnswer ? {} : { fragment: text("judge.insertion.fragment") }),
905
- };
906
729
  const answers = await this.#judge(
907
730
  scope,
908
731
  {
@@ -910,13 +733,7 @@ export class JudgeDecider {
910
733
  host_running_state: { is_turn_active: !scope.isIdle(), plan_mode: this.#planActive(scope) },
911
734
  urgency_signals: { has_stop_or_change: URGENCY_CUES.some(cue => body.includes(cue)) },
912
735
  },
913
- {
914
- insertion_mode: {
915
- type: "choice",
916
- instructions: text("judge.insertion.instructions"),
917
- criteria,
918
- },
919
- },
736
+ insertionQuestions({ fragment: !needsAnswer }),
920
737
  );
921
738
  this.#record("inbound.i2", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
922
739
  const mode = choice(answers, "insertion_mode");
@@ -1071,8 +888,9 @@ export class JudgeDecider {
1071
888
  }
1072
889
 
1073
890
  /**
1074
- * Only stable, explicit fields route without a decision. Everything semantic,
1075
- * including a bare thread reference, goes to the judge.
891
+ * Only stable, explicit fields route without a decision. A reference is resolved, never
892
+ * judged: `meta.thread`, `meta.to` and `meta.resource` name a record, and whether the
893
+ * message means to use them at all is what the judge answers.
1076
894
  */
1077
895
  async #explicit(scope: DecisionScope): Promise<Action | undefined> {
1078
896
  const meta = scope.input.meta;
@@ -1080,8 +898,8 @@ export class JudgeDecider {
1080
898
  if (meta.scope === "resources") return this.#read(scope, "resources");
1081
899
  const op = stringField(meta, "op");
1082
900
  if (op !== undefined && isThreadOp(op)) {
1083
- const thread = await this.#thread(meta);
1084
- const to = await this.#target(meta);
901
+ const thread = await this.refs.thread(meta);
902
+ const to = await this.refs.target(meta);
1085
903
  const title = stringField(meta, "title");
1086
904
  if (op === "create" && title === undefined) throw new OnlyneError("invalid_request", text("error.thread_title_required"));
1087
905
  if (op !== "create" && op !== "list" && thread === undefined) {
@@ -1099,22 +917,7 @@ export class JudgeDecider {
1099
917
  };
1100
918
  }
1101
919
 
1102
- const resource = recordField(meta, "resource");
1103
- if (resource !== undefined) {
1104
- const owner = stringField(resource, "owner");
1105
- const id = stringField(resource, "id");
1106
- const operation = stringField(meta, "operation");
1107
- if (owner === undefined || id === undefined || operation === undefined) {
1108
- throw new OnlyneError("invalid_request", text("error.resource_fields"));
1109
- }
1110
- return {
1111
- kind: "resource",
1112
- resource: { owner, id },
1113
- operation,
1114
- ...(meta.data === undefined ? {} : { data: meta.data }),
1115
- ...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
1116
- };
1117
- }
920
+ if (recordField(meta, "resource") !== undefined) return resourceAction(meta);
1118
921
 
1119
922
  const scopeName = stringField(meta, "scope");
1120
923
  if (scopeName !== undefined) {
@@ -1134,7 +937,7 @@ export class JudgeDecider {
1134
937
  if (this.deps.resourceQuery === undefined) throw new OnlyneError("unavailable", text("error.resource_host_unavailable"));
1135
938
  return { kind: "read", scope: name, query: await this.deps.resourceQuery(meta) };
1136
939
  }
1137
- const thread = await this.#thread(meta);
940
+ const thread = await this.refs.thread(meta);
1138
941
  const page_size = pageSize(meta);
1139
942
  const cursor = stringField(meta, "cursor");
1140
943
  const seq = stringField(meta, "seq");
@@ -1164,7 +967,7 @@ export class JudgeDecider {
1164
967
  attention: attention(meta),
1165
968
  };
1166
969
  }
1167
- const resolved = await this.#messageRef(meta, thread);
970
+ const resolved = await this.refs.message(meta, thread);
1168
971
  if (resolved === undefined) {
1169
972
  throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
1170
973
  }
@@ -1185,20 +988,7 @@ export class JudgeDecider {
1185
988
  const answers = await this.#judge(
1186
989
  scope.ctx,
1187
990
  { focus: { delivery_kind: scope.input.delivery_kind, msg: scope.input.msg, meta: meta ?? null } },
1188
- {
1189
- read_scope: {
1190
- type: "choice",
1191
- instructions: text("judge.read_scope.instructions"),
1192
- criteria: {
1193
- peers: text("judge.read_scope.peers"),
1194
- inbox: text("judge.read_scope.inbox"),
1195
- messages: text("judge.read_scope.messages"),
1196
- evidence: text("judge.read_scope.evidence"),
1197
- resources: text("judge.read_scope.resources"),
1198
- none: text("judge.read_scope.none"),
1199
- },
1200
- },
1201
- },
991
+ readScopeQuestions(),
1202
992
  scope.signal,
1203
993
  );
1204
994
  this.#record("outbound.o2b", scope.input, answers, Date.now() - started);
@@ -1214,7 +1004,7 @@ export class JudgeDecider {
1214
1004
  throw new OnlyneError("decision_unavailable", text("error.read_scope_unknown", { scope: scopeName }));
1215
1005
  }
1216
1006
  const name: ActionReadScope = scopeName === "peers" ? "neighbors" : scopeName;
1217
- const thread = await this.#thread(meta);
1007
+ const thread = await this.refs.thread(meta);
1218
1008
  if (name === "messages") {
1219
1009
  const chosen = thread ?? await this.#threadQuestion(scope, "outbound.o3b-th");
1220
1010
  return {
@@ -1228,7 +1018,7 @@ export class JudgeDecider {
1228
1018
  };
1229
1019
  }
1230
1020
  if (name === "evidence") {
1231
- const resolved = await this.#messageRef(meta, thread);
1021
+ const resolved = await this.refs.message(meta, thread);
1232
1022
  if (resolved === undefined) {
1233
1023
  throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
1234
1024
  }
@@ -1263,21 +1053,7 @@ export class JudgeDecider {
1263
1053
  next_seq: thread.next_seq,
1264
1054
  })),
1265
1055
  },
1266
- {
1267
- thread: {
1268
- type: "choice",
1269
- instructions: text("judge.thread.instructions"),
1270
- criteria: Object.fromEntries(threads.map(thread => [
1271
- thread.thread_ref.id,
1272
- text("judge.thread.item", {
1273
- title: thread.title,
1274
- owner: thread.thread_ref.owner,
1275
- id: thread.thread_ref.id,
1276
- state: thread.state,
1277
- }),
1278
- ])),
1279
- },
1280
- },
1056
+ threadQuestions(threads.map(thread => ({ ref: thread.thread_ref, title: thread.title, state: thread.state }))),
1281
1057
  scope.signal,
1282
1058
  );
1283
1059
  this.#record(node, scope.input, answers, Date.now() - started);
@@ -1285,7 +1061,7 @@ export class JudgeDecider {
1285
1061
  if (alias === undefined || alias === "none") {
1286
1062
  throw new OnlyneError("decision_unavailable", text("error.local_thread_choice"));
1287
1063
  }
1288
- return this.#threadByAlias(alias);
1064
+ return this.refs.threadByAlias(alias);
1289
1065
  }
1290
1066
 
1291
1067
  /** Node O-2C: the local thread operation. */
@@ -1294,20 +1070,7 @@ export class JudgeDecider {
1294
1070
  const answers = await this.#judge(
1295
1071
  scope.ctx,
1296
1072
  { focus: this.#focus(scope) },
1297
- {
1298
- thread_op: {
1299
- type: "choice",
1300
- instructions: text("judge.thread_op.instructions"),
1301
- criteria: {
1302
- create: text("judge.thread_op.create"),
1303
- close: text("judge.thread_op.close"),
1304
- list: text("judge.thread_op.list"),
1305
- describe: text("judge.thread_op.describe"),
1306
- member_add: text("judge.thread_op.member_add"),
1307
- member_remove: text("judge.thread_op.member_remove"),
1308
- },
1309
- },
1310
- },
1073
+ threadOpQuestions(),
1311
1074
  scope.signal,
1312
1075
  );
1313
1076
  this.#record("outbound.o2c", scope.input, answers, Date.now() - started);
@@ -1318,9 +1081,9 @@ export class JudgeDecider {
1318
1081
  // A dependent second call: which thread the operation names is only decidable once the
1319
1082
  // operation is known, and meta.thread settles it without a call (design §4, stepwise).
1320
1083
  const thread = op === "create" || op === "list"
1321
- ? await this.#thread(meta)
1322
- : await this.#thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
1323
- const to = await this.#target(meta);
1084
+ ? await this.refs.thread(meta)
1085
+ : await this.refs.thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
1086
+ const to = await this.refs.target(meta);
1324
1087
  const title = stringField(meta, "title");
1325
1088
  if ((op === "member_add" || op === "member_remove") && to === undefined) {
1326
1089
  throw new OnlyneError("decision_unavailable", text("error.thread_op_target", { op }));
@@ -1343,20 +1106,7 @@ export class JudgeDecider {
1343
1106
  const answers = await this.#judge(
1344
1107
  scope.ctx,
1345
1108
  { focus: this.#focus(scope) },
1346
- {
1347
- domain_op: {
1348
- type: "choice",
1349
- instructions: text("judge.domain_op.instructions"),
1350
- criteria: {
1351
- create: text("judge.domain_op.create"),
1352
- join: text("judge.domain_op.join"),
1353
- leave: text("judge.domain_op.leave"),
1354
- list: text("judge.domain_op.list"),
1355
- describe: text("judge.domain_op.describe"),
1356
- discover: text("judge.domain_op.discover"),
1357
- },
1358
- },
1359
- },
1109
+ domainOpQuestions(),
1360
1110
  scope.signal,
1361
1111
  );
1362
1112
  this.#record("outbound.o2d", scope.input, answers, Date.now() - started);
@@ -1376,24 +1126,6 @@ export class JudgeDecider {
1376
1126
  };
1377
1127
  }
1378
1128
 
1379
- /** The resource branch carries no judge node: meta names every field. */
1380
- async #resourceAction(meta: Record<string, Json>): Promise<Action> {
1381
- const resource = recordField(meta, "resource");
1382
- const owner = resource === undefined ? undefined : stringField(resource, "owner");
1383
- const id = resource === undefined ? undefined : stringField(resource, "id");
1384
- const operation = stringField(meta, "operation");
1385
- if (owner === undefined || id === undefined || operation === undefined) {
1386
- throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
1387
- }
1388
- return {
1389
- kind: "resource",
1390
- resource: { owner, id },
1391
- operation,
1392
- ...(meta.data === undefined ? {} : { data: meta.data }),
1393
- ...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
1394
- };
1395
- }
1396
-
1397
1129
  /** The golden-triage head of every node state: what is being asked, verbatim. */
1398
1130
  #focus(scope: DecisionScope): Row {
1399
1131
  return {
@@ -1443,273 +1175,12 @@ export class JudgeDecider {
1443
1175
  }
1444
1176
 
1445
1177
 
1446
- async #thread(meta: Record<string, Json> | undefined): Promise<ThreadRef | undefined> {
1447
- if (meta === undefined) return undefined;
1448
- const value = meta.thread;
1449
- if (value === undefined) return undefined;
1450
- if (typeof value === "string") return this.#threadByAlias(value);
1451
- const record = recordField(meta, "thread");
1452
- const owner = record === undefined ? undefined : stringField(record, "owner");
1453
- const id = record === undefined ? undefined : stringField(record, "id");
1454
- if (owner !== undefined && id !== undefined) return { owner, id };
1455
- throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
1456
- }
1457
-
1458
- async #threadByAlias(alias: string): Promise<ThreadRef> {
1459
- const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === alias || thread.title === alias);
1460
- if (matches.length === 1) return matches[0]!.ref;
1461
- if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
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) };
1467
- }
1468
- throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
1469
- }
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
-
1484
- async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
1485
- if (meta === undefined) return undefined;
1486
- const value = meta.to;
1487
- if (value === undefined) return undefined;
1488
- if (typeof value !== "string") {
1489
- throw new OnlyneError("invalid_request", text("error.meta_to_single"));
1490
- }
1491
- if (value.length === 0) throw new OnlyneError("invalid_request", text("error.meta_to_empty"));
1492
- return this.#peerId(value);
1493
- }
1494
-
1495
1178
  /**
1496
1179
  * RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
1497
1180
  * resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
1498
1181
  */
1499
1182
  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.
1509
- */
1510
- async #peerId(alias: string): Promise<NodeId> {
1511
- const ids = await this.#knownIds();
1512
- if (alias === "self") return this.deps.config.node.id;
1513
- if (ids.includes(alias)) return alias;
1514
- const matches = ids.filter(id => lastSegment(id) === alias);
1515
- if (matches.length === 1) return matches[0]!;
1516
- if (matches.length > 1) {
1517
- throw new OnlyneError("ambiguous_target", text("error.alias_ambiguous", { alias, matches: matches.join(", ") }));
1518
- }
1519
- throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
1520
- }
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
-
1539
- async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
1540
- if (meta === undefined) return undefined;
1541
- const value = meta.mentions;
1542
- if (value === undefined) return undefined;
1543
- if (!Array.isArray(value)) throw new OnlyneError("invalid_request", text("error.mentions_array"));
1544
- const resolved: string[] = [];
1545
- for (const item of value) {
1546
- if (typeof item !== "string" || item.length === 0) {
1547
- throw new OnlyneError("invalid_request", text("error.mention_empty"));
1548
- }
1549
- if (item === "@all") {
1550
- resolved.push(item);
1551
- continue;
1552
- }
1553
- resolved.push(await this.#peerId(item.startsWith("@") ? item.slice(1) : item));
1554
- }
1555
- return resolved;
1556
- }
1557
-
1558
- async #reply(meta: Record<string, Json> | undefined): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
1559
- if (meta === undefined) return undefined;
1560
- const value = meta.in_reply_to;
1561
- if (value === undefined) return undefined;
1562
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1563
- throw new OnlyneError("invalid_request", text("error.in_reply_to_object"));
1564
- }
1565
- const record = value as Record<string, Json>;
1566
- const sender = stringField(record, "sender");
1567
- const id = stringField(record, "id");
1568
- if (sender !== undefined && id !== undefined) {
1569
- // The authority is an identity, not the caller's spelling: `caerulea` and
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).
1574
- const authority = await this.#peerId(sender).catch(() => sender);
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 };
1586
- }
1587
- const threadAlias = stringField(record, "thread");
1588
- const seq = seqField(record, "seq");
1589
- if (threadAlias === undefined) {
1590
- throw new OnlyneError("invalid_request", text("error.in_reply_to_shape"));
1591
- }
1592
- if (seq === undefined) {
1593
- throw new OnlyneError("invalid_request", text("error.in_reply_to_seq"));
1594
- }
1595
- const thread = await this.#threadByAlias(threadAlias);
1596
- const entry = await this.#entryAt(thread, seq);
1597
- if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: threadAlias, seq }));
1598
- return {
1599
- ref: { sender: entry.message.sender, id: entry.message.id },
1600
- authority: entry.reply_authority,
1601
- thread_ref: entry.thread_ref,
1602
- };
1603
- }
1604
-
1605
- /**
1606
- * The message a read names, with the thread it was resolved in. `{sender,id}` carries no
1607
- * thread here; `{thread,seq}` does, and an explicit meta.thread that contradicts the
1608
- * reference is refused rather than silently winning.
1609
- */
1610
- async #messageRef(meta: Record<string, Json>, thread?: ThreadRef): Promise<{ ref: MessageRef; thread?: ThreadRef } | undefined> {
1611
- const value = meta.message;
1612
- const record = value !== null && typeof value === "object" && !Array.isArray(value)
1613
- ? (value as Record<string, Json>)
1614
- : undefined;
1615
- const sender = record === undefined ? undefined : stringField(record, "sender");
1616
- const id = record === undefined ? undefined : stringField(record, "id");
1617
- if (sender !== undefined && id !== undefined) {
1618
- return { ref: { sender, id }, ...(thread === undefined ? {} : { thread }) };
1619
- }
1620
- // meta.message takes the same two shapes as meta.in_reply_to, including its own thread
1621
- // reference: `{thread, seq}` is the form a reader copies out of another reference, and
1622
- // refusing it while accepting `{sender, id}` made the same fact two spellings (caerulea 4).
1623
- const alias = record === undefined ? undefined : stringField(record, "thread");
1624
- const embedded = alias === undefined ? undefined : await this.#threadByAlias(alias);
1625
- if (thread !== undefined && embedded !== undefined && threadKey(thread) !== threadKey(embedded)) {
1626
- throw new OnlyneError(
1627
- "invalid_request",
1628
- text("error.message_thread_conflict", { thread: threadKey(thread), embedded: threadKey(embedded) }),
1629
- );
1630
- }
1631
- const scope = thread ?? embedded;
1632
- if (scope === undefined) return undefined;
1633
- const seq = (record === undefined ? undefined : seqField(record, "seq")) ?? seqField(meta, "seq");
1634
- if (seq === undefined) return undefined;
1635
- const entry = await this.#entryAt(scope, seq);
1636
- if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: scope.id, seq }));
1637
- return { ref: { sender: entry.message.sender, id: entry.message.id }, thread: scope };
1638
- }
1639
-
1640
- async #entryAt(thread: ThreadRef, seq: string): Promise<Entry | undefined> {
1641
- let cursor: string | undefined;
1642
- for (let page = 0; page < MAX_TAIL_PAGES; page++) {
1643
- const result: Page<Entry> = await this.deps.listMessages(thread, cursor);
1644
- const found = result.items.find(entry => entry.seq === seq);
1645
- if (found !== undefined) return found;
1646
- cursor = result.next_cursor ?? undefined;
1647
- if (cursor === undefined) return undefined;
1648
- }
1649
- return undefined;
1650
- }
1651
-
1652
- /**
1653
- * A subagent may read only the threads its own context records authorize on
1654
- * this node. The unfiltered node-wide views stay with the main session.
1655
- */
1656
- async #guard(scope: DecisionScope, action: Action): Promise<void> {
1657
- if (scope.readOnly !== true) return;
1658
- if (action.kind !== "read") {
1659
- throw new OnlyneError(
1660
- "forbidden",
1661
- text("error.guard_write"),
1662
- );
1663
- }
1664
- if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
1665
- if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
1666
- throw new OnlyneError(
1667
- "forbidden",
1668
- text("error.guard_node_listing"),
1669
- );
1670
- }
1671
- const records = await this.#authorizedRecords(scope.ctx.agent.id);
1672
- if (action.scope === "evidence") {
1673
- const ref = action.message;
1674
- const tied = ref !== undefined && records.some(record =>
1675
- record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
1676
- );
1677
- if (!tied) {
1678
- throw new OnlyneError("forbidden", text("error.guard_evidence"));
1679
- }
1680
- return;
1681
- }
1682
- // `{sender,id}` names no thread, so the record that received that message is the
1683
- // authorization — the same tie evidence uses.
1684
- if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
1685
- const ref = action.message;
1686
- const tied = records.some(record =>
1687
- record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
1688
- );
1689
- if (!tied) {
1690
- throw new OnlyneError("forbidden", text("error.guard_message"));
1691
- }
1692
- return;
1693
- }
1694
- if (action.thread === undefined) {
1695
- throw new OnlyneError("forbidden", text("error.guard_thread_required"));
1696
- }
1697
- const key = threadKey(action.thread);
1698
- if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
1699
- throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
1700
- }
1701
- }
1702
-
1703
- async #authorizedRecords(agentId: string): Promise<ContextRecord[]> {
1704
- let records: ContextRecord[];
1705
- try {
1706
- records = await this.deps.contexts.list();
1707
- } catch (error) {
1708
- throw new OnlyneError("unavailable", text("error.registry_unreadable", { error: message(error) }));
1709
- }
1710
- return records.filter(record =>
1711
- record.node_id === this.deps.config.node.id && record.agent_id === agentId && record.thread_ref !== undefined,
1712
- );
1183
+ return this.refs.peerId(alias);
1713
1184
  }
1714
1185
 
1715
1186
  async #contextRows(): Promise<Source<Row[]>> {
@@ -1874,12 +1345,6 @@ function actionFacts(action: Action): Record<string, string> {
1874
1345
  return facts;
1875
1346
  }
1876
1347
 
1877
- function choice(answers: Answers, key: string): string | undefined {
1878
- const answer = answers[key];
1879
- if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
1880
- return typeof answer.choice === "string" ? answer.choice : undefined;
1881
- }
1882
-
1883
1348
  function stringField(record: Record<string, Json>, key: string): string | undefined {
1884
1349
  const value = record[key];
1885
1350
  return typeof value === "string" && value.length > 0 ? value : undefined;
@@ -1935,10 +1400,6 @@ function branchOf(ctx: ExtensionContext): readonly SessionEntry[] {
1935
1400
  return ctx.sessionManager.getBranch();
1936
1401
  }
1937
1402
 
1938
- function threadKey(ref: ThreadRef): string {
1939
- return `${ref.owner}/${ref.id}`;
1940
- }
1941
-
1942
1403
  /**
1943
1404
  * A wording hint about whether the sender expects an answer: metadata says reply, or the body
1944
1405
  * greets or asks. It is evidence for node I-0, never the decision — a completed reply also
@@ -1951,13 +1412,6 @@ export function needsReplyOf(entry: Entry): boolean {
1951
1412
  return REPLY_CUES.some(cue => body.includes(cue.toLowerCase())) || /\?|?|\bplease\b|期待/.test(entry.message.msg);
1952
1413
  }
1953
1414
 
1954
- /** A node id's last URN segment; plain ids are their own segment. */
1955
- function lastSegment(nodeId: NodeId): string {
1956
- const trimmed = nodeId.replace(/\/+$/, "");
1957
- const parts = trimmed.split(/[:/]/).filter(part => part.length > 0);
1958
- return parts.at(-1) ?? trimmed;
1959
- }
1960
-
1961
1415
  function entryFacts(entry: Entry): Row {
1962
1416
  return {
1963
1417
  thread_ref: entry.thread_ref,