@cello-protocol/daemon 0.0.182 → 0.0.183

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.
Files changed (90) hide show
  1. package/dist/agent-settings-keys.d.ts.map +1 -1
  2. package/dist/agent-settings-keys.js +18 -0
  3. package/dist/agent-settings-keys.js.map +1 -1
  4. package/dist/consortium-bootstrap.d.ts.map +1 -1
  5. package/dist/consortium-bootstrap.js +117 -2
  6. package/dist/consortium-bootstrap.js.map +1 -1
  7. package/dist/content-encryption-status.d.ts +97 -0
  8. package/dist/content-encryption-status.d.ts.map +1 -0
  9. package/dist/content-encryption-status.js +122 -0
  10. package/dist/content-encryption-status.js.map +1 -0
  11. package/dist/content-park-client.d.ts +10 -0
  12. package/dist/content-park-client.d.ts.map +1 -1
  13. package/dist/content-park-client.js +84 -3
  14. package/dist/content-park-client.js.map +1 -1
  15. package/dist/content-park.d.ts.map +1 -1
  16. package/dist/content-park.js +184 -13
  17. package/dist/content-park.js.map +1 -1
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +306 -23
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/document-handlers.d.ts.map +1 -1
  22. package/dist/document-handlers.js +119 -0
  23. package/dist/document-handlers.js.map +1 -1
  24. package/dist/document-layer.d.ts +0 -2
  25. package/dist/document-layer.d.ts.map +1 -1
  26. package/dist/document-layer.js.map +1 -1
  27. package/dist/initiate-session-handler.d.ts.map +1 -1
  28. package/dist/initiate-session-handler.js +34 -3
  29. package/dist/initiate-session-handler.js.map +1 -1
  30. package/dist/manifest-deps.d.ts +0 -11
  31. package/dist/manifest-deps.d.ts.map +1 -1
  32. package/dist/manifest-deps.js +28 -2
  33. package/dist/manifest-deps.js.map +1 -1
  34. package/dist/manifest-validity.d.ts +2 -0
  35. package/dist/manifest-validity.d.ts.map +1 -1
  36. package/dist/manifest-validity.js +1 -1
  37. package/dist/manifest-validity.js.map +1 -1
  38. package/dist/outbound-sessions.d.ts.map +1 -1
  39. package/dist/outbound-sessions.js +19 -0
  40. package/dist/outbound-sessions.js.map +1 -1
  41. package/dist/park-envelope.d.ts +23 -0
  42. package/dist/park-envelope.d.ts.map +1 -1
  43. package/dist/park-envelope.js +76 -0
  44. package/dist/park-envelope.js.map +1 -1
  45. package/dist/register-handler.d.ts.map +1 -1
  46. package/dist/register-handler.js +76 -0
  47. package/dist/register-handler.js.map +1 -1
  48. package/dist/relay-only.d.ts +133 -0
  49. package/dist/relay-only.d.ts.map +1 -0
  50. package/dist/relay-only.js +183 -0
  51. package/dist/relay-only.js.map +1 -0
  52. package/dist/retry-queue.d.ts +26 -1
  53. package/dist/retry-queue.d.ts.map +1 -1
  54. package/dist/retry-queue.js +18 -1
  55. package/dist/retry-queue.js.map +1 -1
  56. package/dist/session-ceremony.d.ts +6 -0
  57. package/dist/session-ceremony.d.ts.map +1 -1
  58. package/dist/session-ceremony.js +26 -0
  59. package/dist/session-ceremony.js.map +1 -1
  60. package/dist/session-content-handlers.d.ts +12 -0
  61. package/dist/session-content-handlers.d.ts.map +1 -1
  62. package/dist/session-content-handlers.js +39 -4
  63. package/dist/session-content-handlers.js.map +1 -1
  64. package/dist/session-node-manager.d.ts +247 -4
  65. package/dist/session-node-manager.d.ts.map +1 -1
  66. package/dist/session-node-manager.js +3088 -304
  67. package/dist/session-node-manager.js.map +1 -1
  68. package/dist/session-read-handlers.d.ts.map +1 -1
  69. package/dist/session-read-handlers.js +16 -1
  70. package/dist/session-read-handlers.js.map +1 -1
  71. package/dist/session-relay-client.d.ts +116 -0
  72. package/dist/session-relay-client.d.ts.map +1 -1
  73. package/dist/session-relay-client.js +395 -6
  74. package/dist/session-relay-client.js.map +1 -1
  75. package/dist/session-salt-agreement.d.ts +58 -4
  76. package/dist/session-salt-agreement.d.ts.map +1 -1
  77. package/dist/session-salt-agreement.js +110 -3
  78. package/dist/session-salt-agreement.js.map +1 -1
  79. package/dist/signaling-connect.d.ts +18 -1
  80. package/dist/signaling-connect.d.ts.map +1 -1
  81. package/dist/signaling-connect.js +164 -1
  82. package/dist/signaling-connect.js.map +1 -1
  83. package/dist/types.d.ts +21 -0
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/wire-content-hash.d.ts +15 -3
  87. package/dist/wire-content-hash.d.ts.map +1 -1
  88. package/dist/wire-content-hash.js +15 -3
  89. package/dist/wire-content-hash.js.map +1 -1
  90. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -72,7 +72,7 @@ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
72
72
  import { registerContactHandlers } from "./contact-handlers.js";
73
73
  import { createSealCoordinator } from "./seal-coordinator.js";
74
74
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
75
- import { registerSessionContentHandlers } from "./session-content-handlers.js";
75
+ import { registerSessionContentHandlers, sentAuthorship } from "./session-content-handlers.js";
76
76
  import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
77
77
  import { isDocumentFrame } from "./document-frame-router.js";
78
78
  import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
@@ -110,6 +110,7 @@ import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
110
110
  import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
111
111
  import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
112
112
  import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
113
+ import { relayOnlyState } from "./relay-only.js";
113
114
  import { decodeCbor } from "@cello-protocol/protocol-types";
114
115
  // Minimal no-op KeyProvider stub for session nodes.
115
116
  // Session nodes don't need signing keys — libp2p generates its own fresh
@@ -162,9 +163,15 @@ export class ProductionSessionNodeFactory {
162
163
  listenAddresses: [listenAddr, ...(config.circuitRelayListenAddrs ?? [])],
163
164
  ...(announce ? { announceAddresses: announce } : {}),
164
165
  connectionGater: config.connectionGater,
166
+ // DOD-M15-RELAYONLY-1: an agent that asked never to be directly reachable must not hole-punch
167
+ // its way to a direct connection. dcutr's job is to UPGRADE a relayed connection, and the
168
+ // INBOUND side starts that upgrade — which is precisely the standing receiver. So filtering
169
+ // what the directory publishes is not enough on its own: the address a peer cannot be TOLD, a
170
+ // hole-punch still REVEALS, and it happens inside libp2p after every assertion has passed.
171
+ ...(config.relayOnly === true ? { holePunch: { enabled: false } } : {}),
165
172
  // Forward the role. After DOD-NAT-REACHABILITY-1, dcutr is on every node
166
- // type; nodeType's remaining transport effect is the HOP gate (client
167
- // types never advertise circuit-relay HOP).
173
+ // type unless relay-only turns it off above; nodeType's remaining transport effect is the HOP
174
+ // gate (client types never advertise circuit-relay HOP).
168
175
  nodeType: config.nodeType,
169
176
  // DOD-M12B-SESSION-SEED-1: forward the caller's transport seed when it supplied one, so a
170
177
  // rebuilt session node returns at the peer id the counterparty already holds. Omitted (rather
@@ -589,6 +596,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
589
596
  for (const a of loadedAgents) {
590
597
  keyProviders.set(a.name, a.keyProvider);
591
598
  }
599
+ // DOD-M15-EPHEMERAL-AUTH-1: the session manager signs each session's throwaway key with the
600
+ // agent's identity, so it needs the same providers. Injected here rather than through the
601
+ // constructor because this map is built after the manager exists — the same reason
602
+ // `setParkedDrainHook` is a setter.
603
+ sessionNodeManager.setKeyProviderResolver((agentName) => keyProviders.get(agentName));
592
604
  // Constructed HERE, before ANY boot-time caller. autoRecoverForAgent is invoked from an agent's
593
605
  // onConnected and from the seal-upgrade content gate — both of which run long before the IPC
594
606
  // handler map exists. Its handlers register later (phase 2), which is what lets this sit up here.
@@ -799,6 +811,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
799
811
  onRelayEndpoints: (endpoints) => {
800
812
  sessionNodeManager.setDirectoryRelayEndpoints(agentName, endpoints.map((e) => ({ relayPeerId: e.peerId, relayAddrs: e.addrs })));
801
813
  },
814
+ // DOD-M15-RELAYSLOTS-1: and the credential those relays require. Same frame, same cadence as
815
+ // the endpoints above — every connect and every reconnect, which is what keeps a token that
816
+ // expires within the hour current for a receiver that lives much longer than that.
817
+ onOnlineToken: (token) => {
818
+ sessionNodeManager.setDirectoryOnlineToken(agentName, token);
819
+ },
820
+ // Review M1: and when there is none, WHY — so the operator surface can say "this directory
821
+ // does not know this agent" instead of "check your directory connection" about a connection
822
+ // that just succeeded.
823
+ onOnlineTokenAbsent: (reason) => {
824
+ sessionNodeManager.setDirectoryOnlineTokenAbsent(agentName, reason);
825
+ },
802
826
  });
803
827
  const mgr = new SignalingManager({
804
828
  connect,
@@ -870,6 +894,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
870
894
  agentName,
871
895
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
872
896
  admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
897
+ // DOD-M15-RELAYONLY-1: lets the handler tell "no addresses because relay-only filtered them"
898
+ // from "no addresses yet", which need opposite answers — a refusal, and the pre-existing path.
899
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agentName, key), sessionNodeManager.hasDatabase()) !== "off",
873
900
  signaling: mgr,
874
901
  logger,
875
902
  });
@@ -944,6 +971,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
944
971
  agentName: agent.name,
945
972
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
946
973
  admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
974
+ // DOD-M15-RELAYONLY-1 — see the note on the sibling call site above.
975
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agent.name, key), sessionNodeManager.hasDatabase()) !== "off",
947
976
  signaling: mgr,
948
977
  logger,
949
978
  });
@@ -1280,9 +1309,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1280
1309
  // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
1281
1310
  // wrong index does not merely stall the far side, it seals a tree the counterparty can
1282
1311
  // never agree with.
1283
- const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID());
1312
+ // The proof travels with the leaf, not only with the transcript row below: when this
1313
+ // append is HELD behind a gap, the `recordTranscriptMessage` call is skipped entirely
1314
+ // and the held entry is the only thing that reaches the row on release.
1315
+ const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1284
1316
  if (placed.placed) {
1285
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
1317
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID(), sentAuthorship(sendResult));
1286
1318
  }
1287
1319
  logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
1288
1320
  }
@@ -1503,9 +1535,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1503
1535
  // next arrival would mint a second greeting at a second sequence.
1504
1536
  // DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
1505
1537
  // that is where its leaf goes.
1506
- const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1538
+ // Witnessed and SIGNED only the direct hand-off failed — so the proof exists and must
1539
+ // reach the leaf. This is the site `sentAuthorship`'s own comment calls dead-by-construction
1540
+ // under an `ok`-gated read; the same reasoning applies to the leaf, not just the row.
1541
+ const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1507
1542
  if (placedQueued.placed) {
1508
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
1543
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1509
1544
  }
1510
1545
  logger.info("session.away.response.deferred", {
1511
1546
  agentName, sessionId, kind, isKnown,
@@ -1517,9 +1552,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1517
1552
  }
1518
1553
  // DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
1519
1554
  // path most likely to have a gap open under it — exactly where a tail append does damage.
1520
- const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1555
+ const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1521
1556
  if (placedReply.placed) {
1522
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
1557
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1523
1558
  }
1524
1559
  logger.info("session.away.response.sent", {
1525
1560
  agentName, sessionId, kind, isKnown,
@@ -1752,6 +1787,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1752
1787
  // seal depends on — silently, while reporting success.
1753
1788
  receiptStore: stores.receiptStore,
1754
1789
  sealLeafStore: stores.sealLeafStore,
1790
+ // DOD-M15-RELAYSLOTS-1: the manager owns the current token and hands the accessor down, so
1791
+ // this client reads a fresh one at every auth instead of a snapshot taken here at build time.
1792
+ onlineToken: stores.onlineToken,
1755
1793
  });
1756
1794
  });
1757
1795
  // DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
@@ -1847,6 +1885,54 @@ async function startDaemonHoldingLock(config, singletonLock) {
1847
1885
  logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
1848
1886
  return null;
1849
1887
  });
1888
+ /**
1889
+ * DOD-M15-RELAYABUSE-1: scheduled park-retry timers, tracked so shutdown can clear them.
1890
+ *
1891
+ * ⚠️ Every other daemon timer is cleared in `stop()`; an untracked one lets an in-process restart
1892
+ * leave a stale timer that drains into a torn-down manager. Unref'd already, so it cannot hold the
1893
+ * process open — this is about a clean teardown, not about exiting.
1894
+ */
1895
+ const parkRetryTimers = new Set();
1896
+ /**
1897
+ * DOD-M15-RELAYABUSE-1: schedule ONE drain at the delay the relay asked for.
1898
+ *
1899
+ * ⚠️ ONE implementation, used by both park paths. The live-send path and the drain path both need
1900
+ * this and a second copy is how the clamp ends up on only one of them — which is exactly the shape
1901
+ * review found here (the number had a consumer on one path and was dropped on the other).
1902
+ *
1903
+ * CLAMPED, because the value comes from another party's software and an unclamped one inverts the
1904
+ * fix: Node's `setTimeout` holds an int32, so a relay reporting `3_000_000_000` overflows it and
1905
+ * fires in about ONE MILLISECOND — re-parking instantly into the limit that just refused, and
1906
+ * printing a `TimeoutOverflowWarning` from a daemon whose convention forbids console output.
1907
+ */
1908
+ function scheduleParkRetry(retryAfterMs, filterAgentName, source) {
1909
+ const MIN_RETRY_MS = 1_000;
1910
+ const MAX_RETRY_MS = 5 * 60_000;
1911
+ const delay = Math.min(Math.max(retryAfterMs, MIN_RETRY_MS), MAX_RETRY_MS);
1912
+ if (delay !== retryAfterMs) {
1913
+ logger.warn("content.park.retry.clamped", {
1914
+ source,
1915
+ requestedMs: retryAfterMs,
1916
+ usedMs: delay,
1917
+ impact: "the relay asked for a retry delay outside the range this daemon will schedule, so it was " +
1918
+ "clamped — an unclamped value can overflow the timer and fire immediately, which would " +
1919
+ "re-park straight into the limit that just refused",
1920
+ });
1921
+ }
1922
+ logger.info("content.park.retry.scheduled", { source, delayMs: delay, ...(filterAgentName !== undefined ? { agentName: filterAgentName } : {}) });
1923
+ const timer = setTimeout(() => {
1924
+ parkRetryTimers.delete(timer);
1925
+ void flushAwaitingContent(filterAgentName).catch((err) => {
1926
+ logger.warn("content.park.retry.timer.failed", {
1927
+ source,
1928
+ reason: err instanceof Error ? err.message : String(err),
1929
+ impact: "the scheduled drain threw; the ordinary event triggers (boot, agent start, reconnect) still apply",
1930
+ });
1931
+ });
1932
+ }, delay);
1933
+ timer.unref?.();
1934
+ parkRetryTimers.add(timer);
1935
+ }
1850
1936
  sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
1851
1937
  const node = sessionNodeManager.getStandingReceiverNode();
1852
1938
  if (!node) {
@@ -1885,8 +1971,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
1885
1971
  // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1886
1972
  // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1887
1973
  // is salted is a fact about the message that was sent; what this side holds now says nothing
1888
- // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1889
- // carrying the value that cannot break anything, and only then does the value change.
1974
+ // about it.
1975
+ //
1976
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
1977
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
1978
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
1979
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
1980
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
1981
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
1982
+ // verdict on an honest message.
1890
1983
  const ciphertext = await sealParkEnvelope({
1891
1984
  signer: senderKp,
1892
1985
  sessionIdHex: sessionId,
@@ -1910,8 +2003,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
1910
2003
  logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
1911
2004
  return { ok: true };
1912
2005
  }
1913
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
1914
- return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
2006
+ logger.warn("content.park.deposit.failed", {
2007
+ sessionId,
2008
+ contentHash: contentHashHex,
2009
+ reason: res.reason,
2010
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2011
+ });
2012
+ /**
2013
+ * DOD-M15-RELAYABUSE-1 — **GIVE THE RELAY'S "WHEN" A CONSUMER.**
2014
+ *
2015
+ * A deferred park is otherwise retried only on EVENTS — boot, agent start, the drain hook, a
2016
+ * signaling reconnect. None of those is coming for a throttle: the relay is healthy, the link
2017
+ * never dropped, and the condition clears on a timer nobody is watching. So the one refusal that
2018
+ * self-heals in about a minute was the one that waited longest, purely because the number the
2019
+ * relay had already computed had no reader.
2020
+ *
2021
+ * Scheduled ONCE per refusal, unref'd so it can never hold the process open, and best-effort:
2022
+ * the existing event triggers remain the guarantee, and this is a shortcut on top of them. It
2023
+ * deliberately does not retry-on-retry — a timer that reschedules itself on failure is a
2024
+ * self-inflicted flood, which is what the limiter exists to stop.
2025
+ */
2026
+ if (res.retryAfterMs !== undefined && res.retryAfterMs > 0) {
2027
+ /**
2028
+ * ⚠️ CLAMPED, because the number comes from ANOTHER PARTY'S SOFTWARE and an unclamped one is
2029
+ * not merely wrong — it inverts the fix. Node's `setTimeout` holds an int32: a relay reporting
2030
+ * `3_000_000_000` overflows it and the timer fires in about **one millisecond**, re-parking
2031
+ * instantly into the limit that just refused, and printing a `TimeoutOverflowWarning` on
2032
+ * stderr from a daemon whose convention forbids console output. A reported `1` does the same
2033
+ * thing 250 ms later.
2034
+ *
2035
+ * The floor is a second and the ceiling five minutes: long enough that a shortcut is a
2036
+ * shortcut, short enough that it stays one. A clamp is announced rather than silent — a relay
2037
+ * asking us to wait 35 days is a fact an operator wants.
2038
+ */
2039
+ scheduleParkRetry(res.retryAfterMs, agentName, "send");
2040
+ }
2041
+ return {
2042
+ ok: false,
2043
+ reason: res.reason ?? "relay_deposit_failed",
2044
+ // DOD-M15-RELAYABUSE-1 review MEDIUM-6: the guidance quotes the relay's OWN window instead of
2045
+ // guessing "about a minute", which is a hardcoded assumption about a configurable value.
2046
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2047
+ };
1915
2048
  });
1916
2049
  // CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
1917
2050
  // content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
@@ -1972,8 +2105,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
1972
2105
  // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1973
2106
  // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1974
2107
  // is salted is a fact about the message that was sent; what this side holds now says nothing
1975
- // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1976
- // carrying the value that cannot break anything, and only then does the value change.
2108
+ // about it.
2109
+ //
2110
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
2111
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
2112
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
2113
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
2114
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
2115
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
2116
+ // verdict on an honest message.
1977
2117
  const ciphertext = await sealParkEnvelope({
1978
2118
  signer: senderKp,
1979
2119
  sessionIdHex: entry.sessionId,
@@ -1997,7 +2137,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1997
2137
  logger.info("content.park.deposited", { sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
1998
2138
  return { parked: true };
1999
2139
  }
2000
- return { parked: false, error: res.reason ?? "deposit_failed" };
2140
+ return {
2141
+ parked: false,
2142
+ error: res.reason ?? "deposit_failed",
2143
+ // DOD-M15-RELAYABUSE-1 review HIGH-2: carry the relay's own "when" out of the DRAIN path too.
2144
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2145
+ };
2001
2146
  };
2002
2147
  // Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
2003
2148
  // (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
@@ -2055,7 +2200,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
2055
2200
  let parkedTotal = 0;
2056
2201
  for (const s of sessions) {
2057
2202
  try {
2058
- parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn);
2203
+ parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn, (retryAfterMs) => {
2204
+ /**
2205
+ * DOD-M15-RELAYABUSE-1 review HIGH-2 — **the drain path can now schedule its own
2206
+ * follow-up, which is what makes the retry work for a BACKLOG rather than one message.**
2207
+ *
2208
+ * Before this, only the live send path heard the relay's delay. With a backlog larger than
2209
+ * one rate-limit window, the drain deposited what the window allowed, every remaining item
2210
+ * was refused, and all of them fell back to waiting for an unrelated reconnect — the exact
2211
+ * condition the retry timer was added to remove.
2212
+ *
2213
+ * ONE timer per pass (the drain reports the largest delay it saw, not one per item), and
2214
+ * it deliberately does not chain beyond that: the next pass schedules the next one only if
2215
+ * it is refused again, so a permanently-full relay costs one timer per window rather than
2216
+ * an accelerating stream of them.
2217
+ */
2218
+ scheduleParkRetry(retryAfterMs, filterAgentName, "drain");
2219
+ });
2059
2220
  }
2060
2221
  catch (err) {
2061
2222
  logger.error("content.park.flush.failed", {
@@ -2163,6 +2324,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
2163
2324
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
2164
2325
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
2165
2326
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2327
+ /**
2328
+ * DOD-M15-RELAYSLOTS-1: WHY it is not reachable, and what to do about it.
2329
+ *
2330
+ * `standing_receiver_reachability` says `retrying` or `unreachable` and stops there, which
2331
+ * for the person reading it is indistinguishable from the product being broken. The relay
2332
+ * now refuses for reasons someone can act on — no token from a directory yet, too many
2333
+ * sessions still open, this relay is misconfigured — each with a different next step, and
2334
+ * every one of them is wasted if it only reaches a log file. Absent when the last attempt
2335
+ * succeeded.
2336
+ */
2337
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2338
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2339
+ : {}),
2166
2340
  // DOD-COATTEND-VISIBLE-1 AC2: how many sessions are driving this agent, including this
2167
2341
  // one. Live, not a high-water mark — it drops when a session disconnects. `selected` says
2168
2342
  // whether YOU hold it; this says whether anyone else does too.
@@ -2403,6 +2577,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
2403
2577
  state: agentStateFor(a),
2404
2578
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
2405
2579
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2580
+ // DOD-M15-RELAYSLOTS-1: the same cause-and-advice on the daemon-wide surface — see the note
2581
+ // on the MCP one above. Two surfaces, one reason to exist.
2582
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2583
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2584
+ : {}),
2406
2585
  })),
2407
2586
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
2408
2587
  retryQueueDepth: retryQueue.getTotalDepth(),
@@ -2434,14 +2613,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
2434
2613
  // Permissive by design (D12): an agent that exists goes online regardless of directory
2435
2614
  // registration state (online-without-registration is an established contract). Returns a
2436
2615
  // structured failure so callers can surface agent_start_failed with a real reason + guidance.
2616
+ /**
2617
+ * `standing_receiver` is part of the SUCCESS shape, not an optional extra — `DOD-M15-START-AGENT-
2618
+ * UNAWAITED-1`. A bare `{ ok: true }` claimed the agent was started and reachable when only the
2619
+ * first half was known, and the union makes the two states impossible to conflate at a call site.
2620
+ */
2437
2621
  function startAgentInternal(name) {
2438
2622
  const agent = agents.find((a) => a.name === name);
2439
2623
  if (!agent || agent.state === "load_failed") {
2440
2624
  return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_agents.` };
2441
2625
  }
2442
2626
  if (onlineAgents.has(name)) {
2443
- // Idempotent — already online, no event
2444
- return { ok: true };
2627
+ // Idempotent — already online, no event.
2628
+ //
2629
+ // It still reports REAL readiness rather than a bare ok. "Already online" says this daemon
2630
+ // marked the agent online at some earlier moment; it says nothing about whether the receiver
2631
+ // that ensure was firing ever came up. An operator who calls start twice — which is exactly
2632
+ // what someone does when the first one seemed not to work — would otherwise get the most
2633
+ // reassuring answer in the run on the attempt where something is actually wrong.
2634
+ const readyNow = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2635
+ if (readyNow)
2636
+ return { ok: true, standing_receiver: "ready" };
2637
+ const cause = sessionNodeManager.standingReceiverAbsenceReason(name);
2638
+ return {
2639
+ ok: true,
2640
+ standing_receiver: "starting",
2641
+ standing_receiver_cause: cause,
2642
+ guidance: `'${name}' was already online, and its standing receiver is not up (${cause}). Outbound ` +
2643
+ `sends and cello_initiate_session ensure it on demand. An inbound session arriving before ` +
2644
+ `it is ready is refused with 'standing_receiver_unavailable' — this daemon, not the ` +
2645
+ `counterparty. If it stays this way, stop the agent and start it again.`,
2646
+ };
2445
2647
  }
2446
2648
  onlineAgents.add(name);
2447
2649
  // Pressing start clears the deliberate-offline mark — that is what makes the switch reversible.
@@ -2467,7 +2669,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
2467
2669
  void sessionNodeManager.ensureStandingReceiverForAgent(name)
2468
2670
  .then(() => flushAwaitingContent(name))
2469
2671
  .catch((err) => {
2470
- logger.warn("session.standing_receiver.ensure.failed", { agentName: name, reason: extractErrorMessage(err) });
2672
+ logger.warn("session.standing_receiver.ensure.failed", {
2673
+ agentName: name,
2674
+ reason: extractErrorMessage(err),
2675
+ // `DOD-M15-START-AGENT-UNAWAITED-1`. The operator has ALREADY been told `ok: true` — this
2676
+ // handler answered before this promise settled — so nothing corrects that answer if this
2677
+ // is permanent. Say what it costs them here, because this line is the only account.
2678
+ impact: "cello_start_agent already answered ok for this agent, and its standing receiver did not " +
2679
+ "come up. The agent is online to the directory and CANNOT accept an inbound session: a " +
2680
+ "counterparty dialling it is refused standing_receiver_unavailable. Initiate and accept " +
2681
+ "each re-ensure on demand, so this may still recover on the next attempt; if it does not, " +
2682
+ "stop and restart the agent.",
2683
+ });
2471
2684
  })
2472
2685
  // DOD-MSG-4 (auto-recover-on-reconnect): RECEIVER drains its parked mailbox from every relay it
2473
2686
  // has sessions on (symmetric to the sender re-park). Its own stage so a failure is labelled
@@ -2477,10 +2690,51 @@ async function startDaemonHoldingLock(config, singletonLock) {
2477
2690
  .catch((err) => {
2478
2691
  logger.warn("content.recover.auto.failed", { agentName: name, stage: "agent_start", error: extractErrorMessage(err) });
2479
2692
  });
2480
- logger.info("agent.online", { agentName: name, agentPubkey: agent.pubkey ?? "" });
2693
+ /**
2694
+ * `DOD-M15-START-AGENT-UNAWAITED-1` — SAY WHETHER THE AGENT CAN ACTUALLY HEAR YET.
2695
+ *
2696
+ * The ensure above is fire-and-forget and that is deliberate: initiate and accept both ensure on
2697
+ * demand, and awaiting it here would turn a transient network failure into a failed start. **The
2698
+ * defect was never the timing — it was the CLAIM.** `{ ok: true }` with nothing else reads as
2699
+ * "your agent is running and reachable", and a session landing in the window before the receiver
2700
+ * exists is refused `standing_receiver_unavailable` — a precondition on OUR side, surfacing to
2701
+ * the operator as though the counterparty or the directory were at fault.
2702
+ *
2703
+ * ⚠️ **This field is only worth having because it can genuinely say `ready`.** Computed one line
2704
+ * after firing an async ensure, a naive readiness flag would be `starting` on every call — a
2705
+ * field that can never take its other value, which is the same defect as a log line reporting a
2706
+ * verdict its producer cannot have. It escapes that because `ensureStandingReceiverForAgent` is
2707
+ * IDEMPOTENT: an agent that already holds a receiver (a repeat start, or one whose receiver
2708
+ * survived) has one at this instant and reports `ready` truthfully.
2709
+ *
2710
+ * `cause` is read from the same four-way answer the refusal path uses, so the response and the
2711
+ * eventual error agree instead of describing the same state in two vocabularies.
2712
+ */
2713
+ const receiverReady = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2714
+ const startingCause = receiverReady ? undefined : sessionNodeManager.standingReceiverAbsenceReason(name);
2715
+ logger.info("agent.online", {
2716
+ agentName: name,
2717
+ agentPubkey: agent.pubkey ?? "",
2718
+ standingReceiver: receiverReady ? "ready" : "starting",
2719
+ ...(startingCause !== undefined ? { standingReceiverCause: startingCause } : {}),
2720
+ });
2481
2721
  // MCP-002: Broadcast agent_state_changed to ALL connections
2482
2722
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
2483
- return { ok: true };
2723
+ if (receiverReady)
2724
+ return { ok: true, standing_receiver: "ready" };
2725
+ return {
2726
+ ok: true,
2727
+ standing_receiver: "starting",
2728
+ standing_receiver_cause: startingCause,
2729
+ // Invariant: an agent-facing response carries an affordance. Naming the refusal text is the
2730
+ // load-bearing half — an operator who hits it in the next second can otherwise only conclude
2731
+ // the other side is broken.
2732
+ guidance: `'${name}' is online and its standing receiver is still being built. Outbound sends and ` +
2733
+ `cello_initiate_session ensure it on demand, so ordinary use is fine. A session arriving in ` +
2734
+ `the next moment can be refused with 'standing_receiver_unavailable' — that is this daemon ` +
2735
+ `not being ready yet, NOT the counterparty being unreachable. It clears on its own; ` +
2736
+ `cello_status reports the receiver once it is up.`,
2737
+ };
2484
2738
  }
2485
2739
  // M8C-AUTOSTART-1 (F18): resolve which agent an agent-defaulting tool should act on for this
2486
2740
  // connection: an explicit { agent } wins; else the connection's current agent; else — when EXACTLY one
@@ -4535,7 +4789,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
4535
4789
  // (deliberate — f75ea09), so it obeys the same discipline a message does — including
4536
4790
  // being HELD when the position is ahead of the tail, which is why the real frame bytes
4537
4791
  // have to travel with it.
4538
- const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc");
4792
+ const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc",
4793
+ /**
4794
+ * No proof — and the reason I first wrote here was FALSE, which review traced rather
4795
+ * than read.
4796
+ *
4797
+ * ⚠️ IT SAID *"the document transport does not go through `sendContent`, so no Structure-1
4798
+ * was signed."* Both halves are wrong. `document-delivery-transport.ts` calls
4799
+ * `deps.sendContent(...)`, wired straight to `sessionNodeManager.sendContent` a few lines
4800
+ * above this; and `session-relay-client.ts` signs the Structure-1 with no `leafKind` gate
4801
+ * at all, so a `0x04` doc leaf is signed exactly like a message. **A proof exists and is
4802
+ * discarded here.**
4803
+ *
4804
+ * That matters more than a wrong comment usually would, because this unit's whole thesis
4805
+ * is *"`undefined` is a claim the author made rather than one the signature made for
4806
+ * them"* — and the first claim made under the new signature was untrue.
4807
+ *
4808
+ * THE TRUE REASON, which is a better one: **no consumer.** A doc leaf released from a
4809
+ * hold writes no transcript row — `#releaseHeld` skips `recordTranscriptMessage` for
4810
+ * `kind === "doc"` — so there is nothing for the proof to reach. Discarding it is
4811
+ * no-consumer-no-ship, deliberately.
4812
+ *
4813
+ * **If doc rows ever reach the transcript, `appendLeaf` needs an authorship parameter**
4814
+ * and this `undefined` becomes a defect rather than a decision.
4815
+ */
4816
+ undefined);
4539
4817
  return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
4540
4818
  },
4541
4819
  });
@@ -4781,6 +5059,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4781
5059
  async function stop(reason) {
4782
5060
  clearInterval(reconcileSweepTimer);
4783
5061
  clearInterval(revivalBoundSweepTimer);
5062
+ // DOD-M15-RELAYABUSE-1: scheduled park retries. Unref'd, so they never held the process open —
5063
+ // cleared so an in-process restart cannot leave one draining into a torn-down manager.
5064
+ for (const t of parkRetryTimers)
5065
+ clearTimeout(t);
5066
+ parkRetryTimers.clear();
4784
5067
  // DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
4785
5068
  // walks every agent, and each step dials a peer and opens a session — which is why a daemon
4786
5069
  // reported down, with its socket already removed, was still logging `document.reconcile.sweep`