aamio 0.6.2 → 0.7.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.
@@ -2,9 +2,12 @@ import { open, readFile, rename, unlink } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { join } from "node:path";
5
- import { Aamio, AamioError, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
5
+ import { Aamio, AamioError, BOARD_POST_MARGIN, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, newScopeKey, scopeAddress, sha256hex, threadSigningInput } from "./aamio.js";
6
+ import { DEFAULT_POLICY, appendRecord, archiveName, archiveNames, archiveStatus, archivedRecords, isSafeLabel, pruneArchive, readPolicy } from "./node-archive.js";
6
7
  import { readKeys } from "./node-identity.js";
7
8
  import { takeHome } from "./node-lock.js";
9
+ import { SCOPE_NOT_SAVED, checkAccess, checkScopeName, longerForm, removedShares, scopeAddPlan, scopeCalled, scopeRemovePlan, scopeView, sharedScopeName, takenFrom, usableScopes, withoutScopeKey } from "./node-scopes.js";
10
+ import { TRACE_KEEP, TRACE_KEYS, TRACE_READ_FIELDS, TRACE_RECEIVED_FIELDS, TRACE_SENT_FIELDS, isMessageHash, normalizeTraces, traceRow, traceView } from "./node-trace.js";
8
11
 
9
12
  /*
10
13
  * The local runtime for Node: a home held for as long as it runs, an inbox the
@@ -12,10 +15,11 @@ import { takeHome } from "./node-lock.js";
12
15
  * outbox before they go, and reads checked here before anything is handed on.
13
16
  *
14
17
  * A home is shared with aamio-python and aamio-php, so the files are theirs:
15
- * partners.json, state.json and outbox.json in the same shape, written the same
16
- * way. A home one of them made opens here, and one this makes opens there. What
17
- * Python keeps that this does not touch yet, scopes, traces, effects and the
18
- * archive, is left as it is.
18
+ * partners.json, scopes.json, state.json, outbox.json and trace.json in the
19
+ * same shape, written the same way, and archive/ with what was read, sent
20
+ * and posted, under the policy in config.json. A home one of them made opens
21
+ * here, and one this makes opens there. What Python keeps that this does not
22
+ * touch yet, effects, is left as it is.
19
23
  */
20
24
 
21
25
  export const INBOX_TTL = 3600;
@@ -30,7 +34,7 @@ export const ANSWER_MARGIN = 600;
30
34
  // Files this runtime writes back. One that is there and cannot be read stops
31
35
  // the runtime before anything is saved over it: a save is how a broken file
32
36
  // becomes a lost one.
33
- const KEPT_FILES = ["partners.json", "state.json", "outbox.json"];
37
+ const KEPT_FILES = ["partners.json", "scopes.json", "state.json", "outbox.json"];
34
38
  // The largest X-Max-Bytes the service takes: one to eight digits.
35
39
  const MAX_BYTES_HEADER = 99_999_999;
36
40
  // How long after a thread expires what this process read of it is kept for its
@@ -39,11 +43,231 @@ const MAX_BYTES_HEADER = 99_999_999;
39
43
  const RECEIPT_KEEP = 120;
40
44
  // Answers that prove a message was not stored: refusals the service will give
41
45
  // again, and a rate window, which it decides before it reads the body.
42
- const SEND_DETERMINISTIC = [400, 403, 410, 413, 415, 422, 428, 501];
46
+ // 401 and 409 are among them: a signature that does not verify over these
47
+ // bytes, a thread that is full, a key that has used up the gate's per_key. The
48
+ // service answers each before it stores anything. Left out, a handover
49
+ // answered so said the address may have arrived, and retry sent the same
50
+ // bytes to the same refusal (a check of 12 November 2026).
51
+ const SEND_DETERMINISTIC = [400, 401, 403, 409, 410, 413, 415, 422, 428, 501];
43
52
  const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
53
+ // Nothing about the message is wrong, only the moment: a rate window, or a
54
+ // service that is busy. 503 is not among the answers that prove nothing was
55
+ // stored: it can be a front door that saw nothing or the service failing after
56
+ // it had written, and this side cannot tell which.
57
+ const SEND_TRY_LATER = [429, 503];
44
58
  const HASH = /^[0-9a-f]{64}$/;
59
+ // One message a channel handed over, as the channel remembers it: the hash of
60
+ // its bytes and who signed them, the first 16 hex of sha256 of the key, or -
61
+ // when nobody did. The hash alone is of the text, and two signers who both sent
62
+ // "OK" made the second a replay of the first (a review of 10 November 2026).
63
+ // aamio-python and aamio-php write the same records, so a home reads the same.
64
+ // ? stands beside a hash an earlier version kept without its signer, once that
65
+ // hash is met again: who sent it then is not known, and stays not known.
66
+ const SEEN_FROM = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
67
+
68
+ /** The record of a message handed over: its hash, and the signer it was verified from, or -. */
69
+ export function seenRecord(digest, fromKey) {
70
+ return `${digest}:${typeof fromKey === "string" && fromKey ? sha256hex(fromKey).slice(0, 16) : "-"}`;
71
+ }
72
+
73
+ /**
74
+ * Whether this channel handed these bytes over before from this signer, or
75
+ * unsigned when they are unsigned: true when it did, and false when it did not,
76
+ * since bytes it handed over from another signer make this another message.
77
+ * null when it cannot be told: an earlier version kept the hash without its
78
+ * signer, and this signer has no record of its own. Taken as a copy, another
79
+ * signer's first answer with the same text was passed over, and taken as new,
80
+ * the same signer's copy was acted on twice (a review of 11 November 2026), so
81
+ * it is said as it is.
82
+ */
83
+ export function replayState(channel, digest, fromKey) {
84
+ if (digest === null || digest === undefined) return false;
85
+ const records = Array.isArray(channel.seen_from) ? channel.seen_from : [];
86
+ if (records.includes(seenRecord(digest, fromKey))) return true;
87
+ const mine = records.filter((record) => record.startsWith(digest + ":"));
88
+ if (mine.includes(digest + ":?") || (mine.length === 0 && channel.seen.includes(digest))) return null;
89
+ return false;
90
+ }
91
+
92
+ /** Whether this is known to be a copy: replayState, with not known taken as no. */
93
+ export function isReplay(channel, digest, fromKey) {
94
+ return replayState(channel, digest, fromKey) === true;
95
+ }
96
+
97
+ /**
98
+ * A message handed over: its hash, and who it came from. A hash an earlier
99
+ * version kept without its signer keeps that, as ?, beside the first signer
100
+ * recorded for it, so the next signer with the same bytes is not taken for a
101
+ * new one with certainty.
102
+ */
103
+ export function remember(channel, digest, fromKey) {
104
+ if (!Array.isArray(channel.seen_from)) channel.seen_from = [];
105
+ if (channel.seen.includes(digest) && !channel.seen_from.some((record) => record.startsWith(digest + ":"))) channel.seen_from.push(digest + ":?");
106
+ if (!channel.seen.includes(digest)) channel.seen.push(digest);
107
+ const record = seenRecord(digest, fromKey);
108
+ if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
109
+ }
45
110
  const ADDRESS = /^[a-z2-7]{20}$/;
46
- const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/;
111
+ // Counted in characters, as aamio-python and aamio-php count a name: without
112
+ // the u flag a name of 33 emoji was 66 units here and refused, and taken there
113
+ // (a check of 13 November 2026).
114
+ const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/u;
115
+
116
+ // ------------------------------------------------------------- the outbox --
117
+ //
118
+ // What happened to a send, read from its entry by the rules aamio-python and
119
+ // aamio-php read it by, so an outbox one of them wrote says the same here.
120
+
121
+ /**
122
+ * Which of five things happened to a send, for a caller deciding whether to
123
+ * replace it, or not_found. Only a stop before the first POST may be called
124
+ * unsent. An attempt that was answered 500 does not prove the message is
125
+ * absent, and one that was delivered is certainly not unsent.
126
+ */
127
+ export function outboxOutcome(entry) {
128
+ if (!isObject(entry)) return "not_found";
129
+ const status = entry.status;
130
+ // Read before the statuses: a retry sets the status back to working while
131
+ // the first attempt is still with the other side.
132
+ if (entry.posting && status === "working") return "attempted";
133
+ if (status === "delivered") return "delivered";
134
+ if (status === "unknown") return "unknown";
135
+ if (status === "attempted") return "attempted";
136
+ if (status === "refused") {
137
+ // An earlier attempt that was left open is not undone by a later refusal.
138
+ // An entry from before ever_open is read by its last answer.
139
+ if (entry.ever_open) return "attempted";
140
+ return SEND_NOT_STORED.includes(entry.last_status) ? "refused" : "attempted";
141
+ }
142
+ // posting is set in the moment before the post, by the runtimes that set
143
+ // it: the one mark that means bytes were on their way.
144
+ if (entry.posting) return "attempted";
145
+ if (status === "working" || status === "stopped") return "never_sent";
146
+ return Number(entry.attempts) > 0 ? "attempted" : "never_sent";
147
+ }
148
+
149
+ /** Whether this message still has something to decide: it left, and what came back settles nothing. */
150
+ export function outboxOpen(entry) {
151
+ return ["attempted", "unknown"].includes(outboxOutcome(entry));
152
+ }
153
+
154
+ /**
155
+ * Whether sending these same bytes again is the right next move. Not the same
156
+ * question as open: a rate window is settled, nothing was stored, and it is
157
+ * also the one refusal whose advice is to wait and send it again. A refusal
158
+ * the service will repeat is not here, and a delivery is not here.
159
+ */
160
+ export function outboxRetryable(entry) {
161
+ if (!isObject(entry)) return false;
162
+ if (outboxOpen(entry)) return true;
163
+ return outboxOutcome(entry) === "refused" && SEND_TRY_LATER.includes(entry.last_status);
164
+ }
165
+
166
+ export const OUTBOX_NOTES = {
167
+ never_sent: "nothing had left this machine for it, and nothing will now",
168
+ attempted: "a send was attempted and its answer does not prove the message is absent, so its outcome stays unknown and nothing here can recall it",
169
+ delivered: "the service had already stored it when this was dropped; dropping the entry does not unsend it",
170
+ refused: "the service answered that it refused this one, so it was not stored, but the attempt did leave this machine",
171
+ unknown: "a send was already away when this was dropped, so its outcome stays unknown and nothing here can recall it",
172
+ not_found: "this outbox has no message with that id, so nothing was dropped and nothing was sent",
173
+ };
174
+
175
+ /**
176
+ * What to do about a send that did not end in a stored message: whether the
177
+ * same stored bytes are worth sending again, and the fix in words. true: the
178
+ * message is fine and the moment was not. false: the same message will fail
179
+ * the same way. null: it cannot be decided from what is known here. Never a
180
+ * permission to repeat something by itself. The words are aamio-python's.
181
+ */
182
+ export function sendAdvice(outcome, status) {
183
+ if (outcome === "never_sent") {
184
+ return [false, "Nothing left this machine for this message, so it was not delivered and will not be: the error says what stopped it. Put that right, then send it as a new message. There is nothing to retry."];
185
+ }
186
+ if (outcome === "unknown") {
187
+ return [null, "No answer came back, so this message may already have been delivered. Keep its message_id. aamio_pending lists what has no settled outcome on this machine; it does not confirm delivery, and nothing here can, because the address it went to is not yours to read. Do not pass the message_id to aamio_send and do not compose a replacement. An approved retry sends the stored bytes again: aamio_outbox_retry with this id, or aamio outbox retry --id on the command line."];
188
+ }
189
+ if (SEND_TRY_LATER.includes(status)) {
190
+ return [true, `aamio declined this for now, not because of the message: ${status} is a rate window or a busy service. Do not change the content. Wait, then send the stored message again through the runtime's outbox retry rather than composing a new one.`];
191
+ }
192
+ if (SEND_DETERMINISTIC.includes(status)) {
193
+ return [false, "aamio refused this and will refuse the same bytes again. Read the error, correct the request, and send the corrected one as a new message."];
194
+ }
195
+ return [null, `aamio answered ${status}, which this client does not classify. It may or may not have stored the message before failing, so treat delivery as unsettled: keep the message_id, read the error, and do not resend blindly.`];
196
+ }
197
+
198
+ // Said with a stop that came while a message was being sent again. The stop
199
+ // is that attempt's: the one before it is still with the other side, and the
200
+ // words of a message that never left, "not sent, send it again", read as leave
201
+ // to write it anew (a check of 12 November 2026).
202
+ const EARLIER_ATTEMPT = "An earlier attempt of it left this machine and may have arrived, so its outcome is still unknown.";
203
+ const RETRY_SAME_ID = "retry the same message: aamio_outbox_retry with this id, or aamio outbox retry --id on the command line. That sends the stored bytes again, which a recipient that keeps what it has seen marks a replay. Do not compose a replacement, and do not send the text as a new message.";
204
+
205
+ /** The codes of a send that did not end in a stored message. Each is said with its message id and its outcome. */
206
+ export const SEND_STOPS = ["send_failed", "not_sent", "retry_not_sent"];
207
+
208
+ /**
209
+ * What such a stop says of itself beside its words, for the command line and
210
+ * the local server alike. The two wrote these fields out separately, and the
211
+ * trace that could not be written was in neither: a command that sends once
212
+ * and exits never saw it (a check of 12 November 2026).
213
+ */
214
+ export function stopFields(error) {
215
+ const said = {};
216
+ for (const field of ["outcome", "message_id", "status"]) if (error?.[field] !== undefined) said[field] = error[field];
217
+ for (const field of ["outbox_error", "trace_error", "archive_error"]) if (error?.[field]) said[field] = error[field];
218
+ // Whether the same stored bytes are worth sending again: true, false, or null when it cannot be told.
219
+ if (error?.retryable !== undefined) said.retryable = error.retryable;
220
+ if (error?.earlier_attempt) said.earlier_attempt = error.earlier_attempt;
221
+ // A channel that is open although the message carrying its address was stopped, or left and has no settled outcome.
222
+ if (isObject(error?.opened)) said.opened = error.opened;
223
+ return said;
224
+ }
225
+
226
+ // How long a channel lives: what the service takes for a thread of its own.
227
+ export const CHANNEL_TTL = 600;
228
+ const CHANNEL_TTL_MIN = 30;
229
+ const CHANNEL_TTL_MAX = 3600;
230
+
231
+ const GATE_SHAPE = 'A gate is an object with "require", "advise" or both, as in {"require": {"pow": {"bits": 20}, "per_key": 5}}. require refuses a write that does not meet it; advise lets the write in and reports on it.';
232
+
233
+ /**
234
+ * The shape of a gate, refused here rather than a round trip later. Only the
235
+ * shape: what the numbers may be is the service's to decide, and its refusal
236
+ * carries the detail. Worth catching before the call because a gate is set
237
+ * once, when the channel is opened, and never changes: one that went nowhere
238
+ * leaves a channel open for its whole life without the conditions its owner
239
+ * meant it to have. Throws invalid_gate.
240
+ */
241
+ export function checkGate(gate) {
242
+ if (!isObject(gate)) throw runtimeError("invalid_gate", `a gate must be an object, not ${Array.isArray(gate) ? "a list" : gate === null ? "null" : typeof gate}. ${GATE_SHAPE}`);
243
+ const unknown = Object.keys(gate).filter((key) => key !== "require" && key !== "advise").sort();
244
+ if (unknown.length) throw runtimeError("invalid_gate", `a gate has no field ${unknown.join(", ")}. ${GATE_SHAPE}`);
245
+ if (!Object.keys(gate).length) throw runtimeError("invalid_gate", `an empty gate asks for nothing; leave the gate out instead. ${GATE_SHAPE}`);
246
+ for (const key of ["require", "advise"]) {
247
+ if (key in gate && !isObject(gate[key])) throw runtimeError("invalid_gate", `gate.${key} must be an object. ${GATE_SHAPE}`);
248
+ }
249
+ return gate;
250
+ }
251
+
252
+ /**
253
+ * A label for a channel of the caller's own. It names the channel in every
254
+ * answer and its file in the archive, so it is made of what a file's name may
255
+ * hold and nothing else: a label with a path in it put what was read on the
256
+ * channel outside the home. The labels that start with inbox or board are
257
+ * the runtime's own channels. Throws invalid_label.
258
+ */
259
+ export function checkLabel(label) {
260
+ if (!isSafeLabel(label)) {
261
+ throw runtimeError("invalid_label", "a channel's label is 1 to 64 letters, digits, dots, dashes and underscores, starting with a letter or a digit, and not a name the system keeps for a device: it names the channel's file in the archive");
262
+ }
263
+ if (/^(?:inbox|board)/i.test(label)) throw runtimeError("invalid_label", `the label ${label} is the runtime's own: a label that starts with inbox or board names an inbox or a board inbox. Choose another`);
264
+ }
265
+
266
+ /** How long a channel is to live, as a whole number of seconds the service takes. Throws invalid_ttl. */
267
+ export function checkChannelTtl(ttl) {
268
+ if (!Number.isInteger(ttl) || ttl < CHANNEL_TTL_MIN || ttl > CHANNEL_TTL_MAX) throw runtimeError("invalid_ttl", `a channel lives a whole number of seconds from ${CHANNEL_TTL_MIN} to ${CHANNEL_TTL_MAX}`);
269
+ return ttl;
270
+ }
47
271
 
48
272
  /** A partner's name and key as partnerAdd takes them, checked where nothing has been opened yet. Throws invalid_name or invalid_key. */
49
273
  export function checkPartner(name, key) {
@@ -115,13 +339,24 @@ function messageShape(body, envelope) {
115
339
  return shape;
116
340
  }
117
341
 
342
+ /** Presence tags from a comma-separated list, as aamio-python reads --tags and AAMIO_TAGS. */
343
+ export function tagsFrom(text) {
344
+ return String(text ?? "").split(",").filter(Boolean);
345
+ }
346
+
118
347
  export class NodeRuntime {
119
348
  /** Takes the home, reads its key and its files. Close it to let go. */
120
- static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, verifyum = process.env.AAMIO_VERIFYUM || VERIFYUM_MCP, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
349
+ static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, verifyum = process.env.AAMIO_VERIFYUM || VERIFYUM_MCP, fetch = globalThis.fetch, clock = () => Date.now() / 1000, archive = null, tags } = {}) {
121
350
  const owner = await takeHome(home, { host });
122
351
  try {
123
- const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock });
352
+ const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock, archive });
124
353
  await runtime.load();
354
+ // The tags presence publishes, taken as aamio-python and aamio-php take
355
+ // them: the ones given now, else the home's own, else AAMIO_TAGS. They
356
+ // are the home's from the next save on. This runtime had no way to set
357
+ // them (a check of 13 November 2026).
358
+ if (Array.isArray(tags)) runtime.state.tags = tags;
359
+ else if (!runtime.state.tags.length) runtime.state.tags = tagsFrom(process.env.AAMIO_TAGS || "");
125
360
  return runtime;
126
361
  } catch (error) {
127
362
  await owner.release();
@@ -129,8 +364,23 @@ export class NodeRuntime {
129
364
  }
130
365
  }
131
366
 
132
- constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock }) {
367
+ constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock, archive = null }) {
133
368
  this.home = home;
369
+ // The archive is the home's own choice, kept in config.json: keep, off or
370
+ // so many days. archive false turns it off for this process, which is
371
+ // what --no-archive does, and true turns it on whatever the home says.
372
+ this.archiveAsked = archive;
373
+ this.archivePolicy = { ...DEFAULT_POLICY };
374
+ this.archiveEnabled = false;
375
+ this.prunedAt = 0;
376
+ // What this process read on each channel, by write address, as it was
377
+ // handed over, with the thread's instance and the archive's mark: what
378
+ // board replies lists beside the archive. In memory only, and gone with
379
+ // the channel.
380
+ this.received = new Map();
381
+ // Messages the archive holds from a read whose cursor could not be
382
+ // written: read again, they are not written down a second time.
383
+ this.writtenDown = new Set();
134
384
  this.host = host.replace(/\/+$/, "");
135
385
  // Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
136
386
  this.verifyum = String(verifyum).replace(/\/+$/, "");
@@ -152,6 +402,13 @@ export class NodeRuntime {
152
402
  this.untold = new Set();
153
403
  // Files whose folder could not be synced after the rename, said once until it can.
154
404
  this.unsynced = new Set();
405
+ // What was sent to each counterpart and what came back, as hashes and
406
+ // shapes: trace.json, a book per public key.
407
+ this.traces = {};
408
+ // The scopes this home holds, and the entries of scopes.json that are not
409
+ // scopes this runtime can use, which go back into the file as they were.
410
+ this.scopes = [];
411
+ this.scopesAside = [];
155
412
  }
156
413
 
157
414
  /**
@@ -183,6 +440,8 @@ export class NodeRuntime {
183
440
  try {
184
441
  // Fatal: a byte that is not UTF-8 makes the file unreadable. Read with
185
442
  // replacement characters, a name in it would be saved back changed.
443
+ // The decoder drops one byte order mark at the start, which is how a
444
+ // file of the home is read by all three runtimes.
186
445
  text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
187
446
  } catch {
188
447
  throw runtimeError("state_unreadable", `${name} in ${this.home} is not UTF-8, so nothing is saved over it. Restore it from a copy, or move it aside.`);
@@ -200,6 +459,10 @@ export class NodeRuntime {
200
459
 
201
460
  async load() {
202
461
  this.partnersFile = await this.loadJson("partners.json", [], Array.isArray);
462
+ const scopes = usableScopes(await this.loadJson("scopes.json", [], Array.isArray));
463
+ this.scopes = scopes.usable;
464
+ this.scopesAside = scopes.aside;
465
+ if (scopes.unusable) this.note("scopes", "scopes_unusable", `scopes.json: ${scopes.unusable} entries are not scopes this runtime can use, and stay in the file as they are`);
203
466
  this.state = await this.loadJson("state.json", {}, isObject);
204
467
  this.outbox = await this.loadJson("outbox.json", {}, isObject);
205
468
  if (!Array.isArray(this.state.tags)) this.state.tags = [];
@@ -208,12 +471,156 @@ export class NodeRuntime {
208
471
  for (const channel of this.state.channels) {
209
472
  if (!isObject(channel)) continue;
210
473
  channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
474
+ channel.seen_from = Array.isArray(channel.seen_from) ? channel.seen_from.filter((value) => typeof value === "string" && SEEN_FROM.test(value)) : [];
211
475
  channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
212
476
  if (!Number.isInteger(channel.after)) channel.after = 0;
213
477
  }
478
+ this.traces = normalizeTraces(await this.loadTrace(), TRACE_KEEP, TRACE_KEYS);
479
+ this.dropExpired();
480
+ this.archivePolicy = await readPolicy(this.home);
481
+ this.archiveEnabled = this.archiveAsked === null || this.archiveAsked === undefined ? this.archivePolicy.mode !== "off" : Boolean(this.archiveAsked);
482
+ await this.prune();
214
483
  await this.settleInterrupted();
215
484
  }
216
485
 
486
+ /**
487
+ * Channels past their time leave the home. The thread is gone at the
488
+ * service with everything on it, and what was read of it is kept for its
489
+ * receipt for as long as the receipt can be taken. The inbox and the board
490
+ * inbox stay until a new one takes their place, so a command can say that
491
+ * the address closed, and when. Left in state.json, every inbox this home
492
+ * ever had was written again at each save. In memory: the file follows at
493
+ * the next save.
494
+ */
495
+ dropExpired() {
496
+ const now = this.now();
497
+ const gone = (channel) => isObject(channel) && channel.label !== "inbox" && channel.label !== "board" && Number(channel.expire_at) + RECEIPT_KEEP <= now;
498
+ for (const channel of this.state.channels.filter(gone)) {
499
+ this.received.delete(channel.w);
500
+ // And what was written down of it at a read that could not be saved: it will not be read again.
501
+ for (const delivery of this.writtenDown) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
502
+ }
503
+ this.state.channels = this.state.channels.filter((channel) => !gone(channel));
504
+ }
505
+
506
+ // ----------------------------------------------------------- archive --
507
+ //
508
+ // What was read, sent and posted, decrypted, in archive/: one file per
509
+ // channel, one record per line, as aamio-python and aamio-php keep it. It
510
+ // is a record of what happened and never the thing itself: a write that
511
+ // fails costs the record, and is said, and the message, the send or the
512
+ // post it is about stands.
513
+
514
+ /** Removes what the policy no longer keeps. A failure is said, since the archive then keeps growing in plain text. */
515
+ async prune() {
516
+ const policy = this.archivePolicy;
517
+ if (!this.archiveEnabled || !(policy.days || policy.max_mb)) return;
518
+ this.prunedAt = this.now();
519
+ try {
520
+ await pruneArchive(this.home, policy, { now: this.now() });
521
+ } catch (error) {
522
+ this.note("archive", "unpruned", `the archive was not pruned: ${error.code || error.name}: ${error.message}. It keeps growing, and what is in it stays readable to whoever can read this folder.`);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Writes one record, and says whether it wrote one. A home that keeps
528
+ * nothing is not a failure, and it is not a write either. Throws when the
529
+ * write fails: the caller knows what the record was about.
530
+ */
531
+ async archive(label, record) {
532
+ if (!this.archiveEnabled || this.closed) return false;
533
+ await appendRecord(this.home, archiveName(label), record);
534
+ if (this.now() - this.prunedAt > 3600) await this.prune();
535
+ return true;
536
+ }
537
+
538
+ /** The same for a record of something that is done already: what went wrong is given back, said in attention, and never thrown. */
539
+ async archiveSafely(label, record, what) {
540
+ try {
541
+ await this.archive(label, record);
542
+ return null;
543
+ } catch (error) {
544
+ const how = `${error.code || error.name}: ${error.message}`;
545
+ this.note("archive", "archive_failed", `${what} could not be written to the archive: ${how}. The archive does not have it.`);
546
+ return how;
547
+ }
548
+ }
549
+
550
+ /** One message of one thread, as this process knows a row it wrote down: the address, the thread's instance where the service names one, the seq and the hash. */
551
+ deliveryOf(channel, entry) {
552
+ return entry.sha256 === null || entry.sha256 === undefined ? null : [channel.w, typeof channel.instance === "string" ? channel.instance : "-", entry.seq, entry.sha256].join("\u0000");
553
+ }
554
+
555
+ /**
556
+ * What the archive holds now and what its policy means. Off stops the
557
+ * writing and removes nothing: what was kept before is still there, in the
558
+ * clear, and the words for off say nothing of it, so this does.
559
+ */
560
+ async archiveStatus() {
561
+ const status = await archiveStatus(this.home, this.archivePolicy);
562
+ if (this.archivePolicy.mode === "off" && status.files > 0) {
563
+ status.note = `Nothing more is written. What was kept before the archive was turned off is still in archive/: ${status.files} file(s), ${status.bytes} bytes. aamio archive prune --all removes it.`;
564
+ }
565
+ return status;
566
+ }
567
+
568
+ /** Removes what the policy no longer keeps, or with everything the whole archive, and says how much went. */
569
+ async archivePrune({ everything = false } = {}) {
570
+ const gone = await pruneArchive(this.home, this.archivePolicy, { now: this.now(), everything });
571
+ return { ...(await this.archiveStatus()), removed: gone.removed };
572
+ }
573
+
574
+ /**
575
+ * The home's own choice, written where the next process finds it, in any of
576
+ * the three runtimes. What config.json holds beside it stays. The choice
577
+ * counts once the file holds it.
578
+ */
579
+ async archiveSet(policy) {
580
+ let config = {};
581
+ try {
582
+ config = await this.loadJson("config.json", {}, isObject);
583
+ } catch {
584
+ // A config.json that cannot be read holds no choice anyone can use: it is replaced by this one.
585
+ config = {};
586
+ }
587
+ try {
588
+ await this.saveJson("config.json", { ...config, archive: policy });
589
+ } catch (error) {
590
+ if (error?.code === "runtime_closed") throw error;
591
+ throw runtimeError("not_saved", `config.json could not be written (${error.code || error.message}), so the archive's policy is as it was. Free the disk, then set it again.`);
592
+ }
593
+ this.archivePolicy = policy;
594
+ this.archiveEnabled = policy.mode !== "off";
595
+ await this.prune();
596
+ return this.archiveStatus();
597
+ }
598
+
599
+ /**
600
+ * trace.json, or nothing when it is not there. The trace is diagnostics: a
601
+ * file that cannot be read never stops the runtime, as aamio-python reads
602
+ * it. It is said, since the next record written replaces the file.
603
+ */
604
+ async loadTrace() {
605
+ let bytes;
606
+ try {
607
+ bytes = await readFile(join(this.home, "trace.json"));
608
+ } catch (error) {
609
+ if (error.code !== "ENOENT") this.untraced(`trace.json could not be read (${error.code || error.message}), so the trace starts empty and the next record replaces the file.`);
610
+ return {};
611
+ }
612
+ try {
613
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
614
+ if (text.trim() === "") return {};
615
+ const value = JSON.parse(text);
616
+ if (!isObject(value)) throw new Error("not a JSON object");
617
+ return value;
618
+ } catch (error) {
619
+ this.untraced(`trace.json is not what a trace holds (${error.message}), so the trace starts empty and the next record replaces the file.`);
620
+ return {};
621
+ }
622
+ }
623
+
217
624
  /**
218
625
  * What a process that stopped left in the outbox, settled by the rules
219
626
  * aamio-python uses, so a shared home says the same in both. A send in
@@ -249,7 +656,13 @@ export class NodeRuntime {
249
656
  : "the process stopped before its proof of work was done, so nothing was sent";
250
657
  changed = true;
251
658
  }
252
- if (["attempted", "unknown"].includes(entry.status) && entry.ever_open !== true) {
659
+ // An attempt left open before this process, by this version or an older
660
+ // one, stays open: a stop or a refusal from now on settles a later
661
+ // attempt, not that one. Asked of the outcome and not of the status: an
662
+ // entry an older client wrote as refused, after an answer that proved
663
+ // nothing, had no mark, and a 410 on its retry called a message that may
664
+ // have landed refused, and not stored (a check of 12 November 2026).
665
+ if (outboxOpen(entry) && entry.ever_open !== true) {
253
666
  entry.ever_open = true;
254
667
  changed = true;
255
668
  }
@@ -299,7 +712,9 @@ export class NodeRuntime {
299
712
  // message was never handed over at all (a review of 9 November 2026).
300
713
  if (!this.unsynced.has(name)) {
301
714
  this.unsynced.add(name);
302
- this.note("files", "not_synced", `${name} was replaced, but the folder ${this.home} could not be synced (${error.code || error.message}). This runtime goes on with what it wrote. After a power cut an older ${name} could come back, and what was read since would then be read again.`);
715
+ // What an older file coming back would mean is said for the file it is
716
+ // true of: the cursor is in state.json, and no other file has one.
717
+ this.note("files", "not_synced", `${name} was replaced, but the folder ${this.home} could not be synced (${error.code || error.message}). This runtime goes on with what it wrote. After a power cut an older ${name} could come back${name === "state.json" ? ", and what was read since would then be read again" : ""}.`);
303
718
  }
304
719
  }
305
720
  }
@@ -323,6 +738,20 @@ export class NodeRuntime {
323
738
  return this.saveJson("outbox.json", this.outbox);
324
739
  }
325
740
 
741
+ /**
742
+ * Written when it changes. A trace that cannot be written costs the trace
743
+ * and nothing else: the line that says so is given back, and null when the
744
+ * file was written.
745
+ */
746
+ async saveTrace() {
747
+ try {
748
+ await this.saveJson("trace.json", this.traces);
749
+ return null;
750
+ } catch (error) {
751
+ return this.untraced(`trace.json: ${error.code || error.name || "Error"}: ${error.message}`);
752
+ }
753
+ }
754
+
326
755
  async close() {
327
756
  if (this.closed) return;
328
757
  this.closed = true;
@@ -335,6 +764,24 @@ export class NodeRuntime {
335
764
  return this.partnersFile.filter((p) => isObject(p) && typeof p.name === "string" && typeof p.key === "string");
336
765
  }
337
766
 
767
+ /**
768
+ * The keys that signed a message this process read and verified, by its
769
+ * hash. The same bytes may carry more than one. What this process checked
770
+ * lives in its memory and not in state.json, so a message read by an
771
+ * earlier process is not here. An observation the channel's allowlist kept
772
+ * out is: a signature that checked is not a permission. An add-on is given
773
+ * the key behind a message this way, and never a key from the conversation.
774
+ */
775
+ verifiedSigners(sha256) {
776
+ const keys = [];
777
+ for (const held of this.observed.values()) {
778
+ for (const row of held.rows.values()) {
779
+ if (row.sha256 === sha256 && row.verified === true && !keys.includes(row.from_key)) keys.push(row.from_key);
780
+ }
781
+ }
782
+ return keys;
783
+ }
784
+
338
785
  /**
339
786
  * The partner with this name: the one so named exactly, or the only one so
340
787
  * named apart from upper and lower case. Two of those, and no exact one, is
@@ -374,11 +821,242 @@ export class NodeRuntime {
374
821
  // release check of 9 November 2026).
375
822
  const next = this.partnersFile.filter((p) => !(isObject(p) && (p.name === name || p.key === key)));
376
823
  next.push({ name, key });
377
- await this.saveJson("partners.json", next);
378
- this.partnersFile = next;
824
+ await this.savePartners(next);
379
825
  return this.inboxAfterChange(`partner ${name} was added`, { partner: name, key });
380
826
  }
381
827
 
828
+ /**
829
+ * A partner out of the address book, and out of the inbox at once, as
830
+ * aamio-python and aamio-php do it. Forgetting the name is not a
831
+ * revocation: the service takes the removed key's writes to the old address
832
+ * until that thread expires. So a new inbox opens without the key, and the
833
+ * one that named it is read until it expires for the partners still in the
834
+ * book, who may not have the new address yet, while what the removed key
835
+ * writes there is not handed over. Once the last partner is gone the
836
+ * new inbox takes signed writes from any key rather than unsigned writes
837
+ * from anyone at an address the partners were given. The name finds the
838
+ * partner the way a send does, apart from upper and lower case when only
839
+ * one is so named.
840
+ */
841
+ async partnerRemove(name) {
842
+ // A key or a name, as a send takes them: the refusal of an ambiguous name
843
+ // says to give the key, and partner remove did not take one.
844
+ const known = isKey(String(name)) ? this.partnerByKey(String(name)) : this.partnerByName(name);
845
+ if (known === null) return { partner: String(name), known: false, inbox: this.channel("inbox")?.w ?? null };
846
+ // The book counts only once partners.json holds it, as with partnerAdd.
847
+ const next = this.partnersFile.filter((p) => !(isObject(p) && p.name === known.name));
848
+ await this.savePartners(next);
849
+ return this.inboxAfterChange(`partner ${known.name} was removed`, { partner: known.name, known: true });
850
+ }
851
+
852
+ /**
853
+ * Writes the address book, and only then holds it. One that cannot be
854
+ * written is said as not_saved, with the book as it was: the command line
855
+ * said only that it could not complete the command.
856
+ */
857
+ async savePartners(next) {
858
+ try {
859
+ await this.saveJson("partners.json", next);
860
+ } catch (error) {
861
+ if (error?.code === "runtime_closed") throw error;
862
+ throw runtimeError("not_saved", `partners.json could not be written (${error.code || error.message}), so the address book is as it was. Free the disk, or give this user write access to ${this.home}, then do it again.`);
863
+ }
864
+ this.partnersFile = next;
865
+ }
866
+
867
+ // ------------------------------------------------------------ scopes --
868
+ //
869
+ // A scope keeps board posts unlisted for a group. Each has a name here, and
870
+ // the name is all a caller passes or sees. The key stays in scopes.json, and
871
+ // scopeShare hands it to a partner sealed. Unlisted is not private.
872
+
873
+ /** Writes the scopes, and only then holds them: a save that fails changes nothing. */
874
+ async saveScopes(scopes, aside = this.scopesAside) {
875
+ await this.saveJson("scopes.json", [...scopes, ...aside]);
876
+ this.scopes = scopes;
877
+ this.scopesAside = aside;
878
+ }
879
+
880
+ /** The same for a caller that asked for the change: what went wrong, and that nothing changed. */
881
+ async keepScopes(scopes, aside, unchanged) {
882
+ try {
883
+ await this.saveScopes(scopes, aside);
884
+ } catch (error) {
885
+ if (error?.code === "runtime_closed") throw error;
886
+ throw runtimeError("not_saved", `scopes.json could not be written (${error.code || error.message}), so ${unchanged}. Free the disk, then do it again.`);
887
+ }
888
+ }
889
+
890
+ /** The scope with this name, apart from upper and lower case. Throws unknown_scope. */
891
+ scopeNamed(name) {
892
+ const entry = scopeCalled(this.scopes, name);
893
+ if (entry === null) throw runtimeError("unknown_scope", `no scope called ${name} here. The scope list shows the ones this runtime holds`);
894
+ return entry;
895
+ }
896
+
897
+ scopeList() {
898
+ return this.scopes.map(scopeView);
899
+ }
900
+
901
+ /** A new scope, its key from the system's secure generator. The key stays here. */
902
+ async scopeNew(name) {
903
+ checkScopeName(name);
904
+ if (scopeCalled(this.scopes, name) !== null) throw runtimeError("scope_exists", `there is a scope called ${name} already`);
905
+ const key = newScopeKey();
906
+ const entry = { name, key, address: scopeAddress(key) };
907
+ await this.keepScopes([...this.scopes, entry], undefined, `scope ${name} was not made`);
908
+ return scopeView(entry);
909
+ }
910
+
911
+ /** A scope made elsewhere: the key, to read and post, or the address, to post only. */
912
+ async scopeAdd(name, { key = null, address = null } = {}) {
913
+ const [held, keep] = scopeAddPlan(this.scopes, name, key ?? null, address ?? null);
914
+ if (keep !== null) await this.keepScopes(keep, undefined, `scope ${name} was not kept`);
915
+ return scopeView(held);
916
+ }
917
+
918
+ /**
919
+ * Removes a scope, and remembers the invitations it came from in the same
920
+ * write. The cursor and the hashes of what was read live in state.json and
921
+ * the scope in scopes.json: an invitation that comes again as a message
922
+ * never seen, after a state.json that could not be written, would bring a
923
+ * removed scope back. Remembered here, the same one read again keeps
924
+ * nothing. A new invitation is another message, and is taken.
925
+ */
926
+ async scopeRemove(name) {
927
+ const entry = this.scopeNamed(name);
928
+ const [scopes, aside] = scopeRemovePlan(this.scopes, this.scopesAside, entry);
929
+ await this.keepScopes(scopes, aside, `scope ${entry.name} is still held`);
930
+ return { removed: entry.name };
931
+ }
932
+
933
+ /** The key itself, for a person to pass on by hand. The local server never calls this. */
934
+ scopeKey(name) {
935
+ const entry = this.scopeNamed(name);
936
+ if (!entry.key) throw runtimeError("scope_write_only", `scope ${entry.name} is held to post only, so there is no key here`);
937
+ return { name: entry.name, key: entry.key, address: entry.address };
938
+ }
939
+
940
+ /**
941
+ * Hands a scope to a partner in a sealed message. read gives the key, write
942
+ * only the address. Only to a partner in the address book, by name or key:
943
+ * an address is learned from the board and from messages, so it can be
944
+ * anyone's, and the key would be sealed to whoever it was learned from. A
945
+ * post asking for a scope would get it.
946
+ */
947
+ async scopeShare(name, to, access) {
948
+ const entry = this.scopeNamed(name);
949
+ checkAccess(access);
950
+ if (access === "read" && !entry.key) throw runtimeError("scope_write_only", `scope ${entry.name} is held to post only, so it can only be shared with access write`);
951
+ const partner = isKey(String(to)) ? this.partnerByKey(to) : this.partnerByName(to);
952
+ if (!partner) throw runtimeError("unknown_partner", "a scope is shared only with a partner in your address book, by name. Never with an address, which can be anyone's");
953
+ const share = access === "read" ? { name: entry.name, key: entry.key } : { name: entry.name, address: entry.address };
954
+ // By the key, so the partner chosen stays chosen. The envelope is sealed
955
+ // to it, and the outbox and the trace keep the envelope and the names of
956
+ // the fields: the scope's key is in neither in the clear.
957
+ const sent = await this.send(partner.key, { text: `Scope ${entry.name}, shared to ${access === "read" ? "read and post" : "post only"}.`, data: { aamio_scope: share }, archivedData: { aamio_scope: { name: entry.name, access } } });
958
+ return { ...sent, scope: entry.name, access };
959
+ }
960
+
961
+ /** What an invitation is known by: the hash of its bytes and its signer, as seen_from records it, or null. */
962
+ shareRecord(entry) {
963
+ return typeof entry.sha256 === "string" && HASH.test(entry.sha256) ? seenRecord(entry.sha256, entry.from_key) : null;
964
+ }
965
+
966
+ /**
967
+ * A scope in an incoming message, before the message is weighed, and before
968
+ * anyone reads it. The key is taken out of the message whatever becomes of
969
+ * the share, and aamio_scope is replaced wherever it sits and whatever it
970
+ * holds, so whoever reads the message never sees a key in it.
971
+ *
972
+ * A share is taken only when it came sealed and verified from a partner in
973
+ * the address book, in a message not seen before, and not from an
974
+ * invitation whose scope was removed since. Nothing is kept here. What
975
+ * taking it would write is given back, or null when nothing is to be taken,
976
+ * and the message carries the longer of the two forms the share can end in,
977
+ * kept or not saved: a message has to be the size it was weighed at.
978
+ */
979
+ scopeSharePlan(entry) {
980
+ const body = entry.body;
981
+ if (!isObject(body)) return null;
982
+ if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
983
+ const data = body.data;
984
+ if (!isObject(data) || !("aamio_scope" in data)) return null;
985
+ const share = data.aamio_scope;
986
+ if (!isObject(share)) {
987
+ data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
988
+ return null;
989
+ }
990
+ const key = share.key ?? null;
991
+ const view = { shared_as: typeof share.name === "string" ? share.name : null, can_read: key !== null, kept: false };
992
+ const partner = entry.from_key ? this.partnerByKey(entry.from_key) : null;
993
+ const invitation = this.shareRecord(entry);
994
+ let why = null;
995
+ if (!(entry.verified && entry.encrypted && entry.known_contact && partner !== null)) {
996
+ why = "not kept: a scope is only taken when it comes sealed from a partner in your address book";
997
+ } else if (entry.replay) {
998
+ why = "not kept again: this message arrived before, and a scope removed since stays removed";
999
+ } else if (entry.replay_unknown) {
1000
+ why = "not kept: these bytes arrived here before, recorded by an earlier version without who sent them, so this may be that message again, and a scope removed since stays removed";
1001
+ } else if (invitation !== null && removedShares(this.scopesAside).has(invitation)) {
1002
+ why = "not kept again: this invitation was taken here before, and its scope was removed since. A scope removed stays removed: ask for it to be shared again";
1003
+ }
1004
+ if (why !== null) {
1005
+ data.aamio_scope = { ...view, note: why };
1006
+ return null;
1007
+ }
1008
+ let held;
1009
+ let keep;
1010
+ let plain;
1011
+ try {
1012
+ const local = sharedScopeName(partner, share.name);
1013
+ [held, keep] = scopeAddPlan(this.scopes, local, key, key !== null ? null : share.address ?? null);
1014
+ // Held already: nothing but the record of this invitation to write.
1015
+ plain = keep === null;
1016
+ // The invitation goes with the scope, in the same write, so that
1017
+ // removing the scope can remember it.
1018
+ if (invitation !== null && !takenFrom(held).includes(invitation)) {
1019
+ const before = held;
1020
+ held = { ...before, taken_from: [...takenFrom(before), invitation] };
1021
+ keep = (keep ?? this.scopes).map((scope) => (scope === before ? held : scope));
1022
+ }
1023
+ } catch (error) {
1024
+ // This share alone. The rest of the messages are handed over either way.
1025
+ data.aamio_scope = { ...view, note: `not kept: ${error.message}` };
1026
+ return null;
1027
+ }
1028
+ const shown = { ...view, ...scopeView(held), kept: true };
1029
+ const unsaved = { ...view, note: SCOPE_NOT_SAVED };
1030
+ data.aamio_scope = longerForm(shown, unsaved);
1031
+ return { data, held, keep, plain, shown, unsaved, shared: share.name };
1032
+ }
1033
+
1034
+ /**
1035
+ * The share of a message that is taken: scopes.json is written, and the
1036
+ * message says kept, or it could not be, and the message says so in the form
1037
+ * it was weighed at while attention says why. Written before the cursor is:
1038
+ * a process that stops between the two reads the message again and finds
1039
+ * the scope held, where the other order would have passed the message by
1040
+ * with the scope not kept and nothing said.
1041
+ */
1042
+ async scopeShareTake(entry, plan, said) {
1043
+ try {
1044
+ if (plan.keep !== null) await this.saveScopes(plan.keep);
1045
+ plan.data.aamio_scope = plan.shown;
1046
+ } catch (error) {
1047
+ const how = `${error.code || error.name}: ${error.message}`;
1048
+ if (plan.plain) {
1049
+ // Held from before, and shown as it is held: only the record of this
1050
+ // invitation was not written, and that is said where a reader looks.
1051
+ plan.data.aamio_scope = plan.shown;
1052
+ said.push(["scope_share_not_recorded", `message ${entry.seq} shared scope ${plan.shared}, which is held here already as ${plan.held.name}, and the invitation could not be recorded with it in scopes.json: ${how}. The scope stays as it is. Removing it will not remember this invitation, so the same message read again after that would bring the scope back.`]);
1053
+ return;
1054
+ }
1055
+ plan.data.aamio_scope = plan.unsaved;
1056
+ said.push(["scope_not_saved", `message ${entry.seq} shared scope ${plan.shared}, and scopes.json could not be written: ${how}. The scope is not kept.`]);
1057
+ }
1058
+ }
1059
+
382
1060
  // ------------------------------------------------------------- inbox --
383
1061
 
384
1062
  channel(label) {
@@ -431,7 +1109,7 @@ export class NodeRuntime {
431
1109
  return taken;
432
1110
  }
433
1111
 
434
- /** A new inbox with the address book as it is now. The old one is kept under another label, and muted when it names a key no longer there. */
1112
+ /** A new inbox with the address book as it is now. The old one is kept under another label, and marked muted when it names a key no longer there. */
435
1113
  async rotateInbox(reason, { caller = false } = {}) {
436
1114
  const old = this.channel("inbox");
437
1115
  let wanted = this.wantedAllow();
@@ -452,7 +1130,7 @@ export class NodeRuntime {
452
1130
  const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
453
1131
  const noted = this.attention.length;
454
1132
  if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
455
- const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
1133
+ const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
456
1134
  this.state.channels.push(inbox);
457
1135
  const muted = this.muteGenerations(reason);
458
1136
  try {
@@ -485,7 +1163,14 @@ export class NodeRuntime {
485
1163
  return outcome;
486
1164
  }
487
1165
 
488
- /** Every older inbox that names a key no longer in the address book is read no more. That is this side's delivery stopping, not a revocation. */
1166
+ /**
1167
+ * Every older inbox that names a key no longer in the address book is marked
1168
+ * muted. It is still read until it expires: what the partners still in the
1169
+ * book write there is handed over, and what a removed key writes there is
1170
+ * not. Read no more, a partner that had not yet learned the new address
1171
+ * wrote where nobody read (a check of 12 November 2026). It is this side's
1172
+ * delivery stopping for that key, not a revocation.
1173
+ */
489
1174
  muteGenerations(reason) {
490
1175
  const partners = new Set(this.partners.map((p) => p.key));
491
1176
  const muted = [];
@@ -495,11 +1180,24 @@ export class NodeRuntime {
495
1180
  if (!gone.length) continue;
496
1181
  channel.muted = true;
497
1182
  muted.push(channel.w);
498
- this.note(channel.label, "muted", `inbox ${channel.w} is read no more after ${reason}: ${gone.length} removed key(s) can still write there until ${channel.expire_at}, and nothing from there is delivered`, channel.w);
1183
+ this.note(channel.label, "muted", `inbox ${channel.w} names ${gone.length} key(s) no longer in the address book after ${reason}. They can still write there until ${channel.expire_at}, and what they write is not handed over; what the partners still in the book write there is read until then.`, channel.w);
499
1184
  }
500
1185
  return muted;
501
1186
  }
502
1187
 
1188
+ /**
1189
+ * Whether a message on this channel is signed by a key that has left the
1190
+ * address book: on an inbox that named the key, which is read on for the
1191
+ * partners still in the book. Judged message by message against the book
1192
+ * as it is, so a key taken back in is read again.
1193
+ */
1194
+ leftTheBook(channel, key) {
1195
+ if (!isObject(channel) || typeof key !== "string" || !String(channel.label).startsWith("inbox")) return false;
1196
+ const allow = channel.allow || [];
1197
+ if (allow.includes("*") || !allow.includes(key)) return false;
1198
+ return !this.partners.some((p) => p.key === key);
1199
+ }
1200
+
503
1201
  /** The inbox, opened again when it is gone, about to expire, or no longer matches the address book. */
504
1202
  async ensureInbox() {
505
1203
  const inbox = this.channel("inbox");
@@ -614,7 +1312,7 @@ export class NodeRuntime {
614
1312
  // -------------------------------------------------------------- send --
615
1313
 
616
1314
  /** One message, signed and sealed to the recipient, written to the outbox before it goes. */
617
- async send(to, { text = null, data = null, re = null } = {}) {
1315
+ async send(to, { text = null, data = null, re = null, archivedData = null } = {}) {
618
1316
  checkRe(re);
619
1317
  const { w, key, partner } = await this.recipient(to);
620
1318
  // What the inbox asks of writers is read before anything is stored: a
@@ -627,7 +1325,7 @@ export class NodeRuntime {
627
1325
  const body = { from: this.keys.hash.slice(0, 8), reply_to: inbox.w };
628
1326
  if (text !== null && text !== undefined) body.text = String(text);
629
1327
  if (data !== null && data !== undefined) body.data = data;
630
- if (re) body.re = re;
1328
+ Object.assign(body, this.conversation(key, re));
631
1329
  const plaintext = JSON.stringify(body);
632
1330
  let entry = await this.outboxAdd(w, key, this.keys.seal(key, plaintext), body);
633
1331
  let answer = await this.deliver(entry, advice);
@@ -640,8 +1338,19 @@ export class NodeRuntime {
640
1338
  entry = await this.outboxAdd(w2, partner.key, this.keys.seal(partner.key, plaintext), body, entry.id);
641
1339
  answer = await this.deliver(entry, advice);
642
1340
  }
1341
+ // The archive is a record of the send, not the send. archivedData stands
1342
+ // in for data there, for a message that carries what no file should: the
1343
+ // archive says which scope was shared and with whom, never its key.
1344
+ const stored = answer.status === 201 && isObject(answer.result) ? answer.result : {};
1345
+ const archiveError = await this.archiveSafely("sent", {
1346
+ kind: "sent", at: this.now(), to: this.nameForKey(entry.to_key) ?? entry.to_key, w: entry.w, status: answer.status, message_id: entry.id, outcome: entry.status,
1347
+ seq: stored.seq ?? null, sha256: stored.sha256 ?? null, body: archivedData === null || archivedData === undefined ? body : { ...body, data: archivedData },
1348
+ }, `the send ${entry.id}`);
643
1349
  if (answer.status !== 201) {
644
- throw runtimeError("send_failed", `send ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError });
1350
+ const [retryable, fix] = sendAdvice(outboxOutcome(entry), answer.status);
1351
+ // The trace that could not be written is said with the failure too: a
1352
+ // command that sends once and exits never fetches attention.
1353
+ throw runtimeError("send_failed", `send ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError, trace_error: answer.traceError, archive_error: archiveError, retryable, fix });
645
1354
  }
646
1355
  // A 201 is a delivery even when the answer carries no receipt: read field
647
1356
  // by field, a missing one threw after the message was stored, and the
@@ -660,6 +1369,10 @@ export class NodeRuntime {
660
1369
  said.push("Delivered: aamio stored it. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown.");
661
1370
  }
662
1371
  if (said.length) sent.note = said.join(" ") + " Do not send this message again.";
1372
+ // The trace that could not be written, beside the delivery it is about.
1373
+ if (answer.traceError) sent.trace_error = answer.traceError;
1374
+ // And the archive: delivered, and only the record of it here is missing.
1375
+ if (archiveError !== null) sent.archive_error = archiveError;
663
1376
  return sent;
664
1377
  }
665
1378
 
@@ -701,14 +1414,21 @@ export class NodeRuntime {
701
1414
 
702
1415
  /** The stored bytes sent once, and what the answer allows this side to claim recorded. */
703
1416
  async deliver(entry, advice) {
1417
+ // What an earlier attempt left open stays open, whatever this one is
1418
+ // answered: read here, before the status is written over.
1419
+ if (outboxOpen(entry)) entry.ever_open = true;
704
1420
  entry.attempts = (entry.attempts || 0) + 1;
705
1421
  entry.status = "sending";
706
1422
  try {
707
1423
  await this.saveOutbox();
708
1424
  } catch (error) {
709
- // Nothing has left this machine, and nothing will.
1425
+ // Nothing has left this machine for this attempt, and nothing will.
710
1426
  entry.status = entry.ever_open ? "unknown" : "stopped";
711
- throw runtimeError("not_sent", `the outbox could not be written (${error.code || error.message}), so message ${entry.id} was not sent. Free the disk, then send it again.`, { message_id: entry.id, outcome: entry.status });
1427
+ const why = error.code || error.message;
1428
+ if (entry.ever_open) {
1429
+ throw runtimeError("retry_not_sent", `the outbox could not be written (${why}), so this attempt of message ${entry.id} was not sent. ${EARLIER_ATTEMPT}`, { message_id: entry.id, outcome: entry.status, retryable: null, fix: "Free the disk, then " + RETRY_SAME_ID });
1430
+ }
1431
+ throw runtimeError("not_sent", `the outbox could not be written (${why}), so message ${entry.id} was not sent. Free the disk, then send it again.`, { message_id: entry.id, outcome: entry.status });
712
1432
  }
713
1433
  const headers = { "Content-Type": "application/json", "X-Key": this.keys.public, "X-Sig": this.keys.sign(threadSigningInput(entry.w, entry.envelope)) };
714
1434
  let notes = [...(advice.notes || [])];
@@ -747,7 +1467,9 @@ export class NodeRuntime {
747
1467
  } catch (error) {
748
1468
  outboxError = `${error.code || error.name}: ${error.message}`;
749
1469
  }
750
- return { ...answer, notes, outboxError };
1470
+ // After the outcome is saved, and unable to change it.
1471
+ const traceError = await this.traceSafely("sent", () => this.traceSent(entry, answer.status, answer.result));
1472
+ return { ...answer, notes, outboxError, traceError };
751
1473
  }
752
1474
 
753
1475
  /** What one answer changes about a message. Certainty only narrows: a delivery settles it, a refusal settles it only when nothing before was left open. */
@@ -775,12 +1497,516 @@ export class NodeRuntime {
775
1497
  entry.status = "attempted";
776
1498
  }
777
1499
 
778
- /** What has no settled outcome on this machine: sending, unknown, attempted, working. */
1500
+ /**
1501
+ * What has no settled outcome on this machine: working, in flight, or open
1502
+ * after an answer that settles nothing. The question retry asks, so the two
1503
+ * never disagree about one entry, an entry an older client wrote included.
1504
+ */
779
1505
  outboxPending() {
780
- return Object.values(this.outbox).filter((e) => isObject(e) && !["delivered", "refused", "stopped"].includes(e.status))
1506
+ return Object.values(this.outbox).filter((e) => isObject(e) && (["working", "sending"].includes(e.status) || outboxOpen(e)))
781
1507
  .map((e) => ({ message_id: e.id, to: this.nameForKey(e.to_key) ?? e.to_key, w: e.w, status: e.status, attempts: e.attempts, last_status: e.last_status ?? null, created_at: e.created_at }));
782
1508
  }
783
1509
 
1510
+ /**
1511
+ * The same stored bytes sent again, for one message or for every one worth
1512
+ * sending again: one that left and got no answer that settles it, or one a
1513
+ * rate window or a busy service turned away. The id, the address, the key
1514
+ * and the envelope are the ones stored, so a recipient that keeps what it
1515
+ * has seen marks a second copy as a replay. A delivery, and a refusal the
1516
+ * service will repeat, are not sent again: nothing goes out for them.
1517
+ * Whether the action behind the message is safe to repeat is the
1518
+ * application's agreement with the other side, not this function's.
1519
+ */
1520
+ async outboxRetry(id = null) {
1521
+ const out = [];
1522
+ for (const entry of Object.values(this.outbox)) {
1523
+ if (!isObject(entry) || (id !== null && entry.id !== id) || !outboxRetryable(entry)) continue;
1524
+ let answer;
1525
+ try {
1526
+ answer = await this.deliver(entry, await this.client.planFor(entry.w));
1527
+ } catch (error) {
1528
+ if (error instanceof Error) {
1529
+ // What was sent again before this one stopped is said with the stop.
1530
+ error.retried = out;
1531
+ // A stop at the gate says nothing was sent, which is true of this
1532
+ // attempt and not of the message: which message, and what an earlier
1533
+ // attempt left open, are said beside the gate's own words.
1534
+ if (error instanceof GateStop) {
1535
+ Object.assign(error, { message_id: entry.id, outcome: outboxOutcome(entry) });
1536
+ if (outboxOpen(entry)) error.earlier_attempt = `This stop is about this attempt only. ${EARLIER_ATTEMPT} When the gate can be met, ${RETRY_SAME_ID}`;
1537
+ }
1538
+ }
1539
+ throw error;
1540
+ }
1541
+ const row = { id: entry.id, w: entry.w, status: entry.status, http: answer.status };
1542
+ if (answer.outboxError) {
1543
+ // What the service answered stands. Only the record of it on this
1544
+ // machine failed, and the answer says both.
1545
+ row.outbox_error = answer.outboxError;
1546
+ row.note = answer.status === 201
1547
+ ? "Delivered: aamio stored it. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown. Do not send this message again."
1548
+ : "The outbox on this machine could not record this answer, so the entry may say sending until the next start, which calls it unknown.";
1549
+ }
1550
+ if (answer.traceError) row.trace_error = answer.traceError;
1551
+ out.push(row);
1552
+ }
1553
+ return out;
1554
+ }
1555
+
1556
+ /**
1557
+ * One entry dropped once its fate no longer matters. Nothing is retried
1558
+ * after this. A send that is already away cannot be recalled, and the answer
1559
+ * says so rather than reporting a clean stop: the difference decides whether
1560
+ * the caller may compose a replacement. The entry is gone from this runtime
1561
+ * only when it is gone from outbox.json.
1562
+ */
1563
+ async outboxForget(id) {
1564
+ const held = Object.hasOwn(this.outbox, id) && isObject(this.outbox[id]) ? this.outbox[id] : null;
1565
+ const outcome = outboxOutcome(held);
1566
+ if (held !== null) {
1567
+ delete this.outbox[id];
1568
+ try {
1569
+ await this.saveOutbox();
1570
+ } catch (error) {
1571
+ this.outbox[id] = held;
1572
+ throw runtimeError("not_saved", `the outbox could not be written (${error.code || error.message}), so message ${id} is still in it and nothing was dropped. Free the disk, then forget it again.`, { message_id: id, outcome });
1573
+ }
1574
+ this.untold.delete(id);
1575
+ }
1576
+ return { id, forgotten: held !== null, already_sending: !["never_sent", "not_found"].includes(outcome), outcome, note: OUTBOX_NOTES[outcome] };
1577
+ }
1578
+
1579
+ // ------------------------------------------------------------- trace --
1580
+ //
1581
+ // A conversation that goes wrong is hard to take apart from one side. The
1582
+ // trace is what this side can show: for each counterpart, what was sent and
1583
+ // how the service answered, what arrived and whether it opened, as hashes and
1584
+ // shapes and never content. It is diagnostics: whatever goes wrong in it
1585
+ // costs the trace, never the send or the read it records.
1586
+
1587
+ /** A trace that did not happen, said in attention, where a caller looks. The line is given back for the answer it belongs to. */
1588
+ untraced(line) {
1589
+ this.note("trace", "untraced", line + " The sends and reads themselves are unaffected; what is behind is the record of them.");
1590
+ return line;
1591
+ }
1592
+
1593
+ /** update run, and whatever goes wrong in it said as a trace that did not happen. Gives back the line that says so, or null. */
1594
+ async traceSafely(what, update) {
1595
+ try {
1596
+ return (await update()) ?? null;
1597
+ } catch (error) {
1598
+ return this.untraced(`trace ${what}: not recorded: ${error?.name || "Error"}: ${error?.message}`);
1599
+ }
1600
+ }
1601
+
1602
+ /** The record for one key, made when first needed and kept to a bound. */
1603
+ traceBook(key) {
1604
+ let book = this.traces[key];
1605
+ if (!isObject(book)) book = this.traces[key] = { sent: [], received: [], last_read: null, active: 0 };
1606
+ book.active = Math.floor(this.now());
1607
+ const keys = Object.keys(this.traces);
1608
+ if (keys.length > TRACE_KEYS) {
1609
+ const stale = keys.sort((a, b) => (this.traces[a]?.active || 0) - (this.traces[b]?.active || 0)).slice(0, keys.length - TRACE_KEYS);
1610
+ for (const old of stale) if (old !== key) delete this.traces[old];
1611
+ }
1612
+ return book;
1613
+ }
1614
+
1615
+ /**
1616
+ * The two fields a message carries about the conversation it is part of: re,
1617
+ * the message it answers, and seen, the last message from this key that was
1618
+ * read here and could be opened. A trace that cannot say leaves seen out,
1619
+ * and never stops a send.
1620
+ */
1621
+ conversation(key, re = null) {
1622
+ const fields = {};
1623
+ if (re) fields.re = re;
1624
+ const last = isObject(this.traces[key]) ? this.traces[key].last_read : null;
1625
+ if (isObject(last) && isMessageHash(last.sha256)) fields.seen = last.sha256;
1626
+ return fields;
1627
+ }
1628
+
1629
+ /** One answer to one send: what went where, as the service stored it. Once per message id: a retry replaces the row. */
1630
+ async traceSent(entry, status, result) {
1631
+ const key = entry.to_key;
1632
+ if (!key) return null;
1633
+ const stored = status === 201 && isObject(result);
1634
+ const record = traceRow({
1635
+ message_id: entry.id, at: Math.floor(this.now()), w: entry.w, status, outcome: entry.status,
1636
+ seq: stored ? result.seq ?? null : null, sha256: stored ? result.sha256 ?? null : null, sealed: true,
1637
+ ...(isObject(entry.shape) ? entry.shape : {}),
1638
+ }, TRACE_SENT_FIELDS);
1639
+ const book = this.traceBook(key);
1640
+ book.sent = [...book.sent.filter((row) => row.message_id !== record.message_id), record].slice(-TRACE_KEEP);
1641
+ return this.saveTrace();
1642
+ }
1643
+
1644
+ /**
1645
+ * One verified message from a key, as it arrived here. Recorded whether or
1646
+ * not it could be opened, since one that could not is what a trace is for.
1647
+ * Taken as read only when it could: last_read is what the next message to
1648
+ * this key names as seen, and it names a message this side opened, not one
1649
+ * that merely arrived. A replay is neither recorded nor read: its first
1650
+ * arrival was, and an old message sent again must not move seen back to it.
1651
+ * Whether anything changed.
1652
+ */
1653
+ traceReceived(channel, entry) {
1654
+ if (entry.replay) return false;
1655
+ const opened = (entry.format === "text" || entry.format === "json") && !entry.error;
1656
+ const body = opened && isObject(entry.body) ? entry.body : {};
1657
+ const row = traceRow({
1658
+ at: entry.at, channel: channel.label, w: channel.w, seq: entry.seq, sha256: entry.sha256, encrypted: entry.encrypted, format: entry.format,
1659
+ error: entry.error,
1660
+ // What a message held is known only if it opened.
1661
+ fields: opened ? Object.keys(body).sort() : null,
1662
+ text_chars: typeof body.text === "string" ? [...body.text].length : null,
1663
+ re: body.re, seen: body.seen,
1664
+ }, TRACE_RECEIVED_FIELDS);
1665
+ const book = this.traceBook(entry.from_key);
1666
+ if (row.sha256 && book.received.some((held) => held.sha256 === row.sha256)) return false;
1667
+ book.received = [...book.received, row].slice(-TRACE_KEEP);
1668
+ if (opened && entry.verified === true && isMessageHash(entry.sha256)) {
1669
+ book.last_read = traceRow({ sha256: entry.sha256, seq: entry.seq, w: channel.w, at: entry.at }, TRACE_READ_FIELDS);
1670
+ }
1671
+ return true;
1672
+ }
1673
+
1674
+ /** What a read handed over, recorded once the read is written down: a message that was not handed over was not read. */
1675
+ async traceRead(channel, entries) {
1676
+ await this.traceSafely("received", async () => {
1677
+ let changed = false;
1678
+ for (const entry of entries) {
1679
+ if (entry.verified === true && entry.from_key) changed = this.traceReceived(channel, entry) || changed;
1680
+ }
1681
+ return changed ? this.saveTrace() : null;
1682
+ });
1683
+ }
1684
+
1685
+ /** A partner name, a key or a write address, as the key it stands for, or null. */
1686
+ traceKey(who) {
1687
+ const partner = typeof who === "string" ? this.partnerByName(who) : null;
1688
+ if (partner) return partner.key;
1689
+ if (isKey(String(who))) return String(who);
1690
+ return typeof who === "string" && Object.hasOwn(this.state.peers, who) ? this.state.peers[who] : null;
1691
+ }
1692
+
1693
+ /**
1694
+ * What was sent to one counterpart and what came back, as hashes and shapes.
1695
+ * who is a partner name, a key or a write address; without it, one line per
1696
+ * counterpart. Nothing here is content: the sha256 is what the service
1697
+ * stored, byte for byte, so the other side can hold its own trace against
1698
+ * this one.
1699
+ */
1700
+ trace(who = null, limit = 20) {
1701
+ const most = Math.max(1, Math.min(Math.floor(Number(limit) || 20), TRACE_KEEP));
1702
+ if (who === null || who === undefined) {
1703
+ const everyone = [];
1704
+ for (const [key, book] of Object.entries(this.traces)) {
1705
+ if (!isObject(book)) continue;
1706
+ const sent = Array.isArray(book.sent) ? book.sent : [];
1707
+ const received = Array.isArray(book.received) ? book.received : [];
1708
+ everyone.push({
1709
+ with: this.nameForKey(key) ?? key, key, sent: sent.length, received: received.length,
1710
+ last_sent_at: sent.length ? sent[sent.length - 1].at ?? null : null,
1711
+ last_received_at: received.length ? received[received.length - 1].at ?? null : null,
1712
+ no_read_claim: traceView(book, TRACE_KEEP).no_read_claim.length,
1713
+ });
1714
+ }
1715
+ const latest = (row) => Math.max(row.last_sent_at || 0, row.last_received_at || 0);
1716
+ everyone.sort((a, b) => latest(b) - latest(a));
1717
+ return { counterparts: everyone, note: "Name one with who for the messages themselves, as hashes." };
1718
+ }
1719
+ const key = this.traceKey(who);
1720
+ const book = key && Object.hasOwn(this.traces, key) ? this.traces[key] : null;
1721
+ if (!isObject(book)) throw runtimeError("no_trace", `nothing sent to or received from ${who} is recorded here`);
1722
+ const view = traceView(book, most);
1723
+ return {
1724
+ with: this.nameForKey(key) ?? key, key, sent: view.sent, received: view.received,
1725
+ // What this side last read and opened from them: what the next message to them names as seen.
1726
+ last_read: book.last_read ?? null,
1727
+ no_read_claim: view.no_read_claim, note: view.note,
1728
+ };
1729
+ }
1730
+
1731
+ // ---------------------------------------------------------- channels --
1732
+ //
1733
+ // A channel is a thread of its own beside the inbox: its own lifetime, its
1734
+ // own list of who may write, and with a gate its own conditions. Its
1735
+ // address reaches whoever is to write there in a sealed and signed message,
1736
+ // which is what lets their runtime send to it: an address pasted into text
1737
+ // binds nothing.
1738
+
1739
+ /** The channel a label names, apart from upper and lower case: two labels that differ only so are one file in the archive where the file system says so. */
1740
+ channelCalled(label) {
1741
+ const wanted = String(label).toLowerCase();
1742
+ return this.state.channels.find((channel) => isObject(channel) && String(channel.label).toLowerCase() === wanted) ?? null;
1743
+ }
1744
+
1745
+ /**
1746
+ * A thread opened for these keys and held as a channel under this label,
1747
+ * used only once state.json holds it. One that cannot be written down is
1748
+ * closed again at the service, as far as that can be done, and its address
1749
+ * is given to nobody: after a restart this home could not read there.
1750
+ */
1751
+ async openChannelThread(label, keys, ttl, gate = null) {
1752
+ let thread;
1753
+ try {
1754
+ thread = await this.client.open({ ttl, allow: keys.length ? keys : undefined, gate: gate ?? undefined });
1755
+ } catch (error) {
1756
+ const said = isObject(error.body) ? error.body : {};
1757
+ const status = Number.isInteger(error.status) ? error.status : 0;
1758
+ // Only a refusal says nothing was opened. With no answer, or one that
1759
+ // does not say, the service may hold a thread: its read key was made for
1760
+ // this request and is kept nowhere, so nobody reads there, nobody was
1761
+ // given its address, and it goes when its time is out. "Nothing was
1762
+ // opened" was said of a thread the service held (a check of 12 November
1763
+ // 2026).
1764
+ const refused = status >= 400 && status < 500;
1765
+ const after = refused
1766
+ ? "Nothing was opened."
1767
+ : `${status === 0 ? "No answer came back, so it is not known" : "That answer does not say"} whether the service opened a thread. This home holds no channel called ${label}, and nobody was given an address: a thread opened there is read by nobody and goes when its time is out.`;
1768
+ const fix = said.fix ?? (refused ? null : "Opening it again is safe: that makes a new address, and the one that may be there is known to nobody.");
1769
+ throw runtimeError("channel_unavailable", `could not open channel: ${status} ${said.error ?? JSON.stringify(error.body ?? error.message)}. ${after}`, { status, ...(fix ? { fix } : {}) });
1770
+ }
1771
+ const channel = { label, read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: keys, after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
1772
+ this.state.channels.push(channel);
1773
+ try {
1774
+ await this.saveState();
1775
+ } catch (error) {
1776
+ this.state.channels = this.state.channels.filter((held) => held !== channel);
1777
+ const closed = await this.client.close({ w: thread.w, id: thread.id }).then(() => true, () => false);
1778
+ throw runtimeError("not_saved", `the channel could not be written down (${error.code || error.message}), so it is not used and its address was given to nobody. ${closed ? "It was closed again at the service." : `It could not be closed at the service either, and stays there, unread, until ${thread.expireAt}.`}`);
1779
+ }
1780
+ return channel;
1781
+ }
1782
+
1783
+ /**
1784
+ * A channel with its own lifetime and, with to, its address handed to
1785
+ * whoever is to write there. to is a partner's name or key, or a write
1786
+ * address a verified message gave as reply_to or channel. That key goes on
1787
+ * the list beside the partners in allow, and the address goes to it sealed
1788
+ * and signed. Where the address goes, whose key seals it, whether the inbox
1789
+ * there takes it and whether the message fits are settled before anything
1790
+ * is opened: what stops there leaves no channel behind.
1791
+ */
1792
+ async channelOpen(label, { ttl = CHANNEL_TTL, allow = null, gate = null, to = null, note = null } = {}) {
1793
+ checkLabel(label);
1794
+ checkChannelTtl(ttl);
1795
+ if (this.channelCalled(label) !== null) throw runtimeError("channel_exists", `there is a channel called ${this.channelCalled(label).label} already. Close it or choose another label`);
1796
+ const handing = to !== null && to !== undefined;
1797
+ if (note !== null && note !== undefined && !handing) throw runtimeError("invalid_note", "note travels with the address to whoever to names, so without to there is nothing to carry it");
1798
+ const keys = [];
1799
+ for (const name of allow ?? []) {
1800
+ const partner = this.partnerByName(name);
1801
+ if (partner === null) throw runtimeError("unknown_partner", `unknown partner: ${name}`);
1802
+ if (!keys.includes(partner.key)) keys.push(partner.key);
1803
+ }
1804
+ if (gate !== null && gate !== undefined) checkGate(gate);
1805
+ let handover = null;
1806
+ if (handing) {
1807
+ const { w: replyTo, key } = await this.recipient(to);
1808
+ // The gate of the inbox the address goes to, read as a send reads it.
1809
+ const advice = await this.client.planFor(replyTo);
1810
+ this.handoverFits(key, ttl, note);
1811
+ if (!keys.includes(key)) keys.push(key);
1812
+ handover = { replyTo, key, advice };
1813
+ }
1814
+ const channel = await this.openChannelThread(label, keys, ttl, gate ?? null);
1815
+ // The gate is in the answer whether or not there is one. It is not kept
1816
+ // here: the thread holds it and GET /{w}/gate serves it, and a second copy
1817
+ // on this machine could only disagree with the first.
1818
+ const opened = { label, w: channel.w, expire_at: channel.expire_at, allow: keys.map((key) => this.nameForKey(key) ?? key), gate: gate ?? null };
1819
+ if (handover === null) return opened;
1820
+ const told = await this.handOver(channel, handover.key, handover.replyTo, note, handover.advice);
1821
+ return { ...opened, handed_to: this.nameForKey(handover.key) ?? handover.key, address_sent_to: handover.replyTo, ...told };
1822
+ }
1823
+
1824
+ /**
1825
+ * A private channel only that key may write to, with its address handed
1826
+ * over when replyTo says where. This is how a conversation leaves the open
1827
+ * board inbox: one answer there, then everything else in a thread nobody
1828
+ * else can write to. key is a key, or a partner's name.
1829
+ */
1830
+ async channelOpenWith(key, { ttl = 900, label = null, replyTo = null, note = null } = {}) {
1831
+ checkChannelTtl(ttl);
1832
+ let theirs = String(key);
1833
+ if (!isKey(theirs)) {
1834
+ const partner = this.partnerByName(theirs);
1835
+ if (partner === null) throw runtimeError("unknown_partner", `not a key and not a known partner: ${key}`);
1836
+ theirs = partner.key;
1837
+ }
1838
+ const handing = replyTo !== null && replyTo !== undefined;
1839
+ if (handing && !(typeof replyTo === "string" && ADDRESS.test(replyTo))) throw runtimeError("invalid_address", "reply-to is a write address, 20 characters of a-z and 2-7, as a verified message gives it in reply_to");
1840
+ if (note !== null && note !== undefined && !handing) throw runtimeError("invalid_note", "note travels with the address to the inbox reply-to names, so without reply-to there is nothing to carry it");
1841
+ // An address this home knows as another key's is not the place to send this one's channel.
1842
+ const bound = handing ? this.state.peers[replyTo] : undefined;
1843
+ if (bound !== undefined && bound !== theirs) {
1844
+ throw runtimeError("binding_conflict", `the address ${replyTo} is bound to ${this.nameForKey(bound) ?? bound}, and this channel is for another key, so its address would be sealed to a key that does not read there. Nothing was opened.`);
1845
+ }
1846
+ let name = label;
1847
+ if (name !== null && name !== undefined) {
1848
+ checkLabel(name);
1849
+ if (this.channelCalled(name) !== null) throw runtimeError("channel_exists", `there is a channel called ${this.channelCalled(name).label} already. Close it or choose another label`);
1850
+ } else {
1851
+ name = `with-${keyHash(theirs).slice(0, 8)}`;
1852
+ if (this.channelCalled(name) !== null) name = this.freeLabel(`${name}-${Math.floor(this.now())}`);
1853
+ }
1854
+ let advice = null;
1855
+ if (handing) {
1856
+ advice = await this.client.planFor(replyTo);
1857
+ this.handoverFits(theirs, ttl, note);
1858
+ }
1859
+ const channel = await this.openChannelThread(name, [theirs], ttl);
1860
+ const opened = { label: name, w: channel.w, expire_at: channel.expire_at, with: this.nameForKey(theirs) ?? theirs, address_sent_to: handing ? replyTo : null };
1861
+ if (!handing) return opened;
1862
+ return { ...opened, ...(await this.handOver(channel, theirs, replyTo, note, advice)) };
1863
+ }
1864
+
1865
+ /** The body that carries a channel's address, and the envelope it is sealed in. */
1866
+ invitation(key, w, expireAt, note = null) {
1867
+ const body = { channel: w, expire_at: expireAt };
1868
+ if (note !== null && note !== undefined && note !== "") body.text = String(note);
1869
+ Object.assign(body, this.conversation(key));
1870
+ return [body, this.keys.seal(key, JSON.stringify(body))];
1871
+ }
1872
+
1873
+ /**
1874
+ * Whether the message that will carry a channel's address fits in one
1875
+ * message, asked before the channel is opened. The address and the expiry
1876
+ * are not known yet. Both have a fixed length, so a stand-in of the same
1877
+ * length seals to the same size. A note too long for it would open the
1878
+ * channel first, and the message carrying the address would then be refused.
1879
+ */
1880
+ handoverFits(key, ttl, note = null) {
1881
+ const [body] = this.invitation(key, "a".repeat(20), Math.floor(this.now()) + ttl, note);
1882
+ // seen can appear before the handover, if a message from this key is read
1883
+ // meanwhile, and it is always 64 characters: counted as there already.
1884
+ if (!("seen" in body)) body.seen = "0".repeat(64);
1885
+ const size = Buffer.byteLength(this.keys.seal(key, JSON.stringify(body)), "utf8");
1886
+ if (size > MESSAGE_MAX_BYTES) {
1887
+ throw runtimeError("too_large", `with this note the message carrying the address would be ${size} bytes, and a message is at most ${MESSAGE_MAX_BYTES}, so no channel was opened. Shorten the note, and send the rest on the channel once it is open.`);
1888
+ }
1889
+ }
1890
+
1891
+ /**
1892
+ * The channel's address sent to replyTo, sealed to key and signed, through
1893
+ * the outbox like any send, so its outcome is kept and the same bytes can
1894
+ * go again. Gives what the caller's answer carries besides: the message's
1895
+ * id, and what could not be recorded of it here.
1896
+ *
1897
+ * Whatever stops it, the channel is open by now, and the failure says so,
1898
+ * with the channel, and says whether anything left this machine and, of
1899
+ * what left, whether it is known not to have arrived.
1900
+ */
1901
+ async handOver(channel, key, replyTo, note = null, advice = null) {
1902
+ const opened = { label: channel.label, w: channel.w, expire_at: channel.expire_at };
1903
+ const listed = `The channel ${channel.label} is open and listed by channel list, aamio_channels over MCP`;
1904
+ // Said of a message the service turned away or a gate stopped: it is known not to be there.
1905
+ const still = `${listed}: only the message carrying its address did not go. `;
1906
+ // Said of one that left and has no answer that settles it. It may be with
1907
+ // them already, and "did not go" read as leave to open another channel and
1908
+ // invite again, which the one who has the first reads as two (a check of
1909
+ // 12 November 2026).
1910
+ const unsettled = `${listed}, and the message carrying its address may have arrived: its outcome is not known. Do not open another channel for this: a new attempt is the same message, under the same message_id. `;
1911
+ let entry = null;
1912
+ let answer;
1913
+ try {
1914
+ const [body, envelope] = this.invitation(key, channel.w, channel.expire_at, note);
1915
+ entry = await this.outboxAdd(replyTo, key, envelope, body);
1916
+ answer = await this.deliver(entry, advice ?? await this.client.planFor(replyTo));
1917
+ } catch (error) {
1918
+ if (error instanceof GateStop) {
1919
+ error.opened = opened;
1920
+ // Nothing was sent, so nobody has the address, and the channel is one
1921
+ // nobody will write to: closed, and opened anew when it can be handed over.
1922
+ error.fix = still + `Nothing was sent, so nobody has been given its address: close it with channel close ${channel.label}, aamio_close_channel over MCP, and open a new one when the inbox's conditions can be met. ` + (error.fix ?? "");
1923
+ throw error;
1924
+ }
1925
+ // Stopped before a byte left: the outbox could not be written, or the message could not be made.
1926
+ throw runtimeError("send_failed", `the channel ${channel.label} is open, and the message carrying its address was not sent: ${error.message}`, {
1927
+ outcome: "never_sent", message_id: entry?.id ?? error.message_id ?? null, status: 0, opened, retryable: false,
1928
+ fix: `Nothing left this machine, so nobody has been given the address of ${channel.label}. Close it with channel close ${channel.label}, aamio_close_channel over MCP, put right what the error says, and open a new one.`,
1929
+ });
1930
+ }
1931
+ if (answer.status !== 201) {
1932
+ const outcome = outboxOutcome(entry);
1933
+ const [retryable, advice] = sendAdvice(outcome, answer.status);
1934
+ // Only a refusal says the message is not there.
1935
+ const refused = outcome === "refused";
1936
+ // And then nobody has the address. Sending a corrected message, as a
1937
+ // refused send is told, would need the address, and an address goes
1938
+ // only with a channel: this one is closed, and a new one opened.
1939
+ const fix = refused && !SEND_TRY_LATER.includes(answer.status)
1940
+ ? `aamio refused it, so nobody has been given the address of ${channel.label}. Close it with channel close ${channel.label}, aamio_close_channel over MCP, put right what the error says, and open a new one.`
1941
+ : advice;
1942
+ throw runtimeError("send_failed", `the channel ${channel.label} is open, and the message carrying its address ${refused ? "did not land" : "may have arrived"}: send ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, {
1943
+ outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError, trace_error: answer.traceError, opened, retryable, fix: (refused ? still : unsettled) + fix,
1944
+ });
1945
+ }
1946
+ const told = { message_id: entry.id };
1947
+ if (answer.notes?.length) told.notes = answer.notes;
1948
+ if (answer.outboxError) {
1949
+ told.outbox_error = answer.outboxError;
1950
+ told.note = "Delivered: aamio stored the message carrying the address. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown. Do not hand the address over again.";
1951
+ }
1952
+ if (answer.traceError) told.trace_error = answer.traceError;
1953
+ return told;
1954
+ }
1955
+
1956
+ /** The channels of this home: the public address, how long each has left, and how far it is read. Never a read key. */
1957
+ channelList() {
1958
+ const now = this.now();
1959
+ return this.state.channels.filter(isObject).map((channel) => ({
1960
+ label: channel.label, w: channel.w, expire_at: channel.expire_at, seconds_left: Math.max(0, Math.floor(Number(channel.expire_at) - now)), expired: Number(channel.expire_at) <= now,
1961
+ allow: (channel.allow || []).map((key) => this.nameForKey(key) ?? key),
1962
+ // What this process read here, and where the cursor stands: what a command of its own, which has read nothing, can still say.
1963
+ received: (this.received.get(channel.w) ?? []).length, read_to: Number.isInteger(channel.after) ? channel.after : 0, gone: channel.gone === true, muted: channel.muted === true,
1964
+ }));
1965
+ }
1966
+
1967
+ /**
1968
+ * Closes a channel before it expires. The thread is asked gone at the
1969
+ * service first, and the channel leaves this home when the service says it
1970
+ * is gone: deleted now, or not there any more. When the service does not
1971
+ * say so, the channel stays and is still read: taken out of this home while
1972
+ * the thread lived on, whoever wrote there would write where nobody reads.
1973
+ * The answer says which of the two happened, here and at the service.
1974
+ */
1975
+ async channelClose(label) {
1976
+ const channel = this.channel(String(label));
1977
+ if (!isObject(channel)) {
1978
+ const labels = this.state.channels.filter(isObject).map((held) => held.label);
1979
+ throw runtimeError("unknown_channel", `no channel called ${label} in this home. It has ${labels.length ? labels.join(", ") : "none"}.`);
1980
+ }
1981
+ let status = 200;
1982
+ try {
1983
+ await this.client.close({ w: channel.w, id: channel.read_key });
1984
+ } catch (error) {
1985
+ status = error instanceof AamioError ? error.status : 0;
1986
+ }
1987
+ const gone = status === 200 || status === 404 || status === 410;
1988
+ if (!gone) {
1989
+ return {
1990
+ label: channel.label, w: channel.w, status, deleted: false, closed_here: false,
1991
+ note: `${status === 0 ? "The service did not answer" : `The service answered ${status}`}, so the thread may still be there, and the channel is kept and still read. Close it again, or let it expire at ${channel.expire_at}.`,
1992
+ };
1993
+ }
1994
+ const before = this.state.channels;
1995
+ this.state.channels = before.filter((held) => held !== channel);
1996
+ try {
1997
+ await this.saveState();
1998
+ } catch (error) {
1999
+ this.state.channels = before;
2000
+ throw runtimeError("not_saved", `${status === 200 ? "the thread is deleted at the service" : "there is no thread at the service any more"}, and state.json could not be written (${error.code || error.message}), so this home still lists ${channel.label}. Close it again once the disk takes writes.`, { status, deleted: status === 200 });
2001
+ }
2002
+ this.received.delete(channel.w);
2003
+ this.observed.delete(channel.w);
2004
+ return {
2005
+ label: channel.label, w: channel.w, status, deleted: status === 200, closed_here: true,
2006
+ ...(status === 200 ? {} : { note: "There was no thread at this address any more: it had expired, or was closed before. The channel is taken out of this home." }),
2007
+ };
2008
+ }
2009
+
784
2010
  // ------------------------------------------------------------- board --
785
2011
  //
786
2012
  // The open board, where agents that have not met find each other. Posts are
@@ -806,7 +2032,7 @@ export class NodeRuntime {
806
2032
  }
807
2033
  const label = held?.label;
808
2034
  if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
809
- const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
2035
+ const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
810
2036
  this.state.channels.push(channel);
811
2037
  try {
812
2038
  await this.saveState();
@@ -818,20 +2044,37 @@ export class NodeRuntime {
818
2044
  return channel;
819
2045
  }
820
2046
 
821
- /** A need or an offer on the open board, pointing at the board inbox, which outlives it. */
822
- async boardPost({ kind, title, text, tags = [], ttl = BOARD_TTL, lang = null, deadline = null } = {}) {
2047
+ /**
2048
+ * A need or an offer on the open board, pointing at the board inbox, which
2049
+ * outlives it. With scope, the name of a scope held here, the post carries
2050
+ * that scope's address and is unlisted: only a find with the scope's key
2051
+ * returns it. Unlisted is not private.
2052
+ */
2053
+ async boardPost({ kind, title, text, tags = [], ttl = BOARD_TTL, lang = null, deadline = null, scope = null } = {}) {
2054
+ // Before the inbox is opened, so a name that is not here costs nothing.
2055
+ const held = scope !== null && scope !== undefined ? this.scopeNamed(scope) : null;
823
2056
  const life = Math.floor(Number(ttl) || BOARD_TTL);
824
- const channel = await this.ensureBoardInbox(life);
2057
+ // An inbox that outlives the post by the margin the library leaves for the work and the round trip.
2058
+ const channel = await this.ensureBoardInbox(life + BOARD_POST_MARGIN);
825
2059
  let made;
826
2060
  try {
827
- made = await this.client.board.post({ kind, title, text, tags: (tags || []).slice(0, 8), ttl: life, ...(lang ? { lang } : {}), ...(deadline ? { deadline } : {}) }, { inbox: { w: channel.w, expireAt: channel.expire_at } });
2061
+ // The address goes inside the signed body, so nobody can post the same bytes without it.
2062
+ made = await this.client.board.post({ kind, title, text, tags: (tags || []).slice(0, 8), ttl: life, ...(lang ? { lang } : {}), ...(deadline ? { deadline } : {}), ...(held ? { scope: held.address } : {}) }, { inbox: { w: channel.w, expireAt: channel.expire_at } });
828
2063
  } catch (error) {
829
2064
  throw boardError("board post", error);
830
2065
  }
831
- // Where the answers go and how to read them, in the answer itself.
2066
+ // The post is made. A record of it that cannot be written is said beside
2067
+ // it, and is no reason to tell the caller the post failed: it would be
2068
+ // made again.
2069
+ const archiveError = await this.archiveSafely("board", { kind: "posted", at: this.now(), post: made.post }, `the post ${isObject(made.post) ? made.post.id : ""}`);
2070
+ // Where the answers go, until when that inbox is open and how to read
2071
+ // them, in the answer itself. The inbox's time is not the post's:
2072
+ // post.expire_at is when the post leaves the board.
832
2073
  return {
833
- post: made.post, inbox: channel.w, answers_arrive_on: "board",
834
- read_them_with: "Read them with read, aamio read on the command line and aamio_read over MCP, which shows every message on your inboxes, the board inbox among them. Each says who signed it and whether it was sealed to you.",
2074
+ post: made.post, inbox: channel.w, inbox_expires_at: channel.expire_at, answers_arrive_on: "board",
2075
+ read_them_with: "Read them with read, aamio read on the command line and aamio_read over MCP, which shows every message on your inboxes, the board inbox among them. board replies on the command line lists only the answers, the messages that name a post of yours or arrived on a board inbox, and says how many it left out.",
2076
+ ...(held ? { scope: held.name } : {}),
2077
+ ...(archiveError !== null ? { archive_error: archiveError } : {}),
835
2078
  };
836
2079
  }
837
2080
 
@@ -840,21 +2083,42 @@ export class NodeRuntime {
840
2083
  * after (the cursor from the last page), wait (up to 25 s for the next one)
841
2084
  * and min_work_bits. The address on each post is bound to its key, so a send
842
2085
  * to it is sealed to the poster. An address bound before keeps its key.
2086
+ *
2087
+ * With scope, the name of a scope held here with its key, the find reads
2088
+ * that scope instead of the public board. The key goes to the board in the
2089
+ * body of the find and nowhere else, and is in nothing this gives back.
843
2090
  */
844
- async boardFind({ kind = null, tags = null, lang = null, after = 0, wait = 0, min_work_bits: bits = 0 } = {}) {
2091
+ async boardFind({ kind = null, tags = null, lang = null, after = 0, wait = 0, min_work_bits: bits = 0, scope = null } = {}) {
2092
+ const held = scope !== null && scope !== undefined ? this.scopeNamed(scope) : null;
2093
+ if (held !== null && !held.key) throw runtimeError("scope_write_only", `scope ${held.name} is held to post only. Reading it takes the key, which a partner can share with access read`);
845
2094
  const filter = { after: Math.max(0, Math.floor(Number(after) || 0)) };
846
2095
  if (kind) filter.kind = kind;
847
2096
  if (tags?.length) filter.tags = tags.slice(0, 20);
848
2097
  if (lang) filter.lang = lang;
849
2098
  if (wait) filter.wait = Math.min(25, Math.floor(wait));
850
2099
  if (bits) filter.min_work_bits = Math.floor(bits);
2100
+ const unread = () => runtimeError("board_failed", `the board did not say it read scope ${held.name}, so these posts are not shown`);
851
2101
  let page;
852
2102
  try {
853
- page = await this.client.board.find(filter);
2103
+ // A board older than scopes answers 400 to the field, so it never reads the public board instead.
2104
+ page = await this.client.board.find(held ? { ...filter, scopeKey: held.key } : filter);
854
2105
  } catch (error) {
855
- throw boardError("board find", error);
2106
+ // The client believes only an answer that names the scope, and says so as an answer of 200 that is not one.
2107
+ if (held && error instanceof AamioError && error.status === 200) throw unread();
2108
+ const failure = boardError("board find", error);
2109
+ if (held) {
2110
+ failure.message = withoutScopeKey(failure.message, held.key);
2111
+ if (failure.fix) failure.fix = withoutScopeKey(String(failure.fix), held.key);
2112
+ }
2113
+ throw failure;
856
2114
  }
857
2115
  if (!isObject(page) || !Array.isArray(page.posts)) throw runtimeError("board_failed", "the board answered the find with something that is not a page of posts");
2116
+ if (held) {
2117
+ // The answer names the scope it read. Without that it did not read this one.
2118
+ if (page.scope !== held.address) throw unread();
2119
+ page = withoutScopeKey(page, held.key);
2120
+ page.scope_name = held.name;
2121
+ }
858
2122
  for (const post of page.posts) {
859
2123
  if (isObject(post) && typeof post.w === "string" && ADDRESS.test(post.w) && isKey(String(post.key)) && !(post.w in this.state.peers)) this.state.peers[post.w] = post.key;
860
2124
  }
@@ -882,11 +2146,14 @@ export class NodeRuntime {
882
2146
 
883
2147
  /** A post of this home's taken down before it expires, signed by the key that posted it. */
884
2148
  async boardWithdraw(id) {
2149
+ let done;
885
2150
  try {
886
- return await this.client.board.withdraw(String(id));
2151
+ done = await this.client.board.withdraw(String(id));
887
2152
  } catch (error) {
888
2153
  throw boardError("withdraw", error);
889
2154
  }
2155
+ const archiveError = await this.archiveSafely("board", { kind: "withdrawn", at: this.now(), id: String(id) }, `the withdrawal of post ${id}`);
2156
+ return archiveError !== null && isObject(done) ? { ...done, archive_error: archiveError } : done;
890
2157
  }
891
2158
 
892
2159
  /**
@@ -896,9 +2163,15 @@ export class NodeRuntime {
896
2163
  * unknown rather than failed. The board inbox outlives the post by
897
2164
  * ANSWER_MARGIN, so the poster can answer back after reading it late.
898
2165
  */
899
- async boardAnswer(postId, { text = null, data = null } = {}) {
900
- const post = await this.boardGet(postId);
901
- if (post === null) throw runtimeError("unknown_post", `no live post with id ${postId}: it expired or was withdrawn. A post in a scope is not served by id, and this runtime does not read scopes yet.`);
2166
+ async boardAnswer(postId, { text = null, data = null, scope = null } = {}) {
2167
+ const scoped = scope !== null && scope !== undefined;
2168
+ // A post in a scope is never served by id alone, so with scope the post is looked up in that scope.
2169
+ const post = scoped ? await this.scopedPost(scope, postId) : await this.boardGet(postId);
2170
+ if (post === null) {
2171
+ throw runtimeError("unknown_post", scoped
2172
+ ? `no live post with id ${postId} in scope ${scope}: it expired or was withdrawn, or it is in another scope.`
2173
+ : `no live post with id ${postId}: it expired or was withdrawn. A post in a scope is found with the scope's name: board answer --scope NAME, or scope over MCP.`);
2174
+ }
902
2175
  if (typeof post.w !== "string" || !ADDRESS.test(post.w) || !isKey(String(post.key))) {
903
2176
  throw runtimeError("board_failed", `post ${postId} came without a write address and a key to seal an answer to, so nothing was sent`);
904
2177
  }
@@ -909,10 +2182,13 @@ export class NodeRuntime {
909
2182
  const body = { post: post.id, reply_to: channel.w, from: this.keys.hash.slice(0, 8) };
910
2183
  if (text !== null && text !== undefined) body.text = String(text);
911
2184
  if (data !== null && data !== undefined) body.data = data;
2185
+ Object.assign(body, this.conversation(post.key));
912
2186
  const entry = await this.outboxAdd(post.w, post.key, this.keys.seal(post.key, JSON.stringify(body)), body);
913
2187
  const answer = await this.deliver(entry, advice);
2188
+ const archiveError = await this.archiveSafely("board", { kind: "answered", at: this.now(), post: post.id, w: post.w, status: answer.status, message_id: entry.id, outcome: entry.status, body }, `the answer ${entry.id}`);
914
2189
  if (answer.status !== 201) {
915
- throw runtimeError("send_failed", `answer ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError });
2190
+ const [retryable, fix] = sendAdvice(outboxOutcome(entry), answer.status);
2191
+ throw runtimeError("send_failed", `answer ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError, trace_error: answer.traceError, archive_error: archiveError, retryable, fix });
916
2192
  }
917
2193
  const receipt = isObject(answer.result) ? answer.result : {};
918
2194
  const done = { post: post.id, w: post.w, message_id: entry.id, seq: receipt.seq ?? null, at: receipt.at ?? null, sha256: receipt.sha256 ?? null, replies_arrive_on: "board", reply_to: channel.w };
@@ -922,9 +2198,166 @@ export class NodeRuntime {
922
2198
  done.note = "Delivered: aamio stored it. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown. Do not answer again.";
923
2199
  }
924
2200
  if (post.key === this.keys.public) done.warning = "You answered your own post. The answer is sealed to your own key, so it reaches nobody but you.";
2201
+ if (answer.traceError) done.trace_error = answer.traceError;
2202
+ if (archiveError !== null) done.archive_error = archiveError;
925
2203
  return done;
926
2204
  }
927
2205
 
2206
+ /**
2207
+ * Reads the board inboxes, and only those: the one posts point at now and
2208
+ * the older ones still open. What waits on the other channels is left where
2209
+ * it is, for read, and the inbox's cursor stands. With wait, the newest
2210
+ * board inbox is waited on, and the others are asked once.
2211
+ */
2212
+ async boardPoll(wait = 0) {
2213
+ this.dropExpired();
2214
+ const now = this.now();
2215
+ const boards = this.state.channels.filter((channel) => isObject(channel) && (channel.label === "board" || String(channel.label).startsWith("board-")) && !channel.muted && Number(channel.expire_at) > now)
2216
+ .sort((a, b) => (a.label === "board" ? -1 : b.label === "board" ? 1 : 0));
2217
+ const collected = [];
2218
+ for (const [index, channel] of boards.entries()) collected.push(...(await this.poll(channel, index === 0 ? Math.min(25, Math.max(0, wait)) : 0, null, null)));
2219
+ return collected;
2220
+ }
2221
+
2222
+ /**
2223
+ * Answers to this home's posts, decrypted and verified, oldest first: what
2224
+ * this process read, and what the archive holds from before. A command is a
2225
+ * process of its own, so without the archive a run would show only what
2226
+ * arrived inside its own wait, though the rest was read and written down.
2227
+ * With postId, the answers that name that post, wherever they arrived.
2228
+ * Without it, everything that arrived on a board inbox and everything that
2229
+ * names a post. How many messages were left out is kept in
2230
+ * boardRepliesLeftOut.
2231
+ *
2232
+ * One delivery is one row. A row with its thread's instance is one
2233
+ * delivery by its address, instance and seq, and the first of its copies
2234
+ * stands, as it was first handed over: the one the archive has from before,
2235
+ * when it has one this process did not write. Any other row is told by all
2236
+ * it shows, and two such rows that show the same are both kept: nothing
2237
+ * says they are one. The rule is aamio-python's, row for row.
2238
+ */
2239
+ async boardReplies(postId = null) {
2240
+ const one = (entry) => (typeof entry.instance === "string" ? [entry.w, entry.instance, entry.seq].join("\u0000") : null);
2241
+ const shown = (entry) => JSON.stringify([entry.w ?? null, entry.seq ?? null, entry.at ?? null, entry.sha256 ?? null, entry.from_key ?? null]);
2242
+ const wanted = (entry) => {
2243
+ const body = entry.body;
2244
+ if (postId !== null && postId !== undefined) return isObject(body) && body.post === postId;
2245
+ // An answer names the post it answers, and most do. One that does not is
2246
+ // still an answer if it arrived on the address a post gave out.
2247
+ if (String(entry.channel ?? "").startsWith("board")) return true;
2248
+ return isObject(body) && typeof body.post === "string";
2249
+ };
2250
+ const count = (keys) => {
2251
+ const counted = new Map();
2252
+ for (const key of keys) if (key !== null) counted.set(key, (counted.get(key) ?? 0) + 1);
2253
+ return counted;
2254
+ };
2255
+ // Every channel, not only the ones named board: an answer can arrive on a
2256
+ // private channel opened for the conversation.
2257
+ const held = [];
2258
+ for (const channel of this.state.channels) if (isObject(channel)) held.push(...(this.received.get(channel.w) ?? []));
2259
+ // And every file of the archive: a board inbox is renewed while the old
2260
+ // one still holds answers, and the old one keeps its own label and its own
2261
+ // file after its channel has left this home. What an earlier run wrote is
2262
+ // read whether the archive is on or off now: it stays the record of what
2263
+ // arrived until it is pruned.
2264
+ const rows = [];
2265
+ let names = [];
2266
+ try {
2267
+ names = await archiveNames(this.home);
2268
+ } catch (error) {
2269
+ this.note("archive", "unread", `the archive could not be listed (${error.code || error.name}), so answers written down earlier are not in this result`);
2270
+ }
2271
+ for (const name of names) {
2272
+ try {
2273
+ rows.push(...(await archivedRecords(this.home, name, "received")));
2274
+ } catch (error) {
2275
+ this.note("archive", "unread", `the archive ${name}.jsonl could not be read (${error.code || error.name}), so answers written down earlier are not in this result`);
2276
+ }
2277
+ }
2278
+ // A delivery the archive has more rows of than this process wrote was
2279
+ // written down before this process read it: that row is its first copy,
2280
+ // and the copies held here are later readings of it.
2281
+ const stored = count(rows.map(one));
2282
+ const written = count(held.filter((entry) => entry.archived !== false).map(one));
2283
+ const earlier = new Set([...stored].filter(([delivery, rowsOfIt]) => rowsOfIt > (written.get(delivery) ?? 0)).map(([delivery]) => delivery));
2284
+ const out = [];
2285
+ const skipped = [];
2286
+ const named = new Set();
2287
+ for (const entry of held) {
2288
+ const delivery = one(entry);
2289
+ if (delivery !== null) {
2290
+ if (named.has(delivery) || earlier.has(delivery)) continue;
2291
+ named.add(delivery);
2292
+ }
2293
+ (wanted(entry) ? out : skipped).push(entry);
2294
+ }
2295
+ // A copy in memory without the instance, from a service that names none,
2296
+ // has its row in the archive as well when it was written down, and stands
2297
+ // for one row there that shows the same. One that was not written down
2298
+ // has no row there and stands for none.
2299
+ const onDisk = count(held.filter((entry) => one(entry) === null && entry.archived !== false).map(shown));
2300
+ for (const entry of rows) {
2301
+ const delivery = one(entry);
2302
+ if (delivery !== null) {
2303
+ if (named.has(delivery)) continue;
2304
+ named.add(delivery);
2305
+ } else if ((onDisk.get(shown(entry)) ?? 0) > 0) {
2306
+ onDisk.set(shown(entry), onDisk.get(shown(entry)) - 1);
2307
+ continue;
2308
+ }
2309
+ if (wanted(entry)) out.push({ ...entry, from_archive: true });
2310
+ else skipped.push(entry);
2311
+ }
2312
+ out.sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0) || (Number(a.seq) || 0) - (Number(b.seq) || 0));
2313
+ this.boardRepliesLeftOut = skipped.length;
2314
+ // An empty list is easily read as an empty inbox, and the reader goes
2315
+ // looking for the fault at the other end. Whatever this passed over is
2316
+ // still a message, so it says how many there are and where.
2317
+ if (skipped.length && !out.length) {
2318
+ const unopened = skipped.filter((entry) => !entry.verified).length;
2319
+ const unread = unopened ? `, and ${unopened} of them arrived unsigned, so the body was never opened` : "";
2320
+ this.note("board replies", "filtered", postId !== null && postId !== undefined
2321
+ ? `Nothing here answers post ${postId}, but ${skipped.length} other message(s) are on your channels${unread}. Run board replies without a post, or read, to see them.`
2322
+ : `${skipped.length} message(s) are here and none of them looks like a board answer, because they name no post and did not arrive on a board inbox${unread}. Run read to see them.`);
2323
+ }
2324
+ return out;
2325
+ }
2326
+
2327
+ /**
2328
+ * Where answers to this home's posts and answers arrive, and whether it is
2329
+ * still open. A board inbox that has expired is the difference between
2330
+ * nobody replied and nobody could.
2331
+ */
2332
+ boardReplyAddress() {
2333
+ const channel = this.channel("board");
2334
+ if (channel === null) return { w: null, open: false, why: "No board inbox on this machine. One is opened when you answer or post." };
2335
+ const left = Math.floor(Number(channel.expire_at) - this.now());
2336
+ if (left > 0) return { w: channel.w, open: true, expires_at: Math.floor(Number(channel.expire_at)), seconds_left: left };
2337
+ return {
2338
+ w: channel.w, open: false, expires_at: Math.floor(Number(channel.expire_at)),
2339
+ why: `The address you answered from closed ${-left} s ago. Anything sent to it after that was refused at the door, so an empty result here does not mean nobody wrote back.`,
2340
+ };
2341
+ }
2342
+
2343
+ /**
2344
+ * One live post of a scope, by its id, or null. A page holds up to 200 posts
2345
+ * and a scope can hold more: the cursor goes on until the post turns up or
2346
+ * the pages run out.
2347
+ */
2348
+ async scopedPost(scope, postId) {
2349
+ let after = 0;
2350
+ for (let pages = 0; pages < 50; pages++) {
2351
+ const page = await this.boardFind({ scope, after });
2352
+ const post = page.posts.find((found) => isObject(found) && found.id === postId);
2353
+ if (post) return post;
2354
+ const next = Math.floor(Number(page.next) || 0);
2355
+ if (!page.posts.length || next <= after) return null;
2356
+ after = next;
2357
+ }
2358
+ return null;
2359
+ }
2360
+
928
2361
  // -------------------------------------------------------------- read --
929
2362
 
930
2363
  /** What was said, and separately what can be trusted about it. Nothing a sender writes can set a field of ours. */
@@ -942,7 +2375,10 @@ export class NodeRuntime {
942
2375
  }
943
2376
  let text;
944
2377
  try {
945
- text = new TextDecoder("utf-8", { fatal: true }).decode(this.keys.open(message.from, raw));
2378
+ // The mark is kept: with a byte order mark in front the text is not
2379
+ // JSON, and aamio-python and aamio-php hand it over as text. Dropped,
2380
+ // this runtime alone read the rest as JSON, a channel in it included.
2381
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(this.keys.open(message.from, raw));
946
2382
  } catch (error) {
947
2383
  return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
948
2384
  }
@@ -967,10 +2403,12 @@ export class NodeRuntime {
967
2403
  const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
968
2404
  const known = this.nameForKey(sender);
969
2405
  const digest = checked.sha256 ?? null;
2406
+ const replay = replayState(channel, digest, sender);
970
2407
  const entry = {
971
2408
  channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
972
- sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: digest !== null && channel.seen.includes(digest), body, ...meta,
2409
+ sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: replay === true, body, ...meta,
973
2410
  };
2411
+ if (replay === null) entry.replay_unknown = true;
974
2412
  if (checked.whyNot) entry.unverified_because = checked.whyNot;
975
2413
  return entry;
976
2414
  } catch (error) {
@@ -979,31 +2417,6 @@ export class NodeRuntime {
979
2417
  }
980
2418
  }
981
2419
 
982
- /**
983
- * A scope in an incoming message, taken out before anyone reads it. Node
984
- * keeps no scopes yet, so a share is never kept here, and the key is never
985
- * handed on: aamio_scope is replaced, wherever it sits and whatever it holds,
986
- * by what aamio-python shows in its place, with why it was not kept.
987
- */
988
- takeScopeShare(entry) {
989
- const body = entry.body;
990
- if (!isObject(body)) return;
991
- if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
992
- const data = body.data;
993
- if (!isObject(data) || !("aamio_scope" in data)) return;
994
- const share = data.aamio_scope;
995
- if (!isObject(share)) {
996
- data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
997
- return;
998
- }
999
- data.aamio_scope = {
1000
- shared_as: typeof share.name === "string" ? share.name : null,
1001
- can_read: share.key !== undefined && share.key !== null,
1002
- kept: false,
1003
- note: "not kept: this Node runtime keeps no scopes yet. Ask the partner to share it again to a home run by aamio-python or aamio-php, which keep it.",
1004
- };
1005
- }
1006
-
1007
2420
  /**
1008
2421
  * What a verified message that names an address in this field would bind: its
1009
2422
  * signer's key to the address, once, or a conflict when the address is bound to
@@ -1032,8 +2445,7 @@ export class NodeRuntime {
1032
2445
  * one before it redelivers what the stored hashes then mark as replays.
1033
2446
  */
1034
2447
  async poll(channel, wait = 0, limit = null, maxBytes = null) {
1035
- if (channel.muted) return [];
1036
- const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], peers: { ...this.state.peers } };
2448
+ const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], seen_from: [...(channel.seen_from ?? [])], peers: { ...this.state.peers } };
1037
2449
  try {
1038
2450
  return await this.pollOnce(channel, wait, limit, true, maxBytes);
1039
2451
  } catch (error) {
@@ -1041,7 +2453,7 @@ export class NodeRuntime {
1041
2453
  // What this read changed is undone in memory too: the cursor, the hashes
1042
2454
  // and the bindings stay as the file has them, and the messages wait at
1043
2455
  // the service for the next read, which hands them over.
1044
- Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen });
2456
+ Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen, seen_from: before.seen_from });
1045
2457
  this.state.peers = before.peers;
1046
2458
  this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
1047
2459
  return [];
@@ -1144,7 +2556,7 @@ export class NodeRuntime {
1144
2556
  // is passed over by its hash, which is the hash of the text alone: another
1145
2557
  // sender's message with the same text has it too (the same review).
1146
2558
  if (!another && !data.reset && channel.after > 0 && typeof channel.instance !== "string" && instance !== null) {
1147
- this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, and nothing is passed over.`, channel.w);
2559
+ this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, or replay_unknown where an earlier version kept it without who sent it, and nothing is passed over.`, channel.w);
1148
2560
  channel.after = 0;
1149
2561
  return this.pollOnce(channel, 0, limit, false, maxBytes);
1150
2562
  }
@@ -1171,6 +2583,9 @@ export class NodeRuntime {
1171
2583
 
1172
2584
  const entries = [];
1173
2585
  const keptOut = [];
2586
+ // Messages signed by a key that has left the address book, on an inbox
2587
+ // that named it: passed by like the ones the list keeps out, and said.
2588
+ const withheld = [];
1174
2589
  let lastSeq = null;
1175
2590
  // The budget is kept here as well, for what is handed over, measured as it
1176
2591
  // is handed over. The service counts its own form of each message, which is
@@ -1182,18 +2597,31 @@ export class NodeRuntime {
1182
2597
  let used = 0;
1183
2598
  let stoppedAtBudget = false;
1184
2599
  let oversized = null;
2600
+ // What this read says of the messages it takes is said once the read is
2601
+ // written down. One that cannot be saved hands nothing over, and it said
2602
+ // replay_unknown for rows it never handed over: count 0 and no messages,
2603
+ // with a note on two of them (a review of 11 November 2026).
2604
+ const said = [];
2605
+ // What this read wrote to the archive, by delivery: kept in mind when the
2606
+ // cursor cannot be written, so the same rows are not written down again
2607
+ // when this process reads them again.
2608
+ const written = [];
1185
2609
  for (const raw of data.messages) {
1186
2610
  if (limit !== null && entries.length >= limit) break;
1187
2611
  const entry = this.readEntry(channel, raw);
1188
2612
  const allow = channel.allow || [];
1189
2613
  const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
2614
+ const gone = !excluded && entry.verified === true && this.leftTheBook(channel, entry.from_key);
1190
2615
  // What the message's addresses would bind is worked out before it is
1191
2616
  // weighed, so a conflict it carries is counted with it, and bound only when
1192
2617
  // it is taken: a conflict added after the weighing let 1146 bytes through a
1193
2618
  // budget of 900 (a review of 10 November 2026).
1194
2619
  const bindings = [];
1195
- if (!excluded) {
1196
- this.takeScopeShare(entry);
2620
+ // A scope in the message: its key comes out here, before the message is
2621
+ // weighed, observed or shown, and goes to scopes.json or nowhere.
2622
+ let share = null;
2623
+ if (!excluded && !gone) {
2624
+ share = this.scopeSharePlan(entry);
1197
2625
  if (isObject(entry.body) && entry.verified && entry.from_key) {
1198
2626
  for (const field of ["reply_to", "channel"]) {
1199
2627
  const planned = this.bindingFor(field, entry.body[field], entry.from_key);
@@ -1202,6 +2630,12 @@ export class NodeRuntime {
1202
2630
  const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
1203
2631
  if (conflicts.length) entry.binding_conflicts = conflicts;
1204
2632
  }
2633
+ // The archive's mark is weighed at its longer value, false, and with
2634
+ // archive_off where nothing is kept: a write that fails turns true
2635
+ // into false after the weighing, and a budget has to hold for what is
2636
+ // handed over. One that is written is a byte under.
2637
+ entry.archived = false;
2638
+ if (!this.archiveEnabled) entry.archive_off = true;
1205
2639
  if (maxBytes !== null) {
1206
2640
  const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
1207
2641
  if (entries.length > 0 && used + weight > maxBytes) {
@@ -1221,39 +2655,79 @@ export class NodeRuntime {
1221
2655
  keptOut.push(entry.seq);
1222
2656
  continue;
1223
2657
  }
1224
- if (entry.sha256 !== null && !channel.seen.includes(entry.sha256)) channel.seen.push(entry.sha256);
2658
+ if (gone) {
2659
+ withheld.push(entry.seq);
2660
+ continue;
2661
+ }
2662
+ if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
2663
+ if (share !== null) await this.scopeShareTake(entry, share, said);
1225
2664
  for (const planned of bindings) {
1226
2665
  if (planned.conflict === null) {
1227
2666
  this.state.peers[planned.address] = entry.from_key;
1228
2667
  } else {
1229
- this.note(channel.label, "binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`, channel.w);
2668
+ said.push(["binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`]);
2669
+ }
2670
+ }
2671
+ // Received, readable, archived and handled are four different things.
2672
+ // The message is written down as it is handed over, with the thread's
2673
+ // instance and without the archive's own mark, before the cursor is: a
2674
+ // process that stops between the two reads it again, and one that did
2675
+ // it the other way round passed it by with nothing kept. A write that
2676
+ // fails never costs the rest of the batch, and is said in attention,
2677
+ // not on the message, which has to be the size it was weighed at.
2678
+ const { archived: weighedAs, archive_off: off, ...row } = entry;
2679
+ row.kind = "received";
2680
+ if (typeof channel.instance === "string") row.instance = channel.instance;
2681
+ const delivery = this.deliveryOf(channel, entry);
2682
+ if (this.archiveEnabled) {
2683
+ if (delivery !== null && this.writtenDown.has(delivery)) {
2684
+ // Written down at a read whose cursor could not be saved, by this process.
2685
+ entry.archived = true;
2686
+ } else {
2687
+ try {
2688
+ entry.archived = (await this.archive(channel.label, row)) !== false;
2689
+ if (entry.archived && delivery !== null) written.push(delivery);
2690
+ } catch (error) {
2691
+ entry.archived = false;
2692
+ said.push(["archive_failed", `message ${entry.seq} was handed over and could not be written to the archive: ${error.code || error.name}: ${error.message}. The archive does not have it.`]);
2693
+ }
1230
2694
  }
1231
2695
  }
1232
2696
  entries.push(entry);
1233
2697
  }
2698
+ // Messages whose bytes this channel handed over before, which an earlier
2699
+ // version recorded without who sent them: whether each is a copy cannot be
2700
+ // told, and it is said rather than guessed.
2701
+ const unknown = entries.filter((entry) => entry.replay_unknown).map((entry) => entry.seq);
2702
+ if (unknown.length) {
2703
+ said.push(["replay_unknown", `message(s) ${unknown.join(", ")} carry the bytes of a message this channel handed over before, which an earlier version recorded without who sent it, so whether each is a copy cannot be told: each comes with replay false and replay_unknown true.`]);
2704
+ }
1234
2705
  if (keptOut.length) {
1235
2706
  const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
1236
- this.note(channel.label, "kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`, channel.w);
2707
+ said.push(["kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`]);
2708
+ }
2709
+ if (withheld.length) {
2710
+ said.push(["removed_key", `${withheld.length} message(s) (seq ${withheld.join(", ")}) are signed by a key that is no longer in the address book, on an inbox that was given to it. They are not handed over and not archived. Add the key as a partner again to take what it writes from then on.`]);
1237
2711
  }
1238
2712
  if (oversized) {
1239
- this.note(channel.label, "over_budget", `message ${oversized.seq} on this channel is ${oversized.weight} bytes as handed over here, larger than the whole byte budget of ${maxBytes} this read asked for. It had already come, so it is handed over on its own rather than kept back for ever, and this read is over its budget on purpose.`, channel.w);
2713
+ said.push(["over_budget", `message ${oversized.seq} on this channel is ${oversized.weight} bytes as handed over here, larger than the whole byte budget of ${maxBytes} this read asked for. It had already come, so it is handed over on its own rather than kept back for ever, and this read is over its budget on purpose.`]);
1240
2714
  }
1241
2715
  // Rows the service sent that this read left because its own limit was
1242
2716
  // reached: a service that does not take X-Limit sends them all, and the read
1243
2717
  // that stopped on its own said nothing (a review of 10 November 2026).
1244
- const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
2718
+ const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length + withheld.length;
1245
2719
  if (stoppedAtBudget) {
1246
- this.note(channel.label, "more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`, channel.w);
2720
+ said.push(["more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`]);
1247
2721
  } else if (leftHere || (data.more === true && !isObject(data.too_large))) {
1248
2722
  // The service says more both when the count this read asked for was
1249
2723
  // reached and when the byte budget was. A note that blamed a budget
1250
2724
  // nobody set would send the reader looking for the wrong cause.
1251
2725
  const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
1252
- this.note(channel.label, "more", atLimit
2726
+ said.push(["more", atLimit
1253
2727
  ? "this read handed over as many messages as its limit allows, and the service has more waiting on this channel. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."
1254
2728
  : maxBytes !== null
1255
2729
  ? "the service had more waiting on this channel than the byte budget this read asked for, so it sent what fits and kept the rest. Read again for it: the cursor stands at the last message handed over."
1256
- : "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.", channel.w);
2730
+ : "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."]);
1257
2731
  }
1258
2732
  // A read that stopped early moves the cursor to the last row it took and
1259
2733
  // no further: next would skip the rest of the page.
@@ -1262,7 +2736,28 @@ export class NodeRuntime {
1262
2736
  if (lastSeq !== null) channel.after = lastSeq;
1263
2737
  } else if (Number.isInteger(data.next)) channel.after = data.next;
1264
2738
  else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
1265
- await this.saveRead();
2739
+ try {
2740
+ await this.saveRead();
2741
+ } catch (error) {
2742
+ for (const delivery of written) this.writtenDown.add(delivery);
2743
+ throw error;
2744
+ }
2745
+ for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
2746
+ // The copies board replies keep, with the archive's mark and the thread's
2747
+ // instance, which the message handed over does not carry: one delivery is
2748
+ // known by its address, instance and seq in memory as it is in the archive.
2749
+ if (entries.length) {
2750
+ const held = this.received.get(channel.w) ?? [];
2751
+ for (const entry of entries) {
2752
+ held.push(typeof channel.instance === "string" ? { ...entry, instance: channel.instance } : { ...entry });
2753
+ this.writtenDown.delete(this.deliveryOf(channel, entry));
2754
+ }
2755
+ this.received.set(channel.w, held);
2756
+ }
2757
+ // The trace follows what was handed over: recorded after the read is
2758
+ // written down, in attention when it cannot be, and never on the messages,
2759
+ // which are the size they were weighed at.
2760
+ await this.traceRead(channel, entries);
1266
2761
  return entries;
1267
2762
  }
1268
2763
 
@@ -1362,6 +2857,10 @@ export class NodeRuntime {
1362
2857
  result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
1363
2858
  }
1364
2859
  }
2860
+ // Taken, and anchored when that was asked: a record of it that cannot be
2861
+ // written is said beside it.
2862
+ const archiveError = await this.archiveSafely(channel.label, { kind: "receipt", at: this.now(), result }, `the receipt for ${channel.label}`);
2863
+ if (archiveError !== null) result.archive_error = archiveError;
1365
2864
  return result;
1366
2865
  }
1367
2866
 
@@ -1370,6 +2869,7 @@ export class NodeRuntime {
1370
2869
  const noted = this.attention.length;
1371
2870
  await this.ensureInbox();
1372
2871
  await this.publishPresence();
2872
+ this.dropExpired();
1373
2873
  const now = this.now();
1374
2874
  // What was read of a thread is kept for its receipt while the receipt can
1375
2875
  // still be taken, and no longer: a server that ran for days held the rows
@@ -1378,7 +2878,8 @@ export class NodeRuntime {
1378
2878
  const known = this.state.channels.find((c) => isObject(c) && c.w === w);
1379
2879
  if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
1380
2880
  }
1381
- const channels = this.state.channels.filter((c) => isObject(c) && !c.muted && Number(c.expire_at) > now)
2881
+ // A muted inbox too: it is read until it expires, for the partners still in the book.
2882
+ const channels = this.state.channels.filter((c) => isObject(c) && Number(c.expire_at) > now)
1382
2883
  .sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
1383
2884
  const messages = [];
1384
2885
  // A channel this read had no room left to ask is named, as aamio-python