aamio 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,10 @@ hour, as partner add says in its answer. The board inbox takes any signed key, s
59
59
  a post has not been met. A read publishes where the inbox is for two
60
60
  minutes, and every message it hands over is checked on this machine: the hash,
61
61
  the signature over this address, and the seal. A message read before comes
62
- back marked `replay`, also after a restart. The server offers the same as
62
+ back marked `replay`, also after a restart. A cursor kept without the thread's
63
+ instance, by aamio 0.6.1 among others, has the thread read again from the start
64
+ once, said in `attention` as `reread`, and what came before comes marked
65
+ `replay`. The server offers the same as
63
66
  tools: `aamio_whoami`, `aamio_partners`, `aamio_partner_add`,
64
67
  `aamio_presence_lookup`, `aamio_send`, `aamio_read` and `aamio_pending`.
65
68
  `aamio_partner_add` takes a name from the agent and nothing else: the key comes
@@ -89,6 +92,37 @@ signed it, from any process. The server has the same as `aamio_board_post`,
89
92
  later milestones. The files are those aamio-python and aamio-php keep, so a
90
93
  home opens in any of the three.
91
94
 
95
+ A receipt says what a thread held: the hash, time and signer key of every
96
+ message, and one root. `receipt` takes it for a channel of the home, the inbox
97
+ unless `--channel` names another, and compares it with what this process read:
98
+
99
+ ```sh
100
+ npx aamio --home ./alice receipt # the inbox
101
+ npx aamio --home ./alice receipt --channel board --anchor
102
+ npx aamio --home ./alice read --max-bytes 65536 # whole messages, this many bytes of them
103
+ ```
104
+
105
+ `root_adds_up` says the receipt's own lines hash to its root.
106
+ `local_root_matches` compares it with the messages this process read on that
107
+ thread, and is `null`, which is not a failure, when the process holds fewer than
108
+ the receipt counts, as a new process that has read nothing does. A thread opened
109
+ again at the same address, after the service restarted, is another thread, and
110
+ nothing read on the old one counts for it. Take the receipt in the process that
111
+ read, the MCP server, for the comparison. An answer that is not a receipt for
112
+ the channel, for another address or with a count that is not its lines, is
113
+ refused before anything is signed or anchored. A signer is named only
114
+ when this process checked the signature; a key the service alone vouches for is
115
+ listed in `keys_service_claim_only`. The receipt is signed with the home's key as
116
+ it was fetched, which records what came back, not that it is true. `--anchor`
117
+ publishes the root through Verifyum to Solana, which leaves the machine and
118
+ cannot be undone. Its idempotency key is the one aamio-python and aamio-php use,
119
+ so one root anchored from any of the three is one proof. The server has the
120
+ receipt as `aamio_receipt`. A read takes `--max-bytes`, 512 or more, and `aamio_read`
121
+ reads 65536 bytes per channel unless told otherwise, measured as the messages
122
+ are handed over: whole messages only, and what does not fit stays at the
123
+ service and is said in `attention`. A message larger than the budget is named
124
+ with its size, or, when it has already come, handed over on its own and said.
125
+
92
126
  Run one server per home. On Windows and Linux, a running Node, Python or
93
127
  Composer client that owns the home blocks a second server. Stop the owner or
94
128
  choose a separate `--home DIR`. The operating system releases the lock if
@@ -179,7 +213,13 @@ that local policy. `keptOut` retains the reason a signature or hash failed.
179
213
  Without `onKeptOut`, `listen()` appends these entries to `thread.keptOut` and
180
214
  warns once per batch. With a callback, the caller owns that reporting and
181
215
  retention. `onGone` and `onReset` expose lost threads and cursor resets, with
182
- warnings by default. `decode(message)` without an address is legacy unchecked
216
+ warnings by default. `open()` keeps the `instance` the service names the thread
217
+ by, and `listen()` reads a thread opened again at its address from the start
218
+ and says it once through `onReset`, in each listener of the thread. A
219
+ listener knows the thread by the instance it had when `listen()` was called. A cursor
220
+ given with a thread that has no `instance`, as one stored by an earlier
221
+ version, is read again from the start once in the same way, and what came
222
+ before it may come again. `decode(message)` without an address is legacy unchecked
183
223
  decoding and returns `checked: false`. Use `read()` or provide the address to
184
224
  check authorship and destination binding.
185
225
 
package/bin/aamio.js CHANGED
@@ -17,8 +17,10 @@ const help = `Usage: aamio [--home DIR] <command>
17
17
  lookup [NAME ...] Which partners are reachable now, and where
18
18
  send TO TEXT [--data JSON] [--re SHA256]
19
19
  Send a partner a message, signed and sealed to its key
20
- read [--wait SECONDS] [--limit N]
20
+ read [--wait SECONDS] [--limit N] [--max-bytes N]
21
21
  Read the inbox: every message checked here first
22
+ receipt [--channel LABEL] [--anchor]
23
+ The receipt for a channel, compared with what this process read
22
24
  outbox pending Sends with no settled outcome on this machine
23
25
  board post KIND TITLE TEXT [--tags a,b] [--ttl SECONDS] [--lang TAG] [--deadline ISO]
24
26
  Put a need or an offer on the open board
@@ -43,7 +45,8 @@ const SPEC = {
43
45
  "partner list": { positional: [] },
44
46
  lookup: { positional: [], rest: true, options: ["--wait"] },
45
47
  send: { positional: ["to", "text"], options: ["--data", "--re"] },
46
- read: { positional: [], options: ["--wait", "--limit"] },
48
+ read: { positional: [], options: ["--wait", "--limit", "--max-bytes"] },
49
+ receipt: { positional: [], options: ["--channel"], switches: ["--anchor"] },
47
50
  "outbox pending": { positional: [] },
48
51
  "board post": { positional: ["kind", "title", "text"], options: ["--tags", "--ttl", "--lang", "--deadline"] },
49
52
  "board find": { positional: [], options: ["--kind", "--tags", "--lang", "--after", "--wait", "--min-work-bits"] },
@@ -84,6 +87,12 @@ function parse(argv) {
84
87
  ended = true;
85
88
  } else if (!ended && arg.startsWith("--")) {
86
89
  const [name, inline] = arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
90
+ // A switch is there or not, and takes no value: receipt --anchor.
91
+ if ((spec.switches ?? []).includes(name)) {
92
+ if (inline !== undefined) throw new Error(`${name} takes no value.`);
93
+ options[name.slice(2)] = true;
94
+ continue;
95
+ }
87
96
  if (!(spec.options ?? []).includes(name)) throw new Error(`${command} does not take ${name}. Run aamio --help for usage.`);
88
97
  const value = inline ?? args[++i];
89
98
  if (value === undefined) throw new Error(`${name} needs a value.`);
@@ -98,9 +107,14 @@ function parse(argv) {
98
107
  return { command, home: resolve(home ?? (process.env.AAMIO_HOME || join(homedir(), ".aamio"))), positional, options };
99
108
  }
100
109
 
101
- function whole(value, name, low, high) {
110
+ function whole(value, name, low, high = null) {
102
111
  if (value === undefined) return undefined;
103
- if (!/^[0-9]{1,6}$/.test(value) || Number(value) < low || Number(value) > high) throw Object.assign(new Error(`${name} is a whole number from ${low} to ${high}.`), { code: "usage" });
112
+ // Leading zeros are the number's own business: --limit 0001 was taken before
113
+ // 10 November 2026 and is again. The digits are capped only to keep the
114
+ // number exact; the bounds are checked on its value.
115
+ if (!/^[0-9]{1,15}$/.test(value) || Number(value) < low || (high !== null && Number(value) > high)) {
116
+ throw Object.assign(new Error(high === null ? `${name} is a whole number, ${low} or more.` : `${name} is a whole number from ${low} to ${high}.`), { code: "usage" });
117
+ }
104
118
  return Number(value);
105
119
  }
106
120
 
@@ -143,12 +157,19 @@ function plan(command, positional, options) {
143
157
  return (runtime) => runtime.send(first, { text: second, data, re: options.re ?? null });
144
158
  }
145
159
  case "read": {
146
- const asked = { wait: whole(options.wait, "--wait", 0, 25) ?? 0, limit: whole(options.limit, "--limit", 1, 200) ?? 50 };
160
+ // Without --max-bytes the command line asks for no byte budget, as aamio-python's does. With it,
161
+ // 512 or more, as over MCP: a budget above what the service takes is sent as the largest it takes.
162
+ const asked = { wait: whole(options.wait, "--wait", 0, 25) ?? 0, limit: whole(options.limit, "--limit", 1, 200) ?? 50, maxBytes: whole(options["max-bytes"], "--max-bytes", 512) ?? null };
147
163
  return async (runtime) => {
148
164
  const messages = await runtime.read(asked);
149
165
  return { messages, count: messages.length, attention: await runtime.attentionTaken() };
150
166
  };
151
167
  }
168
+ case "receipt": {
169
+ const label = options.channel ?? "inbox";
170
+ if (!label) throw Object.assign(new Error("--channel names a channel, as a message's channel field does: inbox, board or an older generation."), { code: "usage" });
171
+ return (runtime) => runtime.receipt(label, { anchor: options.anchor === true });
172
+ }
152
173
  case "outbox pending":
153
174
  return (runtime) => ({ pending: runtime.outboxPending() });
154
175
  case "board post": {
@@ -240,7 +261,7 @@ try {
240
261
  } else {
241
262
  const known = ["home_not_initialized", "key_unreadable", "home_init_failed", "home_in_use", "lock_unavailable", "state_unreadable", "invalid_name",
242
263
  "invalid_key", "invalid_re", "unknown_partner", "ambiguous_partner", "unknown_address", "partner_offline", "too_large", "inbox_unavailable", "runtime_closed",
243
- "board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved"];
264
+ "board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved", "unknown_channel", "receipt_failed"];
244
265
  process.stderr.write((known.includes(error?.code) ? error.message + (error.fix ? ` ${error.fix}` : "") : "The local Node runtime could not complete this command.") + "\n");
245
266
  process.exitCode = 1;
246
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "mcpName": "at.aamio/aamio",
5
5
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
6
6
  "type": "module",
package/src/aamio.d.ts CHANGED
@@ -97,6 +97,13 @@ export interface Thread {
97
97
  id: string;
98
98
  w: string;
99
99
  expireAt?: number;
100
+ /**
101
+ * The instance the service named the thread by, kept by open() and by listen(). A thread opened again at
102
+ * this address has another one, and listen() reads it from the start and says so once, as a reset; so does
103
+ * a listen() given a cursor and a thread without one, as one stored by an earlier version, since that
104
+ * cursor cannot be checked. Absent from a service that names none.
105
+ */
106
+ instance?: string;
100
107
  allow?: string[];
101
108
  /** The service's echo when it differs from the requested, locally kept list. null when absent. */
102
109
  allowAnswered?: unknown;
@@ -146,6 +153,8 @@ export interface ReadResult {
146
153
  exists: boolean;
147
154
  created_at?: number;
148
155
  expire_at?: number;
156
+ /** The thread's instance, from a service that names one: another one than before is another thread at this address. */
157
+ instance?: string;
149
158
  count: number;
150
159
  allow: string[];
151
160
  /** Checked here: verified and from are this client's result, not the service's word. */
package/src/aamio.js CHANGED
@@ -1191,6 +1191,9 @@ export class Aamio {
1191
1191
  const body = gate ? JSON.stringify({ gate }) : undefined;
1192
1192
  const data = await this.request("PUT", "/" + w, { headers, body });
1193
1193
  const thread = { id, w, expireAt: data.expire_at, allow };
1194
+ // The service names each thread by an instance of its own, and listen()
1195
+ // knows the thread by it.
1196
+ if (typeof data.instance === "string") thread.instance = data.instance;
1194
1197
  if (JSON.stringify(data.allow) !== JSON.stringify(allow)) thread.allowAnswered = data.allow ?? null;
1195
1198
  if (data.gate) thread.gate = data.gate;
1196
1199
  return thread;
@@ -1424,37 +1427,82 @@ export class Aamio {
1424
1427
  * Yield messages as they arrive, long polling with wait seconds per call,
1425
1428
  * until signal aborts or the thread answers 410 (then AamioError is thrown).
1426
1429
  */
1427
- async *listen(thread, { after = 0, wait = 25, signal, onKeptOut, onGone, onReset } = {}) {
1428
- let seq = after;
1429
- while (!(signal && signal.aborted)) {
1430
- const data = await this.read(thread, { after: seq, wait });
1431
- // What the thread's own allowlist kept out is not yielded, and it is said.
1432
- if (data.keptOut?.length) {
1433
- if (typeof onKeptOut === "function") onKeptOut(data.keptOut);
1434
- else {
1435
- (thread.keptOut ??= []).push(...data.keptOut);
1436
- console.warn("aamio: messages kept out", data.keptOut);
1430
+ listen(thread, { after = 0, wait = 25, signal, onKeptOut, onGone, onReset } = {}) {
1431
+ // The instance this listener last saw the thread by, its own. Two listeners
1432
+ // on one thread object shared the field on it: the first to see a new
1433
+ // thread wrote it, and the second went on past its first message (a review
1434
+ // of 10 November 2026). The field on the thread is kept as well, for a
1435
+ // caller that stores the thread. It is taken when listen() is called, not
1436
+ // when the first message is asked for: a generator starts at its first
1437
+ // next(), and a listener made before the thread changed, asked first after
1438
+ // another listener had written the new instance on the thread, took that
1439
+ // one as its own and went on past the new thread's first message (a review
1440
+ // of 11 November 2026).
1441
+ let known = typeof thread.instance === "string" ? thread.instance : null;
1442
+ const client = this;
1443
+ return (async function* () {
1444
+ let seq = after;
1445
+ while (!(signal && signal.aborted)) {
1446
+ const data = await client.read(thread, { after: seq, wait });
1447
+ // A thread is known by the instance the service names it by. One opened
1448
+ // again at this address, by its owner or by a write after a restart,
1449
+ // that already holds as many messages as the cursor is not reset by the
1450
+ // service, and listen went on from the old cursor past its first messages
1451
+ // without a word (a review of 10 November 2026). So it is read from the
1452
+ // start, and that is said once, as a reset. A cursor that came without
1453
+ // the instance, as with a thread stored by an earlier version, cannot be
1454
+ // checked at all, and is read again from the start once in the same way:
1455
+ // what came before it may come again (the same review).
1456
+ if (data.exists === true && typeof data.instance === "string") {
1457
+ if (data.instance !== known && !data.reset && seq > 0) {
1458
+ const what = known !== null
1459
+ ? `the thread at this address is a new one, instance ${data.instance} where this listener knew instance ${known}, so it is read again from the start`
1460
+ : `this listener's cursor, ${seq}, came without the thread's instance, so it cannot be told whether the thread here is the one it was kept on. It is read again from the start this once, and messages before the cursor may come again.`;
1461
+ const reset = { after: seq, newest: Number.isInteger(data.count) ? data.count : 0, what };
1462
+ known = data.instance;
1463
+ thread.instance = data.instance;
1464
+ if (Number.isInteger(data.expire_at)) thread.expireAt = data.expire_at;
1465
+ thread.reset = reset;
1466
+ client.forgetGate(thread.w);
1467
+ if (typeof onReset === "function") onReset(reset);
1468
+ else console.warn("aamio: the thread cursor was reset", reset);
1469
+ seq = 0;
1470
+ continue;
1471
+ }
1472
+ // The thread that is there, with its own expiry: after the service read
1473
+ // it from the start it may expire at another time than the one opened.
1474
+ if (known !== data.instance && Number.isInteger(data.expire_at)) thread.expireAt = data.expire_at;
1475
+ known = data.instance;
1476
+ thread.instance = data.instance;
1437
1477
  }
1478
+ // What the thread's own allowlist kept out is not yielded, and it is said.
1479
+ if (data.keptOut?.length) {
1480
+ if (typeof onKeptOut === "function") onKeptOut(data.keptOut);
1481
+ else {
1482
+ (thread.keptOut ??= []).push(...data.keptOut);
1483
+ console.warn("aamio: messages kept out", data.keptOut);
1484
+ }
1485
+ }
1486
+ if (data.exists === false) {
1487
+ if (typeof onGone === "function") onGone(data);
1488
+ else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
1489
+ thread.gone = true;
1490
+ } else if (data.exists === true) thread.gone = false;
1491
+ if (data.reset) {
1492
+ thread.reset = data.reset;
1493
+ client.forgetGate(thread.w);
1494
+ if (typeof onReset === "function") onReset(data.reset);
1495
+ else console.warn("aamio: the thread cursor was reset", data.reset);
1496
+ }
1497
+ for (const message of data.messages) yield message;
1498
+ // The service's next, not the highest seq seen. When a restart takes a
1499
+ // thread and a write opens a new one at the same address, the service
1500
+ // reads from the start and says reset, and next is lower than before.
1501
+ // Keeping the highest seq asked past the new thread on every call and
1502
+ // handed the same messages over each time.
1503
+ if (Number.isInteger(data.next)) seq = data.next;
1438
1504
  }
1439
- if (data.exists === false) {
1440
- if (typeof onGone === "function") onGone(data);
1441
- else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
1442
- thread.gone = true;
1443
- } else if (data.exists === true) thread.gone = false;
1444
- if (data.reset) {
1445
- thread.reset = data.reset;
1446
- this.forgetGate(thread.w);
1447
- if (typeof onReset === "function") onReset(data.reset);
1448
- else console.warn("aamio: the thread cursor was reset", data.reset);
1449
- }
1450
- for (const message of data.messages) yield message;
1451
- // The service's next, not the highest seq seen. When a restart takes a
1452
- // thread and a write opens a new one at the same address, the service
1453
- // reads from the start and says reset, and next is lower than before.
1454
- // Keeping the highest seq asked past the new thread on every call and
1455
- // handed the same messages over each time.
1456
- if (Number.isInteger(data.next)) seq = data.next;
1457
- }
1505
+ })();
1458
1506
  }
1459
1507
 
1460
1508
  /** The receipt of a thread you own, with its root recomputed locally. */
@@ -1497,14 +1545,32 @@ export class Aamio {
1497
1545
  headers: { "Content-Type": "application/json", Accept: "application/json", "MCP-Protocol-Version": "2025-11-25" },
1498
1546
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "verifyum_anchor_commitment", arguments: { commitment, idempotency_key: idem } } }),
1499
1547
  });
1500
- const rpc = await response.json();
1548
+ // The status first. A 503 with a proof_id in its body is no anchoring, and a
1549
+ // 503 in plain text is still a 503: the body used to be read as JSON before
1550
+ // the status was looked at (a review of 10 November 2026).
1551
+ // The status came before the body, and it is the answer's even when the body
1552
+ // breaks off: a 503 cut short was reported as 0 (a review of 10 November 2026).
1553
+ let text;
1554
+ try {
1555
+ text = await response.text();
1556
+ } catch (error) {
1557
+ throw new AamioError(response.status, String(error?.message ?? error), `anchor failed: Verifyum answered ${response.status} and the answer broke off`);
1558
+ }
1559
+ let rpc = null;
1560
+ try {
1561
+ rpc = JSON.parse(text);
1562
+ } catch {
1563
+ rpc = null;
1564
+ }
1565
+ if (!response.ok) throw new AamioError(response.status, rpc ?? text, `anchor failed: Verifyum answered ${response.status}`);
1501
1566
  let result = {};
1502
1567
  try {
1503
1568
  result = JSON.parse(rpc?.result?.content?.[0]?.text ?? "{}");
1504
1569
  } catch {
1505
1570
  result = {};
1506
1571
  }
1507
- if (rpc?.result?.isError || !result.proof_id) throw new AamioError(response.status, rpc, "anchor failed");
1572
+ // A proof_id of blanks names no proof (a review of 10 November 2026).
1573
+ if (rpc === null || typeof rpc !== "object" || rpc.result?.isError || typeof result?.proof_id !== "string" || result.proof_id.trim() === "") throw new AamioError(response.status, rpc ?? text, "anchor failed");
1508
1574
  return { proofId: result.proof_id, proofUrl: result.proof_url, status: result.status, commitment };
1509
1575
  }
1510
1576
 
package/src/node-mcp.js CHANGED
@@ -52,8 +52,12 @@ const RUNTIME_TOOLS = [
52
52
  },
53
53
  {
54
54
  name: "aamio_read",
55
- description: "New messages on your inbox. With wait, returns as soon as one arrives or after that many seconds (max 25). Each message says who signed it (a name from your address book, or unknown key), whether the signature verified (checked on this machine, not taken on the service's word), whether it was sealed to you, and whether it is a replay of one read before. What could not be read, or was kept out by the inbox's list, is in attention. No messages and no attention means nobody wrote. limit caps how many come back, fifty by default; the rest wait for the next call.",
56
- inputSchema: schema({ wait: { type: "integer", minimum: 0, maximum: 25 }, limit: { type: "integer", minimum: 1, maximum: 200 } }),
55
+ description: "New messages on your inbox. With wait, returns as soon as one arrives or after that many seconds (max 25). Each message says who signed it (a name from your address book, or unknown key), whether the signature verified (checked on this machine, not taken on the service's word), whether it was sealed to you, and whether it is a replay of one read before. What could not be read, or was kept out by the inbox's list, is in attention. No messages and no attention means nobody wrote. limit caps how many come back, fifty by default; the rest wait for the next call. max_bytes caps how many bytes of them, per channel, measured as they are handed over here, and defaults to 65536: one message's worth, so ordinary traffic never meets it and a backlog arrives a page at a time. Pass a larger number to take more in one go. A message bigger than the budget stays at the service, named in attention with its size, until a read with a larger budget takes it; one that has already come is handed over on its own, and attention says so.",
56
+ inputSchema: schema({
57
+ wait: { type: "integer", minimum: 0, maximum: 25 },
58
+ limit: { type: "integer", minimum: 1, maximum: 200, description: "at most this many messages in the answer" },
59
+ max_bytes: { type: "integer", minimum: 512, description: "how many bytes of messages to hand over, per channel, 65536 unless you say otherwise: a read across several channels can return that much from each, since a budget split between them would refuse a message that fits. Whole messages only, measured as they are handed over. A message larger than the budget stays at the service and is named in attention, or, when it has already come, is handed over on its own and said in attention." },
60
+ }),
57
61
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
58
62
  },
59
63
  {
@@ -100,6 +104,12 @@ const RUNTIME_TOOLS = [
100
104
  description: "Every tag in use on the board with live counts of needs and offers, dotted children under their branch. Use it to pick where to look before finding or watching.",
101
105
  inputSchema: schema(), annotations: { ...READ_ONLY, openWorldHint: true },
102
106
  },
107
+ {
108
+ name: "aamio_receipt",
109
+ description: "The receipt for one channel: hashes, times and signer keys of every message in it, and one root. channel is a local channel label, inbox by default, board for answers to your posts, or an older generation such as inbox-1800000000: take it from the channel field of the message you are working with. root_adds_up says the receipt's own lines hash to the root it claims; local_root_matches compares it to what this server read and is null when it holds fewer messages than the receipt counts, which is not a failure. The signer column is the service's record unless compared with locally verified messages. keys_unverified_count and keys_service_claim_only identify unchecked claims; they are never turned into contact names. Observations include kept-out messages. Fewer receipt lines than observed messages is a mismatch; local_differences names changed fields when counts match. Signing or anchoring a fetched root does not endorse its claims. A receipt does not prove that the other side read, understood or acted. With anchor, the root is published to Verifyum and anchored on Solana, which leaves this machine and cannot be undone.",
110
+ inputSchema: schema({ channel: { type: "string", description: "local channel label, as a message's channel field names it; defaults to inbox" }, anchor: { type: "boolean", description: "publish the root externally" } }),
111
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
112
+ },
103
113
  {
104
114
  name: "aamio_pending",
105
115
  description: "Messages this home sent whose outcome is not settled: in flight, or unknown because no answer came back. Unknown does not mean undelivered. Never compose a replacement for a message whose outcome is unsettled.",
@@ -201,6 +211,8 @@ async function handle(message, session) {
201
211
 
202
212
  const PARTNER_QUESTION = "aamio_partner_key";
203
213
  const CONFIRM_SECONDS = 900;
214
+ // What aamio_read hands over per channel when the caller names no budget: one message's maximum.
215
+ const DEFAULT_MAX_BYTES = 65536;
204
216
  const PARTNER_NAME = /^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,31}$/u;
205
217
  const CONFIRMATIONS = { secret: randomBytes(32), taken: new Map() };
206
218
 
@@ -414,6 +426,7 @@ function wrongArguments(tool, args) {
414
426
  if (!want) return `${tool.name} takes no argument called ${name}`;
415
427
  const ok = want.type === "string" ? typeof value === "string" && (!want.pattern || new RegExp(want.pattern).test(value)) && value.length >= (want.minLength ?? 0) && value.length <= (want.maxLength ?? Infinity) && (!want.enum || want.enum.includes(value))
416
428
  : want.type === "integer" ? Number.isInteger(value) && value >= (want.minimum ?? -Infinity) && value <= (want.maximum ?? Infinity)
429
+ : want.type === "boolean" ? typeof value === "boolean"
417
430
  : want.type === "object" ? object(value)
418
431
  : want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
419
432
  if (!ok) return `${name} is not what ${tool.name} takes: ${JSON.stringify(want)}`;
@@ -451,9 +464,11 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
451
464
  case "aamio_presence_lookup": return runtime.lookup(args.names ?? null, args.wait ?? 0);
452
465
  case "aamio_send": return runtime.send(args.to, { text: args.text ?? null, data: args.data ?? null, re: args.re ?? null });
453
466
  case "aamio_read": {
454
- const messages = await runtime.read({ wait: args.wait ?? 0, limit: args.limit ?? 50 });
467
+ // One message's maximum per channel unless the caller says otherwise, as aamio-python's server reads.
468
+ const messages = await runtime.read({ wait: args.wait ?? 0, limit: args.limit ?? 50, maxBytes: args.max_bytes ?? DEFAULT_MAX_BYTES });
455
469
  return { messages, count: messages.length, attention: await runtime.attentionTaken() };
456
470
  }
471
+ case "aamio_receipt": return runtime.receipt(args.channel ?? "inbox", { anchor: args.anchor === true });
457
472
  case "aamio_pending": return { pending: runtime.outboxPending() };
458
473
  case "aamio_board_post": return runtime.boardPost({ kind: args.kind, title: args.title, text: args.text, tags: args.tags ?? [], ttl: args.ttl ?? undefined, lang: args.lang ?? null, deadline: args.deadline ?? null });
459
474
  case "aamio_board_find": return runtime.boardFind({ kind: args.kind ?? null, tags: args.tags ?? null, lang: args.lang ?? null, after: args.after ?? 0, wait: args.wait ?? 0, min_work_bits: args.min_work_bits ?? 0 });
@@ -2,7 +2,7 @@ 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, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
5
+ import { Aamio, AamioError, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
6
6
  import { readKeys } from "./node-identity.js";
7
7
  import { takeHome } from "./node-lock.js";
8
8
 
@@ -31,6 +31,12 @@ export const ANSWER_MARGIN = 600;
31
31
  // the runtime before anything is saved over it: a save is how a broken file
32
32
  // becomes a lost one.
33
33
  const KEPT_FILES = ["partners.json", "state.json", "outbox.json"];
34
+ // The largest X-Max-Bytes the service takes: one to eight digits.
35
+ const MAX_BYTES_HEADER = 99_999_999;
36
+ // How long after a thread expires what this process read of it is kept for its
37
+ // receipt: the service keeps an expired thread for a best-effort minute of grace
38
+ // for receipts, and the margin is for clocks that differ.
39
+ const RECEIPT_KEEP = 120;
34
40
  // Answers that prove a message was not stored: refusals the service will give
35
41
  // again, and a rate window, which it decides before it reads the body.
36
42
  const SEND_DETERMINISTIC = [400, 403, 410, 413, 415, 422, 428, 501];
@@ -54,6 +60,41 @@ function runtimeError(code, message, extra = {}) {
54
60
  return Object.assign(new Error(message), { code, ...extra });
55
61
  }
56
62
 
63
+ /**
64
+ * Why a service's answer is not a receipt for the thread at w, or null when it
65
+ * is one: the v1 schema, the address that was asked for, its times, one line in
66
+ * order for every message it counts, and a root its commitment names. Checked
67
+ * before anything is compared, signed or anchored: a receipt for another address,
68
+ * and one that counted seven messages and listed one, were signed with
69
+ * root_adds_up true (a review of 10 November 2026).
70
+ */
71
+ function receiptFault(data, w) {
72
+ if (!isObject(data)) return "it is not an object";
73
+ if (data.schema !== "aamio-receipt-v1") return `its schema is ${JSON.stringify(data.schema ?? null)}, not aamio-receipt-v1`;
74
+ if (data.w !== w) return `it is for ${JSON.stringify(data.w ?? null)}, not for ${w}, the address asked for`;
75
+ for (const field of ["created_at", "expire_at", "issued_at"]) {
76
+ if (!Number.isInteger(data[field]) || data[field] < 0) return `its ${field} is not a time`;
77
+ }
78
+ if (!Array.isArray(data.messages)) return "it has no list of messages";
79
+ if (!Number.isInteger(data.count) || data.count !== data.messages.length) return `it counts ${JSON.stringify(data.count ?? null)} messages and has ${data.messages.length} lines`;
80
+ let previous = 0;
81
+ for (const [index, m] of data.messages.entries()) {
82
+ const fine = isObject(m) && Number.isInteger(m.seq) && m.seq > previous && Number.isInteger(m.at) && m.at >= 0
83
+ && typeof m.sha256 === "string" && HASH.test(m.sha256) && (m.from === undefined || m.from === null || typeof m.from === "string");
84
+ if (!fine) return `line ${index + 1} is not a message line in order`;
85
+ previous = m.seq;
86
+ }
87
+ if (typeof data.root !== "string" || !HASH.test(data.root)) return "its root is not a sha256 in hex";
88
+ if (data.commitment !== "sha256:" + data.root) return "its commitment is not sha256: and its root";
89
+ // The signers are the lines' own: the service lists each key that signed a
90
+ // line, once. One that left a signer out was signed as if nobody had signed
91
+ // (a review of 10 November 2026).
92
+ if (!Array.isArray(data.keys) || data.keys.some((key) => typeof key !== "string") || new Set(data.keys).size !== data.keys.length) return "its keys are not a list of keys";
93
+ const signers = new Set(data.messages.map((m) => m.from).filter((from) => typeof from === "string"));
94
+ if (data.keys.length !== signers.size || data.keys.some((key) => !signers.has(key))) return "its keys are not the signers of its lines";
95
+ return null;
96
+ }
97
+
57
98
  function isObject(value) {
58
99
  return value !== null && typeof value === "object" && !Array.isArray(value);
59
100
  }
@@ -76,10 +117,10 @@ function messageShape(body, envelope) {
76
117
 
77
118
  export class NodeRuntime {
78
119
  /** Takes the home, reads its key and its files. Close it to let go. */
79
- static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
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 } = {}) {
80
121
  const owner = await takeHome(home, { host });
81
122
  try {
82
- const runtime = new NodeRuntime({ home, host, board, keys: await readKeys(home), owner, fetch, clock });
123
+ const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock });
83
124
  await runtime.load();
84
125
  return runtime;
85
126
  } catch (error) {
@@ -88,9 +129,15 @@ export class NodeRuntime {
88
129
  }
89
130
  }
90
131
 
91
- constructor({ home, host, board = DEFAULT_BOARD, keys, owner, fetch, clock }) {
132
+ constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock }) {
92
133
  this.home = home;
93
134
  this.host = host.replace(/\/+$/, "");
135
+ // Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
136
+ this.verifyum = String(verifyum).replace(/\/+$/, "");
137
+ // What this process read of each thread, by write address and seq: the
138
+ // lines a receipt is compared with. In memory only, as in aamio-python, so
139
+ // a process that did not read a message cannot vouch for it.
140
+ this.observed = new Map();
94
141
  this.keys = keys;
95
142
  this.owner = owner;
96
143
  this.clock = clock;
@@ -405,7 +452,7 @@ export class NodeRuntime {
405
452
  const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
406
453
  const noted = this.attention.length;
407
454
  if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
408
- const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, gone: false, muted: false, seen: [] };
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: [] };
409
456
  this.state.channels.push(inbox);
410
457
  const muted = this.muteGenerations(reason);
411
458
  try {
@@ -759,7 +806,7 @@ export class NodeRuntime {
759
806
  }
760
807
  const label = held?.label;
761
808
  if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
762
- const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, gone: false, muted: false, seen: [] };
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: [] };
763
810
  this.state.channels.push(channel);
764
811
  try {
765
812
  await this.saveState();
@@ -957,17 +1004,26 @@ export class NodeRuntime {
957
1004
  };
958
1005
  }
959
1006
 
960
- /** A verified message that names an address binds its signer's key to it, once. A different key later is a conflict, and the binding stays. */
961
- bindClaimedAddress(channel, entry, field, address, senderKey) {
962
- if (typeof address !== "string" || !ADDRESS.test(address)) return;
1007
+ /**
1008
+ * What a verified message that names an address in this field would bind: its
1009
+ * signer's key to the address, once, or a conflict when the address is bound to
1010
+ * another key, and the binding stays. Nothing is changed here: the binding is
1011
+ * made when the message is taken.
1012
+ */
1013
+ bindingFor(field, address, senderKey) {
1014
+ if (typeof address !== "string" || !ADDRESS.test(address)) return null;
963
1015
  const bound = this.state.peers[address];
964
- if (bound === undefined) {
965
- this.state.peers[address] = senderKey;
966
- return;
967
- }
968
- if (bound === senderKey) return;
969
- (entry.binding_conflicts ??= []).push({ field, address, claimed_by: senderKey, bound_to: bound });
970
- this.note(channel.label, "binding_conflict", `message ${entry.seq} names ${address} as ${field}, signed by ${this.nameForKey(senderKey) ?? senderKey}, and that address is already bound to ${this.nameForKey(bound) ?? bound}. The binding is kept.`, channel.w);
1016
+ if (bound === undefined) return { field, address, conflict: null };
1017
+ if (bound === senderKey) return null;
1018
+ return { field, address, conflict: { field, address, claimed_by: senderKey, bound_to: bound } };
1019
+ }
1020
+
1021
+ /** The cursor and what was read belonged to a thread that is not there now. The hashes are the reader's and stay. */
1022
+ forgetThread(channel) {
1023
+ channel.after = 0;
1024
+ channel.created_at = null;
1025
+ channel.instance = null;
1026
+ this.observed.delete(channel.w);
971
1027
  }
972
1028
 
973
1029
  /**
@@ -975,17 +1031,17 @@ export class NodeRuntime {
975
1031
  * before the caller sees a message: a crash after that redelivers nothing, and
976
1032
  * one before it redelivers what the stored hashes then mark as replays.
977
1033
  */
978
- async poll(channel, wait = 0, limit = null) {
1034
+ async poll(channel, wait = 0, limit = null, maxBytes = null) {
979
1035
  if (channel.muted) return [];
980
- const before = { after: channel.after, created_at: channel.created_at, gone: channel.gone, seen: [...channel.seen], peers: { ...this.state.peers } };
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 } };
981
1037
  try {
982
- return await this.pollOnce(channel, wait, limit, true);
1038
+ return await this.pollOnce(channel, wait, limit, true, maxBytes);
983
1039
  } catch (error) {
984
1040
  if (!error?.notSaved) throw error;
985
1041
  // What this read changed is undone in memory too: the cursor, the hashes
986
1042
  // and the bindings stay as the file has them, and the messages wait at
987
1043
  // the service for the next read, which hands them over.
988
- Object.assign(channel, { after: before.after, created_at: before.created_at, gone: before.gone, seen: before.seen });
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 });
989
1045
  this.state.peers = before.peers;
990
1046
  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);
991
1047
  return [];
@@ -1000,12 +1056,39 @@ export class NodeRuntime {
1000
1056
  }
1001
1057
  }
1002
1058
 
1003
- async pollOnce(channel, wait, limit, again) {
1059
+ /**
1060
+ * One message this process read on a channel, kept for the receipt: the
1061
+ * service's line for it as this process saw it, and what was checked here.
1062
+ * The rows belong to the thread they were read on, the one opened at
1063
+ * created_at at this address. A thread opened at the same address after the
1064
+ * service restarted is another thread, and its receipt is never compared with
1065
+ * them (a review of 10 November 2026).
1066
+ */
1067
+ observe(channel, raw, entry) {
1068
+ if (!Number.isInteger(entry.seq) || entry.seq < 1) return;
1069
+ let held = this.observed.get(channel.w);
1070
+ const created = channel.created_at ?? null;
1071
+ const expires = Number.isInteger(channel.expire_at) ? channel.expire_at : null;
1072
+ const instance = typeof channel.instance === "string" ? channel.instance : null;
1073
+ if (!held || held.created_at !== created || held.expire_at !== expires || held.instance !== instance) {
1074
+ this.observed.set(channel.w, (held = { created_at: created, expire_at: expires, instance, rows: new Map() }));
1075
+ }
1076
+ const message = isObject(raw) ? raw : {};
1077
+ held.rows.set(entry.seq, { seq: entry.seq, at: entry.at, sha256: entry.sha256, from: typeof message.from === "string" ? message.from : null, verified: entry.verified === true, from_key: entry.from_key ?? null });
1078
+ }
1079
+
1080
+ async pollOnce(channel, wait, limit, again, maxBytes = null) {
1004
1081
  let path = "/" + channel.w;
1005
1082
  if (channel.after > 0 || wait > 0) path += "/after/" + channel.after;
1006
1083
  if (wait > 0) path += "/wait/" + Math.min(25, wait);
1007
1084
  const headers = { "X-Read": channel.read_key };
1008
1085
  if (limit !== null) headers["X-Limit"] = String(limit);
1086
+ // Whole messages only, and this many bytes of them at most: the service
1087
+ // keeps the rest, and says so with more or too_large. It takes the header
1088
+ // as at most eight digits and holds any budget to the thread's maximum, a
1089
+ // megabyte, so a larger one is sent as the largest it takes: nine digits
1090
+ // were answered 400 and the channel went unread.
1091
+ if (maxBytes !== null) headers["X-Max-Bytes"] = String(Math.min(maxBytes, MAX_BYTES_HEADER));
1009
1092
  let data;
1010
1093
  try {
1011
1094
  data = await this.client.request("GET", path, { headers, expect: [200] });
@@ -1025,39 +1108,126 @@ export class NodeRuntime {
1025
1108
  if (!channel.gone) this.note(channel.label, "gone", "there is no thread at this address any more: it expired and was swept, or the service restarted and it went with it" + (channel.label === "inbox" ? ", so a new inbox is opened for the partners" : ""), channel.w);
1026
1109
  channel.gone = true;
1027
1110
  // The cursor belonged to that thread. The hashes are the reader's and stay.
1028
- channel.after = 0;
1029
- channel.created_at = null;
1111
+ this.forgetThread(channel);
1030
1112
  await this.saveRead();
1031
1113
  return [];
1032
1114
  }
1033
1115
  channel.gone = false;
1034
1116
  const created = data.created_at ?? null;
1035
- if (again && channel.created_at !== null && created !== null && created !== channel.created_at && !data.reset) {
1036
- this.note(channel.label, "restarted", `the thread at this address is a new one, opened at ${created} where this channel knew one opened at ${channel.created_at}, so it is read again from the start`, channel.w);
1117
+ const expires = Number.isInteger(data.expire_at) ? data.expire_at : null;
1118
+ const instance = typeof data.instance === "string" ? data.instance : null;
1119
+ // A thread is known by its instance, which the service gives each thread
1120
+ // when it is opened: a thread opened again at the same address in the same
1121
+ // second, with the same lifetime and list, looked the same in every other
1122
+ // field, and a cursor taken from the old one passed over its first messages
1123
+ // (a review of 10 November 2026). A service that names no instance is read
1124
+ // by when the thread was opened and when it expires, as before.
1125
+ const known = typeof channel.instance === "string" && instance !== null;
1126
+ const another = known
1127
+ ? instance !== channel.instance
1128
+ : channel.created_at !== null && ((created !== null && created !== channel.created_at) || (expires !== null && Number.isInteger(channel.expire_at) && expires !== channel.expire_at));
1129
+ if (again && another && !data.reset) {
1130
+ this.note(channel.label, "restarted", known
1131
+ ? `the thread at this address is a new one, instance ${instance} where this channel knew instance ${channel.instance}, so it is read again from the start`
1132
+ : `the thread at this address is a new one, opened at ${created} and expiring at ${expires}, where this channel knew one opened at ${channel.created_at} and expiring at ${channel.expire_at}, so it is read again from the start`, channel.w);
1133
+ // What was read of the old thread is no line of the new one's receipt.
1134
+ this.forgetThread(channel);
1135
+ return this.pollOnce(channel, 0, limit, false, maxBytes);
1136
+ }
1137
+ // A cursor kept by a client that did not keep the thread's instance, aamio
1138
+ // 0.6.1 among them, cannot be checked against it: a thread opened again at
1139
+ // this address in the same second, with the same lifetime and list, looks
1140
+ // the same in every other field, and its first messages were passed over
1141
+ // without a word (a review of 10 November 2026). So the thread is read again
1142
+ // from the start, once, and all of it is handed over: what this channel
1143
+ // handed over before comes again marked replay, as after a restart. Nothing
1144
+ // is passed over by its hash, which is the hash of the text alone: another
1145
+ // sender's message with the same text has it too (the same review).
1146
+ 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);
1037
1148
  channel.after = 0;
1038
- channel.created_at = null;
1039
- return this.pollOnce(channel, 0, limit, false);
1149
+ return this.pollOnce(channel, 0, limit, false, maxBytes);
1150
+ }
1151
+ if (data.reset) {
1152
+ this.note(channel.label, "restarted", (isObject(data.reset) && data.reset.what) || "the service read this thread from the start", channel.w);
1153
+ this.observed.delete(channel.w);
1040
1154
  }
1041
- if (data.reset) this.note(channel.label, "restarted", (isObject(data.reset) && data.reset.what) || "the service read this thread from the start", channel.w);
1155
+ // The channel follows the thread that is there, all of it at once: one
1156
+ // taken up after its cursor was forgotten, or read from the start by the
1157
+ // service after a restart, may expire at another time than the one opened.
1158
+ // A channel that took the new instance and kept the old expiry chose an
1159
+ // inbox that had expired, and one that expired later was given up early
1160
+ // (a review of 10 November 2026). The list this channel was opened with is
1161
+ // its own, and stays.
1162
+ if ((channel.created_at === null || another || data.reset) && expires !== null) channel.expire_at = expires;
1042
1163
  channel.created_at = created;
1164
+ if (instance !== null) channel.instance = instance;
1165
+ // What the service kept back. It answers a byte budget with whole messages
1166
+ // only, so a message larger than the budget is named here and stays at the
1167
+ // service, with what came after it waiting behind it.
1168
+ if (isObject(data.too_large)) {
1169
+ this.note(channel.label, "too_large", `message ${data.too_large.seq} on this channel is ${data.too_large.bytes} bytes and does not fit the byte budget this read asked for, so it was not sent. It is still there, what was written after it waits behind it, and every read at this budget will leave it. Read again with a larger budget to take it: max_bytes over MCP, or --max-bytes on the command line.`, channel.w);
1170
+ }
1043
1171
 
1044
1172
  const entries = [];
1045
1173
  const keptOut = [];
1046
1174
  let lastSeq = null;
1175
+ // The budget is kept here as well, for what is handed over, measured as it
1176
+ // is handed over. The service counts its own form of each message, which is
1177
+ // not the size of the message opened here, and a service without read
1178
+ // limits sends everything: 512 bytes asked for, 628 handed over (a review of
1179
+ // 10 November 2026). A row that does not fit is not taken at all. It is not
1180
+ // observed for the receipt, not remembered as read and binds no address, and
1181
+ // the cursor stays before it, so the next read asks the service for it.
1182
+ let used = 0;
1183
+ let stoppedAtBudget = false;
1184
+ let oversized = null;
1047
1185
  for (const raw of data.messages) {
1048
1186
  if (limit !== null && entries.length >= limit) break;
1049
1187
  const entry = this.readEntry(channel, raw);
1050
- lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
1051
1188
  const allow = channel.allow || [];
1052
1189
  const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
1190
+ // What the message's addresses would bind is worked out before it is
1191
+ // weighed, so a conflict it carries is counted with it, and bound only when
1192
+ // it is taken: a conflict added after the weighing let 1146 bytes through a
1193
+ // budget of 900 (a review of 10 November 2026).
1194
+ const bindings = [];
1195
+ if (!excluded) {
1196
+ this.takeScopeShare(entry);
1197
+ if (isObject(entry.body) && entry.verified && entry.from_key) {
1198
+ for (const field of ["reply_to", "channel"]) {
1199
+ const planned = this.bindingFor(field, entry.body[field], entry.from_key);
1200
+ if (planned !== null) bindings.push(planned);
1201
+ }
1202
+ const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
1203
+ if (conflicts.length) entry.binding_conflicts = conflicts;
1204
+ }
1205
+ if (maxBytes !== null) {
1206
+ const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
1207
+ if (entries.length > 0 && used + weight > maxBytes) {
1208
+ stoppedAtBudget = true;
1209
+ break;
1210
+ }
1211
+ // Alone and still over the budget: it has come, so it is handed over
1212
+ // on its own rather than kept back for ever, and that is said.
1213
+ if (entries.length === 0 && weight > maxBytes) oversized = { seq: entry.seq, weight };
1214
+ used += weight;
1215
+ }
1216
+ }
1217
+ // Every message read counts for the receipt, the ones the list keeps out too.
1218
+ this.observe(channel, raw, entry);
1219
+ lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
1053
1220
  if (excluded) {
1054
1221
  keptOut.push(entry.seq);
1055
1222
  continue;
1056
1223
  }
1057
1224
  if (entry.sha256 !== null && !channel.seen.includes(entry.sha256)) channel.seen.push(entry.sha256);
1058
- this.takeScopeShare(entry);
1059
- if (isObject(entry.body) && entry.verified && entry.from_key) {
1060
- for (const field of ["reply_to", "channel"]) this.bindClaimedAddress(channel, entry, field, entry.body[field], entry.from_key);
1225
+ for (const planned of bindings) {
1226
+ if (planned.conflict === null) {
1227
+ this.state.peers[planned.address] = entry.from_key;
1228
+ } 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);
1230
+ }
1061
1231
  }
1062
1232
  entries.push(entry);
1063
1233
  }
@@ -1065,39 +1235,199 @@ export class NodeRuntime {
1065
1235
  const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
1066
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);
1067
1237
  }
1068
- const stoppedEarly = limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
1069
- if (stoppedEarly && lastSeq !== null) channel.after = lastSeq;
1070
- else if (Number.isInteger(data.next)) channel.after = data.next;
1238
+ 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);
1240
+ }
1241
+ // Rows the service sent that this read left because its own limit was
1242
+ // reached: a service that does not take X-Limit sends them all, and the read
1243
+ // 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;
1245
+ 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);
1247
+ } else if (leftHere || (data.more === true && !isObject(data.too_large))) {
1248
+ // The service says more both when the count this read asked for was
1249
+ // reached and when the byte budget was. A note that blamed a budget
1250
+ // nobody set would send the reader looking for the wrong cause.
1251
+ const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
1252
+ this.note(channel.label, "more", atLimit
1253
+ ? "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
+ : maxBytes !== null
1255
+ ? "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);
1257
+ }
1258
+ // A read that stopped early moves the cursor to the last row it took and
1259
+ // no further: next would skip the rest of the page.
1260
+ const stoppedEarly = stoppedAtBudget || leftHere;
1261
+ if (stoppedEarly) {
1262
+ if (lastSeq !== null) channel.after = lastSeq;
1263
+ } else if (Number.isInteger(data.next)) channel.after = data.next;
1071
1264
  else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
1072
1265
  await this.saveRead();
1073
1266
  return entries;
1074
1267
  }
1075
1268
 
1269
+ /**
1270
+ * The receipt for one channel, and two checks of it that are not one. Whether
1271
+ * its own lines hash to the root it claims can always be checked:
1272
+ * root_adds_up. Whether it agrees with what this process read can be, when
1273
+ * this process holds every message it counts: local_root_matches, null when
1274
+ * it holds fewer, which is not a failure, and false when it holds more. A
1275
+ * signer the service names is shown by name only when this process checked
1276
+ * that signature itself. The receipt is signed with this home's key as it
1277
+ * was fetched, which records what was taken, not that its claims are true.
1278
+ * With anchor, and only then, its root goes to Verifyum, with the same
1279
+ * idempotency key aamio-python and aamio-php use.
1280
+ */
1281
+ async receipt(label = "inbox", { anchor = false } = {}) {
1282
+ const channel = this.channel(String(label));
1283
+ if (!isObject(channel)) {
1284
+ const labels = this.state.channels.filter(isObject).map((c) => c.label);
1285
+ throw runtimeError("unknown_channel", `no channel called ${label} in this home. It has ${labels.length ? labels.join(", ") : "none yet: a read opens the inbox"}. A message names the channel it came on.`);
1286
+ }
1287
+ let data;
1288
+ try {
1289
+ ({ receipt: data } = await this.client.receipt({ id: channel.read_key, w: channel.w }));
1290
+ } catch (error) {
1291
+ throw runtimeError("receipt_failed", `the receipt could not be taken: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}`, { status: error.status ?? 0 });
1292
+ }
1293
+ // Checked as a receipt for this channel before anything is compared,
1294
+ // signed or anchored.
1295
+ const fault = receiptFault(data, channel.w);
1296
+ if (fault !== null) {
1297
+ throw runtimeError("receipt_failed", `the service's answer is not a receipt for this channel: ${fault}. Nothing was compared, signed or anchored.`);
1298
+ }
1299
+ // What this process read counts only for the thread it was read on. After
1300
+ // the service restarted, a new thread at the same address was compared with
1301
+ // what was read of the old one, and its signer named as checked here.
1302
+ const observed = this.observed.get(channel.w);
1303
+ // The same thread by its instance where the service names one, and by when
1304
+ // it was opened and expires where it does not.
1305
+ const sameThread = observed !== undefined && (typeof data.instance === "string" && observed.instance !== null
1306
+ ? observed.instance === data.instance
1307
+ : observed.created_at !== null && observed.created_at === data.created_at && (observed.expire_at === null || observed.expire_at === data.expire_at));
1308
+ const earlier = observed !== undefined && !sameThread && observed.rows.size > 0 ? observed : null;
1309
+ const entries = sameThread ? [...observed.rows.values()].sort((a, b) => a.seq - b.seq) : [];
1310
+ const which = (thread) => (typeof thread.instance === "string" ? `instance ${thread.instance}, ` : "the one ") + `opened at ${thread.created_at} and expiring at ${thread.expire_at}`;
1311
+ const held = entries.length;
1312
+ const line = (m) => `${m.seq}\t${m.at}\t${m.sha256}\t${m.from ?? "-"}\n`;
1313
+ const listed = data.messages.map(line).join("");
1314
+ const ours = entries.map(line).join("");
1315
+ const comparable = held === data.count;
1316
+ // A signer is named only for a row this process checked and the receipt
1317
+ // lists as it was read: the same seq, time, hash and signer. A row read on a
1318
+ // thread the fields cannot tell from this one is not taken for it (a review
1319
+ // of 10 November 2026).
1320
+ const listedBySeq = new Map(data.messages.map((m) => [m.seq, m]));
1321
+ const asListed = (e) => {
1322
+ const m = listedBySeq.get(e.seq);
1323
+ return m !== undefined && m.at === e.at && m.sha256 === e.sha256 && (m.from ?? null) === (e.from ?? null);
1324
+ };
1325
+ const verifiedKeys = new Set(entries.filter((e) => e.verified && typeof e.from_key === "string" && asListed(e)).map((e) => e.from_key));
1326
+ const claimed = Array.isArray(data.keys) ? data.keys : [];
1327
+ const unverified = claimed.filter((key) => typeof key !== "string" || !verifiedKeys.has(key));
1328
+ const attestation = `aamio-receipt-v1\n${channel.w}\n${data.root}\n${data.count}\n${data.issued_at ?? 0}`;
1329
+ const result = {
1330
+ label: channel.label, w: channel.w, root: data.root, commitment: data.commitment ?? null, count: data.count,
1331
+ keys: claimed.map((key) => (typeof key === "string" && verifiedKeys.has(key) ? this.nameForKey(key) ?? key : key)),
1332
+ keys_unverified_count: unverified.length, keys_service_claim_only: unverified,
1333
+ root_adds_up: sha256hex(listed) === data.root, held_locally: held,
1334
+ local_root_matches: comparable ? sha256hex(ours) === data.root : held > data.count ? false : null,
1335
+ signed_by: this.keys.public, signature: this.keys.sign(attestation), signed_text: attestation, receipt: data,
1336
+ };
1337
+ if (comparable) {
1338
+ const local = new Map(entries.map((e) => [e.seq, e]));
1339
+ result.local_differences = (data.messages ?? []).map((m) => {
1340
+ const seen = local.get(m.seq);
1341
+ return { seq: m.seq, fields: ["at", "sha256", "from"].filter((field) => !seen || (seen[field] ?? null) !== (m[field] ?? null)) };
1342
+ }).filter((difference) => difference.fields.length);
1343
+ result.signers_not_verified_locally = entries.filter((e) => e.from && !e.verified).map((e) => e.seq);
1344
+ }
1345
+ if (held > data.count) {
1346
+ result.local_check = "Mismatch: the receipt counts fewer messages than this process read. The thread was replaced or the service lost messages.";
1347
+ } else if (!comparable) {
1348
+ result.local_check = `Not compared: this process holds ${held} of the ${data.count} messages the receipt counts, so a local root would differ for a reason that is not the receipt's.`
1349
+ + (earlier !== null ? ` What this process read at this address was on another thread, ${which(earlier)}; this receipt is for ${which(data)}, so none of that counts here.` : "")
1350
+ + " root_adds_up checks only the receipt's arithmetic; our signature records what was fetched, not that its claims are true. For the independent check, take the receipt in the process that read the messages.";
1351
+ }
1352
+ if (anchor === true) {
1353
+ const idempotencyKey = sha256hex(`aamio-listen:${this.keys.hash}:${data.root}`).slice(0, 32);
1354
+ try {
1355
+ const proof = await this.client.anchor(`sha256:${data.root}`, { endpoint: this.verifyum, idempotencyKey });
1356
+ result.anchor = { proof_id: proof.proofId, proof_url: proof.proofUrl ?? null, status: proof.status ?? null, commitment: proof.commitment };
1357
+ } catch (error) {
1358
+ // The receipt stands; only the anchoring failed, and it says how: the
1359
+ // HTTP status, 0 when nothing answered, and Verifyum's own words when
1360
+ // its tool answered with an error.
1361
+ const said = error.body?.result?.content?.[0]?.text;
1362
+ result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
1363
+ }
1364
+ }
1365
+ return result;
1366
+ }
1367
+
1076
1368
  /** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
1077
- async read({ wait = 0, limit = 50 } = {}) {
1369
+ async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
1370
+ const noted = this.attention.length;
1078
1371
  await this.ensureInbox();
1079
1372
  await this.publishPresence();
1080
1373
  const now = this.now();
1374
+ // What was read of a thread is kept for its receipt while the receipt can
1375
+ // still be taken, and no longer: a server that ran for days held the rows
1376
+ // of every inbox it had read (a review of 10 November 2026).
1377
+ for (const w of [...this.observed.keys()]) {
1378
+ const known = this.state.channels.find((c) => isObject(c) && c.w === w);
1379
+ if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
1380
+ }
1081
1381
  const channels = this.state.channels.filter((c) => isObject(c) && !c.muted && Number(c.expire_at) > now)
1082
1382
  .sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
1083
1383
  const messages = [];
1384
+ // A channel this read had no room left to ask is named, as aamio-python
1385
+ // names it: what waits there was not looked at, and a read that stopped at
1386
+ // its limit is not a read of everything.
1387
+ const notAsked = [];
1388
+ const notAskedAfter = [];
1084
1389
  // Every channel without waiting first: a message on an older inbox that
1085
1390
  // is still read is handed over at once, not after a wait on the new one.
1391
+ // The byte budget is per channel, as in aamio-python: one split between
1392
+ // them would refuse a message that fits.
1086
1393
  for (const channel of channels) {
1087
- if (messages.length >= limit) break;
1088
- messages.push(...(await this.poll(channel, 0, limit - messages.length)));
1394
+ if (messages.length >= limit) {
1395
+ notAsked.push(channel);
1396
+ continue;
1397
+ }
1398
+ messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
1089
1399
  }
1090
1400
  const inbox = this.channel("inbox");
1091
1401
  if (!messages.length && wait > 0 && inbox && !inbox.muted) {
1092
- messages.push(...(await this.poll(inbox, wait, limit)));
1402
+ messages.push(...(await this.poll(inbox, wait, limit, maxBytes)));
1093
1403
  // What reached the other channels during the wait comes with this
1094
1404
  // answer, as in aamio-python: only the inbox can end the wait early. It
1095
1405
  // used to wait for the next read (a review of 9 November 2026).
1096
1406
  for (const channel of channels) {
1097
- if (channel === inbox || messages.length >= limit) continue;
1098
- messages.push(...(await this.poll(channel, 0, limit - messages.length)));
1407
+ if (channel === inbox) continue;
1408
+ if (messages.length >= limit) {
1409
+ notAskedAfter.push(channel);
1410
+ continue;
1411
+ }
1412
+ messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
1099
1413
  }
1100
1414
  }
1415
+ for (const channel of notAsked) {
1416
+ this.note(channel.label, "more", `this read reached its limit of ${limit} message(s) before it asked this channel, so what waits here was not looked at. Nothing was passed over: read again for it.`, channel.w);
1417
+ }
1418
+ for (const channel of notAskedAfter) {
1419
+ this.note(channel.label, "more", `this read reached its limit of ${limit} message(s) during the wait, so this channel was asked before the wait and not after it. What reached it meanwhile comes with the next read.`, channel.w);
1420
+ }
1421
+ // A read says a thing once. A channel asked before a wait and in it gave
1422
+ // the same too_large twice (a review of 10 November 2026).
1423
+ const said = new Set();
1424
+ const fresh = this.attention.slice(noted).filter((note) => {
1425
+ const key = `${note.w}\u0000${note.state}\u0000${note.what}`;
1426
+ if (said.has(key)) return false;
1427
+ said.add(key);
1428
+ return true;
1429
+ });
1430
+ this.attention.splice(noted, this.attention.length - noted, ...fresh);
1101
1431
  return messages;
1102
1432
  }
1103
1433
  }