aamio 0.6.1 → 0.6.3

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
@@ -58,8 +58,17 @@ for the partners, and the open one is still read until it expires, within the
58
58
  hour, as partner add says in its answer. The board inbox takes any signed key, since whoever answers
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
- 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
61
+ the signature over this address, and the seal. A message read before, the same
62
+ bytes from the same signer, comes back marked `replay`, also after a restart;
63
+ another signer's message with the same text does not. Bytes an earlier version
64
+ kept without who sent them come with `replay_unknown: true`, said in
65
+ `attention`, until their signer is recorded. Where `replay` decides what is
66
+ done, upgrade every client that reads one home together: an earlier version
67
+ that reads in between records no signer for what it hands over, and a copy from
68
+ that signer can then come with `replay: false`. A cursor kept without the thread's
69
+ instance, by aamio 0.6.1 among others, has the thread read again from the start
70
+ once, said in `attention` as `reread`, and what came before comes marked
71
+ `replay`. The server offers the same as
63
72
  tools: `aamio_whoami`, `aamio_partners`, `aamio_partner_add`,
64
73
  `aamio_presence_lookup`, `aamio_send`, `aamio_read` and `aamio_pending`.
65
74
  `aamio_partner_add` takes a name from the agent and nothing else: the key comes
@@ -89,6 +98,37 @@ signed it, from any process. The server has the same as `aamio_board_post`,
89
98
  later milestones. The files are those aamio-python and aamio-php keep, so a
90
99
  home opens in any of the three.
91
100
 
101
+ A receipt says what a thread held: the hash, time and signer key of every
102
+ message, and one root. `receipt` takes it for a channel of the home, the inbox
103
+ unless `--channel` names another, and compares it with what this process read:
104
+
105
+ ```sh
106
+ npx aamio --home ./alice receipt # the inbox
107
+ npx aamio --home ./alice receipt --channel board --anchor
108
+ npx aamio --home ./alice read --max-bytes 65536 # whole messages, this many bytes of them
109
+ ```
110
+
111
+ `root_adds_up` says the receipt's own lines hash to its root.
112
+ `local_root_matches` compares it with the messages this process read on that
113
+ thread, and is `null`, which is not a failure, when the process holds fewer than
114
+ the receipt counts, as a new process that has read nothing does. A thread opened
115
+ again at the same address, after the service restarted, is another thread, and
116
+ nothing read on the old one counts for it. Take the receipt in the process that
117
+ read, the MCP server, for the comparison. An answer that is not a receipt for
118
+ the channel, for another address or with a count that is not its lines, is
119
+ refused before anything is signed or anchored. A signer is named only
120
+ when this process checked the signature; a key the service alone vouches for is
121
+ listed in `keys_service_claim_only`. The receipt is signed with the home's key as
122
+ it was fetched, which records what came back, not that it is true. `--anchor`
123
+ publishes the root through Verifyum to Solana, which leaves the machine and
124
+ cannot be undone. Its idempotency key is the one aamio-python and aamio-php use,
125
+ so one root anchored from any of the three is one proof. The server has the
126
+ receipt as `aamio_receipt`. A read takes `--max-bytes`, 512 or more, and `aamio_read`
127
+ reads 65536 bytes per channel unless told otherwise, measured as the messages
128
+ are handed over: whole messages only, and what does not fit stays at the
129
+ service and is said in `attention`. A message larger than the budget is named
130
+ with its size, or, when it has already come, handed over on its own and said.
131
+
92
132
  Run one server per home. On Windows and Linux, a running Node, Python or
93
133
  Composer client that owns the home blocks a second server. Stop the owner or
94
134
  choose a separate `--home DIR`. The operating system releases the lock if
@@ -179,7 +219,13 @@ that local policy. `keptOut` retains the reason a signature or hash failed.
179
219
  Without `onKeptOut`, `listen()` appends these entries to `thread.keptOut` and
180
220
  warns once per batch. With a callback, the caller owns that reporting and
181
221
  retention. `onGone` and `onReset` expose lost threads and cursor resets, with
182
- warnings by default. `decode(message)` without an address is legacy unchecked
222
+ warnings by default. `open()` keeps the `instance` the service names the thread
223
+ by, and `listen()` reads a thread opened again at its address from the start
224
+ and says it once through `onReset`, in each listener of the thread. A
225
+ listener knows the thread by the instance it had when `listen()` was called. A cursor
226
+ given with a thread that has no `instance`, as one stored by an earlier
227
+ version, is read again from the start once in the same way, and what came
228
+ before it may come again. `decode(message)` without an address is legacy unchecked
183
229
  decoding and returns `checked: false`. Use `read()` or provide the address to
184
230
  check authorship and destination binding.
185
231
 
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.3",
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,11 +31,68 @@ 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];
37
43
  const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
38
44
  const HASH = /^[0-9a-f]{64}$/;
45
+ // One message a channel handed over, as the channel remembers it: the hash of
46
+ // its bytes and who signed them, the first 16 hex of sha256 of the key, or -
47
+ // when nobody did. The hash alone is of the text, and two signers who both sent
48
+ // "OK" made the second a replay of the first (a review of 10 November 2026).
49
+ // aamio-python and aamio-php write the same records, so a home reads the same.
50
+ // ? stands beside a hash an earlier version kept without its signer, once that
51
+ // hash is met again: who sent it then is not known, and stays not known.
52
+ const SEEN_FROM = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
53
+
54
+ /** The record of a message handed over: its hash, and the signer it was verified from, or -. */
55
+ export function seenRecord(digest, fromKey) {
56
+ return `${digest}:${typeof fromKey === "string" && fromKey ? sha256hex(fromKey).slice(0, 16) : "-"}`;
57
+ }
58
+
59
+ /**
60
+ * Whether this channel handed these bytes over before from this signer, or
61
+ * unsigned when they are unsigned: true when it did, and false when it did not,
62
+ * since bytes it handed over from another signer make this another message.
63
+ * null when it cannot be told: an earlier version kept the hash without its
64
+ * signer, and this signer has no record of its own. Taken as a copy, another
65
+ * signer's first answer with the same text was passed over, and taken as new,
66
+ * the same signer's copy was acted on twice (a review of 11 November 2026), so
67
+ * it is said as it is.
68
+ */
69
+ export function replayState(channel, digest, fromKey) {
70
+ if (digest === null || digest === undefined) return false;
71
+ const records = Array.isArray(channel.seen_from) ? channel.seen_from : [];
72
+ if (records.includes(seenRecord(digest, fromKey))) return true;
73
+ const mine = records.filter((record) => record.startsWith(digest + ":"));
74
+ if (mine.includes(digest + ":?") || (mine.length === 0 && channel.seen.includes(digest))) return null;
75
+ return false;
76
+ }
77
+
78
+ /** Whether this is known to be a copy: replayState, with not known taken as no. */
79
+ export function isReplay(channel, digest, fromKey) {
80
+ return replayState(channel, digest, fromKey) === true;
81
+ }
82
+
83
+ /**
84
+ * A message handed over: its hash, and who it came from. A hash an earlier
85
+ * version kept without its signer keeps that, as ?, beside the first signer
86
+ * recorded for it, so the next signer with the same bytes is not taken for a
87
+ * new one with certainty.
88
+ */
89
+ export function remember(channel, digest, fromKey) {
90
+ if (!Array.isArray(channel.seen_from)) channel.seen_from = [];
91
+ if (channel.seen.includes(digest) && !channel.seen_from.some((record) => record.startsWith(digest + ":"))) channel.seen_from.push(digest + ":?");
92
+ if (!channel.seen.includes(digest)) channel.seen.push(digest);
93
+ const record = seenRecord(digest, fromKey);
94
+ if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
95
+ }
39
96
  const ADDRESS = /^[a-z2-7]{20}$/;
40
97
  const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/;
41
98
 
@@ -54,6 +111,41 @@ function runtimeError(code, message, extra = {}) {
54
111
  return Object.assign(new Error(message), { code, ...extra });
55
112
  }
56
113
 
114
+ /**
115
+ * Why a service's answer is not a receipt for the thread at w, or null when it
116
+ * is one: the v1 schema, the address that was asked for, its times, one line in
117
+ * order for every message it counts, and a root its commitment names. Checked
118
+ * before anything is compared, signed or anchored: a receipt for another address,
119
+ * and one that counted seven messages and listed one, were signed with
120
+ * root_adds_up true (a review of 10 November 2026).
121
+ */
122
+ function receiptFault(data, w) {
123
+ if (!isObject(data)) return "it is not an object";
124
+ if (data.schema !== "aamio-receipt-v1") return `its schema is ${JSON.stringify(data.schema ?? null)}, not aamio-receipt-v1`;
125
+ if (data.w !== w) return `it is for ${JSON.stringify(data.w ?? null)}, not for ${w}, the address asked for`;
126
+ for (const field of ["created_at", "expire_at", "issued_at"]) {
127
+ if (!Number.isInteger(data[field]) || data[field] < 0) return `its ${field} is not a time`;
128
+ }
129
+ if (!Array.isArray(data.messages)) return "it has no list of messages";
130
+ 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`;
131
+ let previous = 0;
132
+ for (const [index, m] of data.messages.entries()) {
133
+ const fine = isObject(m) && Number.isInteger(m.seq) && m.seq > previous && Number.isInteger(m.at) && m.at >= 0
134
+ && typeof m.sha256 === "string" && HASH.test(m.sha256) && (m.from === undefined || m.from === null || typeof m.from === "string");
135
+ if (!fine) return `line ${index + 1} is not a message line in order`;
136
+ previous = m.seq;
137
+ }
138
+ if (typeof data.root !== "string" || !HASH.test(data.root)) return "its root is not a sha256 in hex";
139
+ if (data.commitment !== "sha256:" + data.root) return "its commitment is not sha256: and its root";
140
+ // The signers are the lines' own: the service lists each key that signed a
141
+ // line, once. One that left a signer out was signed as if nobody had signed
142
+ // (a review of 10 November 2026).
143
+ 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";
144
+ const signers = new Set(data.messages.map((m) => m.from).filter((from) => typeof from === "string"));
145
+ if (data.keys.length !== signers.size || data.keys.some((key) => !signers.has(key))) return "its keys are not the signers of its lines";
146
+ return null;
147
+ }
148
+
57
149
  function isObject(value) {
58
150
  return value !== null && typeof value === "object" && !Array.isArray(value);
59
151
  }
@@ -76,10 +168,10 @@ function messageShape(body, envelope) {
76
168
 
77
169
  export class NodeRuntime {
78
170
  /** 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 } = {}) {
171
+ 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
172
  const owner = await takeHome(home, { host });
81
173
  try {
82
- const runtime = new NodeRuntime({ home, host, board, keys: await readKeys(home), owner, fetch, clock });
174
+ const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock });
83
175
  await runtime.load();
84
176
  return runtime;
85
177
  } catch (error) {
@@ -88,9 +180,15 @@ export class NodeRuntime {
88
180
  }
89
181
  }
90
182
 
91
- constructor({ home, host, board = DEFAULT_BOARD, keys, owner, fetch, clock }) {
183
+ constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock }) {
92
184
  this.home = home;
93
185
  this.host = host.replace(/\/+$/, "");
186
+ // Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
187
+ this.verifyum = String(verifyum).replace(/\/+$/, "");
188
+ // What this process read of each thread, by write address and seq: the
189
+ // lines a receipt is compared with. In memory only, as in aamio-python, so
190
+ // a process that did not read a message cannot vouch for it.
191
+ this.observed = new Map();
94
192
  this.keys = keys;
95
193
  this.owner = owner;
96
194
  this.clock = clock;
@@ -161,6 +259,7 @@ export class NodeRuntime {
161
259
  for (const channel of this.state.channels) {
162
260
  if (!isObject(channel)) continue;
163
261
  channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
262
+ channel.seen_from = Array.isArray(channel.seen_from) ? channel.seen_from.filter((value) => typeof value === "string" && SEEN_FROM.test(value)) : [];
164
263
  channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
165
264
  if (!Number.isInteger(channel.after)) channel.after = 0;
166
265
  }
@@ -405,7 +504,7 @@ export class NodeRuntime {
405
504
  const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
406
505
  const noted = this.attention.length;
407
506
  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: [] };
507
+ 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: [] };
409
508
  this.state.channels.push(inbox);
410
509
  const muted = this.muteGenerations(reason);
411
510
  try {
@@ -759,7 +858,7 @@ export class NodeRuntime {
759
858
  }
760
859
  const label = held?.label;
761
860
  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: [] };
861
+ 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: [] };
763
862
  this.state.channels.push(channel);
764
863
  try {
765
864
  await this.saveState();
@@ -920,10 +1019,12 @@ export class NodeRuntime {
920
1019
  const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
921
1020
  const known = this.nameForKey(sender);
922
1021
  const digest = checked.sha256 ?? null;
1022
+ const replay = replayState(channel, digest, sender);
923
1023
  const entry = {
924
1024
  channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
925
- sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: digest !== null && channel.seen.includes(digest), body, ...meta,
1025
+ sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: replay === true, body, ...meta,
926
1026
  };
1027
+ if (replay === null) entry.replay_unknown = true;
927
1028
  if (checked.whyNot) entry.unverified_because = checked.whyNot;
928
1029
  return entry;
929
1030
  } catch (error) {
@@ -957,17 +1058,26 @@ export class NodeRuntime {
957
1058
  };
958
1059
  }
959
1060
 
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;
1061
+ /**
1062
+ * What a verified message that names an address in this field would bind: its
1063
+ * signer's key to the address, once, or a conflict when the address is bound to
1064
+ * another key, and the binding stays. Nothing is changed here: the binding is
1065
+ * made when the message is taken.
1066
+ */
1067
+ bindingFor(field, address, senderKey) {
1068
+ if (typeof address !== "string" || !ADDRESS.test(address)) return null;
963
1069
  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);
1070
+ if (bound === undefined) return { field, address, conflict: null };
1071
+ if (bound === senderKey) return null;
1072
+ return { field, address, conflict: { field, address, claimed_by: senderKey, bound_to: bound } };
1073
+ }
1074
+
1075
+ /** The cursor and what was read belonged to a thread that is not there now. The hashes are the reader's and stay. */
1076
+ forgetThread(channel) {
1077
+ channel.after = 0;
1078
+ channel.created_at = null;
1079
+ channel.instance = null;
1080
+ this.observed.delete(channel.w);
971
1081
  }
972
1082
 
973
1083
  /**
@@ -975,17 +1085,17 @@ export class NodeRuntime {
975
1085
  * before the caller sees a message: a crash after that redelivers nothing, and
976
1086
  * one before it redelivers what the stored hashes then mark as replays.
977
1087
  */
978
- async poll(channel, wait = 0, limit = null) {
1088
+ async poll(channel, wait = 0, limit = null, maxBytes = null) {
979
1089
  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 } };
1090
+ 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 } };
981
1091
  try {
982
- return await this.pollOnce(channel, wait, limit, true);
1092
+ return await this.pollOnce(channel, wait, limit, true, maxBytes);
983
1093
  } catch (error) {
984
1094
  if (!error?.notSaved) throw error;
985
1095
  // What this read changed is undone in memory too: the cursor, the hashes
986
1096
  // and the bindings stay as the file has them, and the messages wait at
987
1097
  // 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 });
1098
+ 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 });
989
1099
  this.state.peers = before.peers;
990
1100
  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
1101
  return [];
@@ -1000,12 +1110,39 @@ export class NodeRuntime {
1000
1110
  }
1001
1111
  }
1002
1112
 
1003
- async pollOnce(channel, wait, limit, again) {
1113
+ /**
1114
+ * One message this process read on a channel, kept for the receipt: the
1115
+ * service's line for it as this process saw it, and what was checked here.
1116
+ * The rows belong to the thread they were read on, the one opened at
1117
+ * created_at at this address. A thread opened at the same address after the
1118
+ * service restarted is another thread, and its receipt is never compared with
1119
+ * them (a review of 10 November 2026).
1120
+ */
1121
+ observe(channel, raw, entry) {
1122
+ if (!Number.isInteger(entry.seq) || entry.seq < 1) return;
1123
+ let held = this.observed.get(channel.w);
1124
+ const created = channel.created_at ?? null;
1125
+ const expires = Number.isInteger(channel.expire_at) ? channel.expire_at : null;
1126
+ const instance = typeof channel.instance === "string" ? channel.instance : null;
1127
+ if (!held || held.created_at !== created || held.expire_at !== expires || held.instance !== instance) {
1128
+ this.observed.set(channel.w, (held = { created_at: created, expire_at: expires, instance, rows: new Map() }));
1129
+ }
1130
+ const message = isObject(raw) ? raw : {};
1131
+ 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 });
1132
+ }
1133
+
1134
+ async pollOnce(channel, wait, limit, again, maxBytes = null) {
1004
1135
  let path = "/" + channel.w;
1005
1136
  if (channel.after > 0 || wait > 0) path += "/after/" + channel.after;
1006
1137
  if (wait > 0) path += "/wait/" + Math.min(25, wait);
1007
1138
  const headers = { "X-Read": channel.read_key };
1008
1139
  if (limit !== null) headers["X-Limit"] = String(limit);
1140
+ // Whole messages only, and this many bytes of them at most: the service
1141
+ // keeps the rest, and says so with more or too_large. It takes the header
1142
+ // as at most eight digits and holds any budget to the thread's maximum, a
1143
+ // megabyte, so a larger one is sent as the largest it takes: nine digits
1144
+ // were answered 400 and the channel went unread.
1145
+ if (maxBytes !== null) headers["X-Max-Bytes"] = String(Math.min(maxBytes, MAX_BYTES_HEADER));
1009
1146
  let data;
1010
1147
  try {
1011
1148
  data = await this.client.request("GET", path, { headers, expect: [200] });
@@ -1025,79 +1162,339 @@ export class NodeRuntime {
1025
1162
  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
1163
  channel.gone = true;
1027
1164
  // The cursor belonged to that thread. The hashes are the reader's and stay.
1028
- channel.after = 0;
1029
- channel.created_at = null;
1165
+ this.forgetThread(channel);
1030
1166
  await this.saveRead();
1031
1167
  return [];
1032
1168
  }
1033
1169
  channel.gone = false;
1034
1170
  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);
1171
+ const expires = Number.isInteger(data.expire_at) ? data.expire_at : null;
1172
+ const instance = typeof data.instance === "string" ? data.instance : null;
1173
+ // A thread is known by its instance, which the service gives each thread
1174
+ // when it is opened: a thread opened again at the same address in the same
1175
+ // second, with the same lifetime and list, looked the same in every other
1176
+ // field, and a cursor taken from the old one passed over its first messages
1177
+ // (a review of 10 November 2026). A service that names no instance is read
1178
+ // by when the thread was opened and when it expires, as before.
1179
+ const known = typeof channel.instance === "string" && instance !== null;
1180
+ const another = known
1181
+ ? instance !== channel.instance
1182
+ : channel.created_at !== null && ((created !== null && created !== channel.created_at) || (expires !== null && Number.isInteger(channel.expire_at) && expires !== channel.expire_at));
1183
+ if (again && another && !data.reset) {
1184
+ this.note(channel.label, "restarted", known
1185
+ ? `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`
1186
+ : `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);
1187
+ // What was read of the old thread is no line of the new one's receipt.
1188
+ this.forgetThread(channel);
1189
+ return this.pollOnce(channel, 0, limit, false, maxBytes);
1190
+ }
1191
+ // A cursor kept by a client that did not keep the thread's instance, aamio
1192
+ // 0.6.1 among them, cannot be checked against it: a thread opened again at
1193
+ // this address in the same second, with the same lifetime and list, looks
1194
+ // the same in every other field, and its first messages were passed over
1195
+ // without a word (a review of 10 November 2026). So the thread is read again
1196
+ // from the start, once, and all of it is handed over: what this channel
1197
+ // handed over before comes again marked replay, as after a restart. Nothing
1198
+ // is passed over by its hash, which is the hash of the text alone: another
1199
+ // sender's message with the same text has it too (the same review).
1200
+ if (!another && !data.reset && channel.after > 0 && typeof channel.instance !== "string" && instance !== null) {
1201
+ 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);
1037
1202
  channel.after = 0;
1038
- channel.created_at = null;
1039
- return this.pollOnce(channel, 0, limit, false);
1203
+ return this.pollOnce(channel, 0, limit, false, maxBytes);
1204
+ }
1205
+ if (data.reset) {
1206
+ this.note(channel.label, "restarted", (isObject(data.reset) && data.reset.what) || "the service read this thread from the start", channel.w);
1207
+ this.observed.delete(channel.w);
1040
1208
  }
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);
1209
+ // The channel follows the thread that is there, all of it at once: one
1210
+ // taken up after its cursor was forgotten, or read from the start by the
1211
+ // service after a restart, may expire at another time than the one opened.
1212
+ // A channel that took the new instance and kept the old expiry chose an
1213
+ // inbox that had expired, and one that expired later was given up early
1214
+ // (a review of 10 November 2026). The list this channel was opened with is
1215
+ // its own, and stays.
1216
+ if ((channel.created_at === null || another || data.reset) && expires !== null) channel.expire_at = expires;
1042
1217
  channel.created_at = created;
1218
+ if (instance !== null) channel.instance = instance;
1219
+ // What the service kept back. It answers a byte budget with whole messages
1220
+ // only, so a message larger than the budget is named here and stays at the
1221
+ // service, with what came after it waiting behind it.
1222
+ if (isObject(data.too_large)) {
1223
+ 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);
1224
+ }
1043
1225
 
1044
1226
  const entries = [];
1045
1227
  const keptOut = [];
1046
1228
  let lastSeq = null;
1229
+ // The budget is kept here as well, for what is handed over, measured as it
1230
+ // is handed over. The service counts its own form of each message, which is
1231
+ // not the size of the message opened here, and a service without read
1232
+ // limits sends everything: 512 bytes asked for, 628 handed over (a review of
1233
+ // 10 November 2026). A row that does not fit is not taken at all. It is not
1234
+ // observed for the receipt, not remembered as read and binds no address, and
1235
+ // the cursor stays before it, so the next read asks the service for it.
1236
+ let used = 0;
1237
+ let stoppedAtBudget = false;
1238
+ let oversized = null;
1239
+ // What this read says of the messages it takes is said once the read is
1240
+ // written down. One that cannot be saved hands nothing over, and it said
1241
+ // replay_unknown for rows it never handed over: count 0 and no messages,
1242
+ // with a note on two of them (a review of 11 November 2026).
1243
+ const said = [];
1047
1244
  for (const raw of data.messages) {
1048
1245
  if (limit !== null && entries.length >= limit) break;
1049
1246
  const entry = this.readEntry(channel, raw);
1050
- lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
1051
1247
  const allow = channel.allow || [];
1052
1248
  const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
1249
+ // What the message's addresses would bind is worked out before it is
1250
+ // weighed, so a conflict it carries is counted with it, and bound only when
1251
+ // it is taken: a conflict added after the weighing let 1146 bytes through a
1252
+ // budget of 900 (a review of 10 November 2026).
1253
+ const bindings = [];
1254
+ if (!excluded) {
1255
+ this.takeScopeShare(entry);
1256
+ if (isObject(entry.body) && entry.verified && entry.from_key) {
1257
+ for (const field of ["reply_to", "channel"]) {
1258
+ const planned = this.bindingFor(field, entry.body[field], entry.from_key);
1259
+ if (planned !== null) bindings.push(planned);
1260
+ }
1261
+ const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
1262
+ if (conflicts.length) entry.binding_conflicts = conflicts;
1263
+ }
1264
+ if (maxBytes !== null) {
1265
+ const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
1266
+ if (entries.length > 0 && used + weight > maxBytes) {
1267
+ stoppedAtBudget = true;
1268
+ break;
1269
+ }
1270
+ // Alone and still over the budget: it has come, so it is handed over
1271
+ // on its own rather than kept back for ever, and that is said.
1272
+ if (entries.length === 0 && weight > maxBytes) oversized = { seq: entry.seq, weight };
1273
+ used += weight;
1274
+ }
1275
+ }
1276
+ // Every message read counts for the receipt, the ones the list keeps out too.
1277
+ this.observe(channel, raw, entry);
1278
+ lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
1053
1279
  if (excluded) {
1054
1280
  keptOut.push(entry.seq);
1055
1281
  continue;
1056
1282
  }
1057
- 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);
1283
+ if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
1284
+ for (const planned of bindings) {
1285
+ if (planned.conflict === null) {
1286
+ this.state.peers[planned.address] = entry.from_key;
1287
+ } else {
1288
+ 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.`]);
1289
+ }
1061
1290
  }
1062
1291
  entries.push(entry);
1063
1292
  }
1293
+ // Messages whose bytes this channel handed over before, which an earlier
1294
+ // version recorded without who sent them: whether each is a copy cannot be
1295
+ // told, and it is said rather than guessed.
1296
+ const unknown = entries.filter((entry) => entry.replay_unknown).map((entry) => entry.seq);
1297
+ if (unknown.length) {
1298
+ 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.`]);
1299
+ }
1064
1300
  if (keptOut.length) {
1065
1301
  const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
1066
- 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);
1302
+ 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.`]);
1303
+ }
1304
+ if (oversized) {
1305
+ 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.`]);
1067
1306
  }
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;
1307
+ // Rows the service sent that this read left because its own limit was
1308
+ // reached: a service that does not take X-Limit sends them all, and the read
1309
+ // that stopped on its own said nothing (a review of 10 November 2026).
1310
+ const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
1311
+ if (stoppedAtBudget) {
1312
+ 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.`]);
1313
+ } else if (leftHere || (data.more === true && !isObject(data.too_large))) {
1314
+ // The service says more both when the count this read asked for was
1315
+ // reached and when the byte budget was. A note that blamed a budget
1316
+ // nobody set would send the reader looking for the wrong cause.
1317
+ const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
1318
+ said.push(["more", atLimit
1319
+ ? "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."
1320
+ : maxBytes !== null
1321
+ ? "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."
1322
+ : "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."]);
1323
+ }
1324
+ // A read that stopped early moves the cursor to the last row it took and
1325
+ // no further: next would skip the rest of the page.
1326
+ const stoppedEarly = stoppedAtBudget || leftHere;
1327
+ if (stoppedEarly) {
1328
+ if (lastSeq !== null) channel.after = lastSeq;
1329
+ } else if (Number.isInteger(data.next)) channel.after = data.next;
1071
1330
  else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
1072
1331
  await this.saveRead();
1332
+ for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
1073
1333
  return entries;
1074
1334
  }
1075
1335
 
1336
+ /**
1337
+ * The receipt for one channel, and two checks of it that are not one. Whether
1338
+ * its own lines hash to the root it claims can always be checked:
1339
+ * root_adds_up. Whether it agrees with what this process read can be, when
1340
+ * this process holds every message it counts: local_root_matches, null when
1341
+ * it holds fewer, which is not a failure, and false when it holds more. A
1342
+ * signer the service names is shown by name only when this process checked
1343
+ * that signature itself. The receipt is signed with this home's key as it
1344
+ * was fetched, which records what was taken, not that its claims are true.
1345
+ * With anchor, and only then, its root goes to Verifyum, with the same
1346
+ * idempotency key aamio-python and aamio-php use.
1347
+ */
1348
+ async receipt(label = "inbox", { anchor = false } = {}) {
1349
+ const channel = this.channel(String(label));
1350
+ if (!isObject(channel)) {
1351
+ const labels = this.state.channels.filter(isObject).map((c) => c.label);
1352
+ 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.`);
1353
+ }
1354
+ let data;
1355
+ try {
1356
+ ({ receipt: data } = await this.client.receipt({ id: channel.read_key, w: channel.w }));
1357
+ } catch (error) {
1358
+ throw runtimeError("receipt_failed", `the receipt could not be taken: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}`, { status: error.status ?? 0 });
1359
+ }
1360
+ // Checked as a receipt for this channel before anything is compared,
1361
+ // signed or anchored.
1362
+ const fault = receiptFault(data, channel.w);
1363
+ if (fault !== null) {
1364
+ throw runtimeError("receipt_failed", `the service's answer is not a receipt for this channel: ${fault}. Nothing was compared, signed or anchored.`);
1365
+ }
1366
+ // What this process read counts only for the thread it was read on. After
1367
+ // the service restarted, a new thread at the same address was compared with
1368
+ // what was read of the old one, and its signer named as checked here.
1369
+ const observed = this.observed.get(channel.w);
1370
+ // The same thread by its instance where the service names one, and by when
1371
+ // it was opened and expires where it does not.
1372
+ const sameThread = observed !== undefined && (typeof data.instance === "string" && observed.instance !== null
1373
+ ? observed.instance === data.instance
1374
+ : observed.created_at !== null && observed.created_at === data.created_at && (observed.expire_at === null || observed.expire_at === data.expire_at));
1375
+ const earlier = observed !== undefined && !sameThread && observed.rows.size > 0 ? observed : null;
1376
+ const entries = sameThread ? [...observed.rows.values()].sort((a, b) => a.seq - b.seq) : [];
1377
+ const which = (thread) => (typeof thread.instance === "string" ? `instance ${thread.instance}, ` : "the one ") + `opened at ${thread.created_at} and expiring at ${thread.expire_at}`;
1378
+ const held = entries.length;
1379
+ const line = (m) => `${m.seq}\t${m.at}\t${m.sha256}\t${m.from ?? "-"}\n`;
1380
+ const listed = data.messages.map(line).join("");
1381
+ const ours = entries.map(line).join("");
1382
+ const comparable = held === data.count;
1383
+ // A signer is named only for a row this process checked and the receipt
1384
+ // lists as it was read: the same seq, time, hash and signer. A row read on a
1385
+ // thread the fields cannot tell from this one is not taken for it (a review
1386
+ // of 10 November 2026).
1387
+ const listedBySeq = new Map(data.messages.map((m) => [m.seq, m]));
1388
+ const asListed = (e) => {
1389
+ const m = listedBySeq.get(e.seq);
1390
+ return m !== undefined && m.at === e.at && m.sha256 === e.sha256 && (m.from ?? null) === (e.from ?? null);
1391
+ };
1392
+ const verifiedKeys = new Set(entries.filter((e) => e.verified && typeof e.from_key === "string" && asListed(e)).map((e) => e.from_key));
1393
+ const claimed = Array.isArray(data.keys) ? data.keys : [];
1394
+ const unverified = claimed.filter((key) => typeof key !== "string" || !verifiedKeys.has(key));
1395
+ const attestation = `aamio-receipt-v1\n${channel.w}\n${data.root}\n${data.count}\n${data.issued_at ?? 0}`;
1396
+ const result = {
1397
+ label: channel.label, w: channel.w, root: data.root, commitment: data.commitment ?? null, count: data.count,
1398
+ keys: claimed.map((key) => (typeof key === "string" && verifiedKeys.has(key) ? this.nameForKey(key) ?? key : key)),
1399
+ keys_unverified_count: unverified.length, keys_service_claim_only: unverified,
1400
+ root_adds_up: sha256hex(listed) === data.root, held_locally: held,
1401
+ local_root_matches: comparable ? sha256hex(ours) === data.root : held > data.count ? false : null,
1402
+ signed_by: this.keys.public, signature: this.keys.sign(attestation), signed_text: attestation, receipt: data,
1403
+ };
1404
+ if (comparable) {
1405
+ const local = new Map(entries.map((e) => [e.seq, e]));
1406
+ result.local_differences = (data.messages ?? []).map((m) => {
1407
+ const seen = local.get(m.seq);
1408
+ return { seq: m.seq, fields: ["at", "sha256", "from"].filter((field) => !seen || (seen[field] ?? null) !== (m[field] ?? null)) };
1409
+ }).filter((difference) => difference.fields.length);
1410
+ result.signers_not_verified_locally = entries.filter((e) => e.from && !e.verified).map((e) => e.seq);
1411
+ }
1412
+ if (held > data.count) {
1413
+ result.local_check = "Mismatch: the receipt counts fewer messages than this process read. The thread was replaced or the service lost messages.";
1414
+ } else if (!comparable) {
1415
+ 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.`
1416
+ + (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.` : "")
1417
+ + " 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.";
1418
+ }
1419
+ if (anchor === true) {
1420
+ const idempotencyKey = sha256hex(`aamio-listen:${this.keys.hash}:${data.root}`).slice(0, 32);
1421
+ try {
1422
+ const proof = await this.client.anchor(`sha256:${data.root}`, { endpoint: this.verifyum, idempotencyKey });
1423
+ result.anchor = { proof_id: proof.proofId, proof_url: proof.proofUrl ?? null, status: proof.status ?? null, commitment: proof.commitment };
1424
+ } catch (error) {
1425
+ // The receipt stands; only the anchoring failed, and it says how: the
1426
+ // HTTP status, 0 when nothing answered, and Verifyum's own words when
1427
+ // its tool answered with an error.
1428
+ const said = error.body?.result?.content?.[0]?.text;
1429
+ result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
1430
+ }
1431
+ }
1432
+ return result;
1433
+ }
1434
+
1076
1435
  /** 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 } = {}) {
1436
+ async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
1437
+ const noted = this.attention.length;
1078
1438
  await this.ensureInbox();
1079
1439
  await this.publishPresence();
1080
1440
  const now = this.now();
1441
+ // What was read of a thread is kept for its receipt while the receipt can
1442
+ // still be taken, and no longer: a server that ran for days held the rows
1443
+ // of every inbox it had read (a review of 10 November 2026).
1444
+ for (const w of [...this.observed.keys()]) {
1445
+ const known = this.state.channels.find((c) => isObject(c) && c.w === w);
1446
+ if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
1447
+ }
1081
1448
  const channels = this.state.channels.filter((c) => isObject(c) && !c.muted && Number(c.expire_at) > now)
1082
1449
  .sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
1083
1450
  const messages = [];
1451
+ // A channel this read had no room left to ask is named, as aamio-python
1452
+ // names it: what waits there was not looked at, and a read that stopped at
1453
+ // its limit is not a read of everything.
1454
+ const notAsked = [];
1455
+ const notAskedAfter = [];
1084
1456
  // Every channel without waiting first: a message on an older inbox that
1085
1457
  // is still read is handed over at once, not after a wait on the new one.
1458
+ // The byte budget is per channel, as in aamio-python: one split between
1459
+ // them would refuse a message that fits.
1086
1460
  for (const channel of channels) {
1087
- if (messages.length >= limit) break;
1088
- messages.push(...(await this.poll(channel, 0, limit - messages.length)));
1461
+ if (messages.length >= limit) {
1462
+ notAsked.push(channel);
1463
+ continue;
1464
+ }
1465
+ messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
1089
1466
  }
1090
1467
  const inbox = this.channel("inbox");
1091
1468
  if (!messages.length && wait > 0 && inbox && !inbox.muted) {
1092
- messages.push(...(await this.poll(inbox, wait, limit)));
1469
+ messages.push(...(await this.poll(inbox, wait, limit, maxBytes)));
1093
1470
  // What reached the other channels during the wait comes with this
1094
1471
  // answer, as in aamio-python: only the inbox can end the wait early. It
1095
1472
  // used to wait for the next read (a review of 9 November 2026).
1096
1473
  for (const channel of channels) {
1097
- if (channel === inbox || messages.length >= limit) continue;
1098
- messages.push(...(await this.poll(channel, 0, limit - messages.length)));
1474
+ if (channel === inbox) continue;
1475
+ if (messages.length >= limit) {
1476
+ notAskedAfter.push(channel);
1477
+ continue;
1478
+ }
1479
+ messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
1099
1480
  }
1100
1481
  }
1482
+ for (const channel of notAsked) {
1483
+ 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);
1484
+ }
1485
+ for (const channel of notAskedAfter) {
1486
+ 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);
1487
+ }
1488
+ // A read says a thing once. A channel asked before a wait and in it gave
1489
+ // the same too_large twice (a review of 10 November 2026).
1490
+ const said = new Set();
1491
+ const fresh = this.attention.slice(noted).filter((note) => {
1492
+ const key = `${note.w}\u0000${note.state}\u0000${note.what}`;
1493
+ if (said.has(key)) return false;
1494
+ said.add(key);
1495
+ return true;
1496
+ });
1497
+ this.attention.splice(noted, this.attention.length - noted, ...fresh);
1101
1498
  return messages;
1102
1499
  }
1103
1500
  }