@cello-protocol/daemon 0.0.169 → 0.0.170

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 (47) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +16 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/away-detection.d.ts +62 -15
  5. package/dist/away-detection.d.ts.map +1 -1
  6. package/dist/away-detection.js +77 -20
  7. package/dist/away-detection.js.map +1 -1
  8. package/dist/close-session-handler.d.ts.map +1 -1
  9. package/dist/close-session-handler.js +69 -3
  10. package/dist/close-session-handler.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +291 -38
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/delivery-open-registry.d.ts +92 -0
  15. package/dist/delivery-open-registry.d.ts.map +1 -0
  16. package/dist/delivery-open-registry.js +121 -0
  17. package/dist/delivery-open-registry.js.map +1 -0
  18. package/dist/document-delivery-transport.d.ts +10 -1
  19. package/dist/document-delivery-transport.d.ts.map +1 -1
  20. package/dist/document-delivery-transport.js +10 -1
  21. package/dist/document-delivery-transport.js.map +1 -1
  22. package/dist/document-layer.d.ts +21 -0
  23. package/dist/document-layer.d.ts.map +1 -1
  24. package/dist/document-layer.js +34 -1
  25. package/dist/document-layer.js.map +1 -1
  26. package/dist/document-reconcile-scheduler.d.ts +33 -0
  27. package/dist/document-reconcile-scheduler.d.ts.map +1 -1
  28. package/dist/document-reconcile-scheduler.js +73 -0
  29. package/dist/document-reconcile-scheduler.js.map +1 -1
  30. package/dist/inbound-sessions.d.ts +7 -0
  31. package/dist/inbound-sessions.d.ts.map +1 -1
  32. package/dist/inbound-sessions.js +27 -6
  33. package/dist/inbound-sessions.js.map +1 -1
  34. package/dist/notification-handlers.d.ts.map +1 -1
  35. package/dist/notification-handlers.js +39 -2
  36. package/dist/notification-handlers.js.map +1 -1
  37. package/dist/session-content-handlers.d.ts.map +1 -1
  38. package/dist/session-content-handlers.js +134 -8
  39. package/dist/session-content-handlers.js.map +1 -1
  40. package/dist/session-node-manager.d.ts +153 -8
  41. package/dist/session-node-manager.d.ts.map +1 -1
  42. package/dist/session-node-manager.js +1376 -35
  43. package/dist/session-node-manager.js.map +1 -1
  44. package/dist/types.d.ts +64 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js.map +1 -1
  47. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -91,7 +91,8 @@ import { startRegistryPoll } from "./registry-poll.js";
91
91
  import { CONSENT_ACCEPTED } from "./consent-migration.js";
92
92
  import { TrustSignalStore } from "./trust-signal-store.js";
93
93
  import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
94
- import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS } from "./away-detection.js";
94
+ import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
95
+ import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
95
96
  import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
96
97
  import { decodeCbor } from "@cello-protocol/protocol-types";
97
98
  // Minimal no-op KeyProvider stub for session nodes.
@@ -930,6 +931,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
930
931
  logger.info("session.away.mutual.skipped", {
931
932
  agentName,
932
933
  sessionId,
934
+ // DOD-M12B-AWAY-MARK-1: WHICH branch matched. The marker is in-band and therefore
935
+ // typeable, so prefixing every message with it skips the one-shot auto-close for free —
936
+ // the cost of that attack fell from "reproduce the exact away wording" to "type 15
937
+ // characters". This field is what makes a peer doing it on every message visible;
938
+ // without it the line reads as routine machine-to-machine traffic.
939
+ matched: isAutoReplyMarked(text) ? "marker" : "legacy_exact",
933
940
  impact: "no away reply and no one-shot seal — two away agents must not notarize a conversation nobody had",
934
941
  });
935
942
  return;
@@ -953,7 +960,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
953
960
  // Reviewer F2: token at the END — the daemon's own output must honor the
954
961
  // DOD-SIGNAL-TOKEN-1 append-at-end contract that DOD-WRAP-SUBSTRING-1 detection
955
962
  // is anchored on (a counterparty daemon's end-anchored detector must see this close).
956
- const rejectText = "This inbox only accepts one message per visit. Closing. [[WRAP]]";
963
+ // DOD-M12B-AWAY-MARK-1: machine-generated, so it is marked and the marker goes at the
964
+ // FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
965
+ const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
957
966
  const rejectBytes = new TextEncoder().encode(rejectText);
958
967
  const rejectHash = wireContentHash(rejectBytes);
959
968
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
@@ -966,9 +975,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
966
975
  // session is unsealable for good.
967
976
  if (sendResult.ok || sendResult.durable) {
968
977
  const rejectHashHex = Buffer.from(rejectHash).toString("hex");
969
- const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", rejectHashHex, randomUUID());
970
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", rejectBytes, randomUUID());
971
- logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: leafIndex, queued: !sendResult.ok });
978
+ // DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
979
+ // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
980
+ // wrong index does not merely stall the far side, it seals a tree the counterparty can
981
+ // never agree with.
982
+ const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID());
983
+ if (placed.placed) {
984
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
985
+ }
986
+ logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
972
987
  }
973
988
  else {
974
989
  // Not queued anywhere — the rejection is gone. We still seal (we are closing regardless),
@@ -1102,6 +1117,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1102
1117
  try {
1103
1118
  // DOD-AWAY-TIER-1: resolve most-specific-first — per-contact away_message → per-tier away
1104
1119
  // (settings) → agent default (settings) → the system default (code, per kind). Total.
1120
+ // DOD-M12B-AWAY-MARK-1: THE choke point. Every away reply this daemon sends passes through
1121
+ // this one expression — the per-contact message, the per-tier message, the agent default, the
1122
+ // system default and the stranger ack — so marking here is what makes "an operator's
1123
+ // CONFIGURED away message is indistinguishable from a person" go away, which is the half
1124
+ // exact-text matching could not reach by construction. markAsAutoReply is idempotent, so the
1125
+ // system defaults (already marked at their source) do not pick up a second token.
1105
1126
  const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
1106
1127
  ?? (isKnown ? systemDefault : STRANGER_TEXT);
1107
1128
  const draftBytes = new TextEncoder().encode(awayText);
@@ -1120,9 +1141,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
1120
1141
  logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
1121
1142
  return;
1122
1143
  }
1123
- const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
1144
+ // DOD-M12B-AWAY-MARK-1: mark AFTER screening, never before. A redact verdict REPLACES the
1145
+ // bytes, and marking the draft would let that replacement silently strip the marker — an away
1146
+ // reply back on the wire indistinguishable from a person, with no log line saying so. Marking
1147
+ // here also means the gateway screens the operator's actual disclosure rather than a daemon
1148
+ // token bolted to the front of it. markAsAutoReply is idempotent and the system defaults are
1149
+ // marked at their source, so nothing gains a second token.
1150
+ const screenedBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
1124
1151
  ? new Uint8Array(awayVerdict.content)
1125
1152
  : draftBytes;
1153
+ const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
1126
1154
  const contentHash = wireContentHash(contentBytes);
1127
1155
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1128
1156
  if (!sendResult.ok && !sendResult.durable) {
@@ -1148,17 +1176,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
1148
1176
  //
1149
1177
  // The dedup guard deliberately STAYS SET: a queued reply is coming, and re-sending on the
1150
1178
  // next arrival would mint a second greeting at a second sequence.
1151
- const { leafIndex: queuedLeaf } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1152
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, queuedLeaf, "sent", contentBytes, randomUUID());
1179
+ // DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
1180
+ // that is where its leaf goes.
1181
+ const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1182
+ if (placedQueued.placed) {
1183
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
1184
+ }
1153
1185
  logger.info("session.away.response.deferred", {
1154
- agentName, sessionId, kind, isKnown, sequenceNumber: queuedLeaf,
1186
+ agentName, sessionId, kind, isKnown,
1187
+ sequenceNumber: placedQueued.placed ? placedQueued.leafIndex : placedQueued.heldAt,
1188
+ committed: placedQueued.placed,
1155
1189
  reason: sendResult.reason, cause: sendResult.cause,
1156
1190
  });
1157
1191
  return;
1158
1192
  }
1159
- const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1160
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", contentBytes, randomUUID());
1161
- logger.info("session.away.response.sent", { agentName, sessionId, kind, isKnown, sequenceNumber: leafIndex });
1193
+ // DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
1194
+ // path most likely to have a gap open under it — exactly where a tail append does damage.
1195
+ const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1196
+ if (placedReply.placed) {
1197
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
1198
+ }
1199
+ logger.info("session.away.response.sent", {
1200
+ agentName, sessionId, kind, isKnown,
1201
+ sequenceNumber: placedReply.placed ? placedReply.leafIndex : placedReply.heldAt,
1202
+ committed: placedReply.placed,
1203
+ });
1162
1204
  }
1163
1205
  catch (err) {
1164
1206
  // Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
@@ -1215,15 +1257,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
1215
1257
  // agent restoration can dispatch state events before the scheduler exists.
1216
1258
  let reconcileScheduler;
1217
1259
  let documentOwnerKeyForHook;
1260
+ // DOD-M12B-DELIVERY-QUIET-1: which peers this daemon's document delivery worker is mid-dial to.
1261
+ // Read by BOTH reachability triggers and by the doorbell below. See delivery-open-registry.ts for
1262
+ // why this records an intent keyed on the peer rather than the session id — the short version is
1263
+ // that the directory mints the id and pushes the assignment to the counterparty before the opener
1264
+ // ever sees it, so on a one-daemon pair the inbound doorbell fires first and a session-id
1265
+ // registry is always too late.
1266
+ const deliveryOpens = createDeliveryOpenRegistry();
1267
+ /**
1268
+ * An agent's own pubkey, synchronously. The registry keys on PUBKEYS at both ends because that is
1269
+ * the only pair the dialling side and the accepting side can both name — the accepting side never
1270
+ * learns the dialler's local agent name, and the dialling side never learns ours.
1271
+ */
1272
+ const pubkeyOfAgent = (name) => loadedAgents.find((a) => a.name === name)?.pubkey ?? "";
1273
+ /**
1274
+ * Is a delivery worker mid-dial from `openerPubkey` to `targetPubkey`?
1275
+ *
1276
+ * One predicate, passed to every consumer, so the doorbell, the phone and both reachability
1277
+ * triggers cannot drift apart on the answer. An unresolvable agent yields "" and never matches,
1278
+ * which fails OPEN — an unknown agent rings rather than being silently muted.
1279
+ */
1280
+ const isDeliveryOpenInFlight = (openerPubkey, targetPubkey) => openerPubkey !== "" && targetPubkey !== "" && deliveryOpens.isDeliveryOpening(openerPubkey, targetPubkey);
1281
+ /**
1282
+ * The ACCEPTING side's question: "did a delivery worker on `openerPubkey`'s daemon dial the local
1283
+ * agent `targetAgentName`?"
1284
+ *
1285
+ * A named predicate rather than a reversed call, because getting the order wrong here is exactly
1286
+ * the defect this line was re-opened to fix: the accepting side naturally reaches for its own
1287
+ * agent first, which compares the receiver's tuple against a registry holding the dialler's and
1288
+ * silently never matches.
1289
+ */
1290
+ const isDeliveryOpenToAgent = (openerPubkey, targetAgentName) => isDeliveryOpenInFlight(openerPubkey, pubkeyOfAgent(targetAgentName));
1218
1291
  function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
1292
+ // DOD-M12B-DELIVERY-QUIET-1: this session came up because a DELIVERY WORKER dialled, so its
1293
+ // creation is machine traffic — not news about the peer, not a conversation, and not something
1294
+ // to buzz a phone about. Scoped to `created` on purpose: a session going DOWN is news whoever
1295
+ // opened it, and it is the operator's only signal that a conversation was cut off.
1296
+ //
1297
+ // ARGUMENTS ARE REVERSED HERE, AND THAT IS THE POINT. This function runs on the side that
1298
+ // ACCEPTED, so the opener is the COUNTERPARTY and the target is US. Asking
1299
+ // `(agentName, counterpartyPubkey)` — our own agent first, like the initiator half does — is
1300
+ // what made the first version of this guard unreachable in production: it compared the
1301
+ // receiver's tuple against a registry holding the dialler's.
1302
+ if (state === "created" && counterpartyPubkey !== null && isDeliveryOpenToAgent(counterpartyPubkey, agentName)) {
1303
+ // Suppression is LOGGED. A doorbell that silently stops ringing is the next defect, and the
1304
+ // measured storm was so hard to read precisely because nothing named its cause.
1305
+ logger.info("session.doorbell.suppressed_delivery", {
1306
+ agentName, sessionId, state, opener: counterpartyPubkey.slice(0, 16),
1307
+ impact: "document delivery opened this session — no doorbell, no phone push, no backoff reset",
1308
+ });
1309
+ return;
1310
+ }
1219
1311
  // MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
1220
- // offered-name drop below so a terminal state's own doorbell still shows the name.
1312
+ // offered-name drop below so a terminal state's own doorbell still shows the name. Below the
1313
+ // guard: on a suppressed event it was computed and thrown away.
1221
1314
  const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
1222
1315
  // SYNC-P5 (R39): a session coming up IS the party-became-reachable signal — every shared
1223
1316
  // document gets a reconcile attempt, and the scheduler's backoff resets (they just answered).
1317
+ // Sound ONLY when the peer caused it, which is what the guard above establishes.
1224
1318
  if (reconcileScheduler && documentOwnerKeyForHook && counterpartyPubkey && state === "created") {
1225
1319
  const ownerAgentId = documentOwnerKeyForHook(agentName);
1226
1320
  if (ownerAgentId !== null) {
1321
+ // DOD-M12B-DELIVERY-QUIET-1: the trigger FIRING is logged, not only its failure. This is
1322
+ // the storm driver — it zeroes the backoff and sweeps every shared document — and it was
1323
+ // invisible in the log unless it threw, so 321 attempts in 85 minutes named no cause. It is
1324
+ // also the only way either direction of the delivery exemption is observable.
1325
+ logger.info("document.reconcile.reachable_trigger_fired", {
1326
+ agentName, trigger: "inbound_session_created", peer: counterpartyPubkey.slice(0, 16),
1327
+ });
1227
1328
  void reconcileScheduler
1228
1329
  .onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
1229
1330
  .catch((err) => {
@@ -1624,7 +1725,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1624
1725
  // down below the module. Construction is synchronous and its deps are all ready — the handlers
1625
1726
  // it registers are fine this early because the handler map already exists.
1626
1727
  // Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
1627
- const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
1728
+ const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, recordRefusal, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
1628
1729
  logger,
1629
1730
  sessionNodeManager,
1630
1731
  agents,
@@ -1641,6 +1742,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1641
1742
  sendAwayResponse,
1642
1743
  dispatchSessionStateChangedWithTelegram,
1643
1744
  sendTelegramDoorbell,
1745
+ isDeliveryOpenToAgent,
1644
1746
  });
1645
1747
  if (!sharedSignaling) {
1646
1748
  for (const agent of loadedAgents) {
@@ -1687,6 +1789,29 @@ async function startDaemonHoldingLock(config, singletonLock) {
1687
1789
  // unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
1688
1790
  // / cello_list_sessions (with filter + limit flags).
1689
1791
  const STATUS_RESUMABLE_CAP = 10;
1792
+ /**
1793
+ * DOD-M12B-SEAL-STUCK-1 — ask the seal gate for ONE session, and never let the answer take the
1794
+ * status response with it.
1795
+ *
1796
+ * This runs per session on every `cello status` and every `cello_status`, and it touches the
1797
+ * database. An unguarded throw here rejects the whole response, after which the CLI finds the
1798
+ * singleton lock still held and prints `daemon: "broken_shutdown"` — telling the operator their
1799
+ * healthy daemon has failed to stop, over one session row it could not read. There is a
1800
+ * documented precedent in this codebase for exactly that shape.
1801
+ *
1802
+ * A failure yields `unknown`, never `ready`: "we could not check" must not read as "safe to
1803
+ * close", because a close on a short chain is terminal.
1804
+ */
1805
+ function probeSealReadiness(agentName, sessionId) {
1806
+ try {
1807
+ return sessionNodeManager.sealReadinessView(agentName, sessionId);
1808
+ }
1809
+ catch (err) {
1810
+ const message = err instanceof Error ? err.message : String(err);
1811
+ logger.warn("session.seal.readiness.probe.failed", { agentName, sessionId, error: message });
1812
+ return { state: "unknown", reason: `probe_failed: ${message}` };
1813
+ }
1814
+ }
1690
1815
  function buildInterruptedSessions() {
1691
1816
  return sessionNodeManager
1692
1817
  .getSessionsByStatus("interrupted")
@@ -1701,6 +1826,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1701
1826
  // DOD-SESSION-NAME-1 (AC-A11): an interrupted session is one you may want to resume or seal
1702
1827
  // — the name is how you tell which one it was.
1703
1828
  sessionName: row.session_name ?? null,
1829
+ // DOD-M12B-SEAL-STUCK-1: the same answer the active list carries, for the same reason —
1830
+ // an interrupted session can seal, so it can also be blocked from sealing by a gap, and
1831
+ // that is knowable here instead of only after a failed close. A plain property, never a
1832
+ // spread: a spread bypasses excess-property checking, which is how `frontierMismatch`
1833
+ // below came to typecheck while no renderer could read it.
1834
+ sealReadiness: probeSealReadiness(row.agent_name, row.session_id),
1704
1835
  // DOD-FRONTIER-STRAND-1 AC3: if a seal exchange has already proved the two sides disagree on
1705
1836
  // how many messages this session holds, SAY SO HERE. Otherwise a stranded session is listed
1706
1837
  // exactly like a healthy paused one, and the only way to learn it can never seal is to
@@ -1732,7 +1863,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1732
1863
  continue;
1733
1864
  if (now - row.created_at <= HALF_OPEN_TTL_MS)
1734
1865
  continue; // too young — may just be setting up
1735
- if (sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id) === "alive")
1866
+ // DOD-M12B-ACK-1: 'impaired' counts as live HERE. This reaper's question is "did the
1867
+ // counterparty ever establish?", and impaired means it did — the connection is up, only
1868
+ // delivery on it is failing. Reaping on impaired would abandon exactly the sessions this
1869
+ // milestone exists to repair.
1870
+ const liveness = sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id);
1871
+ if (liveness === "alive" || liveness === "impaired")
1736
1872
  continue; // live
1737
1873
  if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
1738
1874
  continue; // counterparty spoke
@@ -1757,13 +1893,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
1757
1893
  // was invisible to the operator.
1758
1894
  function buildActiveSessions() {
1759
1895
  reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
1760
- return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
1761
- sessionId: row.session_id,
1762
- agentName: row.agent_name,
1763
- counterpartyPubkey: row.counterparty_pubkey,
1764
- liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
1765
- sessionName: row.session_name ?? null, // DOD-SESSION-NAME-1 (AC-A11)
1766
- }));
1896
+ return sessionNodeManager.getSessionsByStatus("active").map((row) => {
1897
+ // DOD-M12B-SEAL-STUCK-1: ask the SAME gate the close path asks, so the surface and the
1898
+ // refusal can never disagree. Computed here rather than cached because the answer changes
1899
+ // the moment a gap fills, and a stale "stuck" is its own lie.
1900
+ return {
1901
+ sessionId: row.session_id,
1902
+ agentName: row.agent_name,
1903
+ counterpartyPubkey: row.counterparty_pubkey,
1904
+ liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
1905
+ sessionName: row.session_name ?? null, // DOD-SESSION-NAME-1 (AC-A11)
1906
+ sealReadiness: probeSealReadiness(row.agent_name, row.session_id),
1907
+ };
1908
+ });
1767
1909
  }
1768
1910
  /**
1769
1911
  * The state an operator sees, for one agent — used by BOTH status surfaces.
@@ -2949,9 +3091,24 @@ async function startDaemonHoldingLock(config, singletonLock) {
2949
3091
  onSessionOpened: (agentName, counterpartyPubkey) => {
2950
3092
  if (!reconcileScheduler || !documentOwnerKeyForHook)
2951
3093
  return;
3094
+ // DOD-M12B-DELIVERY-QUIET-1: the INITIATOR half of the same circularity. This hook fires
3095
+ // inside openSessionAs, which the delivery worker's adapter brackets with begin/release — so
3096
+ // the intent is still in flight here and the check needs no plumbing of its own. Resetting
3097
+ // the backoff on a session WE opened to deliver a frame wipes a refusal the peer may have
3098
+ // given seconds ago and immediately sweeps every document, which opens more sessions.
3099
+ // We are the OPENER here, so our own pubkey goes first — the mirror of the inbound half above.
3100
+ if (isDeliveryOpenInFlight(pubkeyOfAgent(agentName), counterpartyPubkey)) {
3101
+ logger.debug("document.reconcile.reachable_trigger_skipped", {
3102
+ agentName, reason: "session_opened_by_document_delivery",
3103
+ });
3104
+ return;
3105
+ }
2952
3106
  const ownerAgentId = documentOwnerKeyForHook(agentName);
2953
3107
  if (ownerAgentId === null)
2954
3108
  return;
3109
+ logger.info("document.reconcile.reachable_trigger_fired", {
3110
+ agentName, trigger: "outbound_session_opened", peer: counterpartyPubkey.slice(0, 16),
3111
+ });
2955
3112
  void reconcileScheduler
2956
3113
  .onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
2957
3114
  .catch((err) => {
@@ -3178,6 +3335,45 @@ async function startDaemonHoldingLock(config, singletonLock) {
3178
3335
  }
3179
3336
  return { ok: true };
3180
3337
  });
3338
+ // DOD-M12B-DELIVERY-QUIET-1: drive the delivery-open intent directly, so the doorbell exemption
3339
+ // is testable without standing up a document, a peer daemon and a real directory negotiation.
3340
+ // The registry's begin() hands back a closure, so the releases are held here by key.
3341
+ // The hook takes AGENT NAMES and resolves both to pubkeys through `pubkeyOfAgent`, exactly as the
3342
+ // real delivery adapter does. That is deliberate: the first version of this hook let a test
3343
+ // register a tuple production never produces — an agent registered as the dialler and then
3344
+ // emitted as the receiver of its own dial — so the suite proved the guard's boolean logic and
3345
+ // nothing about the wiring, and stayed green against a guard that could never fire.
3346
+ const testDeliveryReleases = new Map();
3347
+ // DOD-M12B-INBOX-TRUTH-1: seed a refusal, so the ended-unread return branch's lost
3348
+ // `refused_session_requests` has a regression test. The field had none before or after the fix.
3349
+ handlers.set("__test_record_refusal", async (params, _connectionId) => {
3350
+ const agentName = params?.agentName;
3351
+ const sessionId = params?.sessionId;
3352
+ if (!agentName || !sessionId)
3353
+ return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3354
+ recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", params?.reason ?? "test");
3355
+ return { ok: true };
3356
+ });
3357
+ handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
3358
+ const openerAgent = params?.openerAgent;
3359
+ const targetAgent = params?.targetAgent;
3360
+ if (!openerAgent || !targetAgent)
3361
+ return { error: "missing_params", guidance: "Provide openerAgent and targetAgent (both agent NAMES)." };
3362
+ const openerPubkey = pubkeyOfAgent(openerAgent);
3363
+ const targetPubkey = pubkeyOfAgent(targetAgent);
3364
+ testDeliveryReleases.set(`${openerAgent}>${targetAgent}`, deliveryOpens.begin(openerPubkey, targetPubkey));
3365
+ return { ok: true, openerPubkey, targetPubkey };
3366
+ });
3367
+ handlers.set("__test_delivery_open_end", async (params, _connectionId) => {
3368
+ const openerAgent = params?.openerAgent;
3369
+ const targetAgent = params?.targetAgent;
3370
+ if (!openerAgent || !targetAgent)
3371
+ return { error: "missing_params", guidance: "Provide openerAgent and targetAgent (both agent NAMES)." };
3372
+ const key = `${openerAgent}>${targetAgent}`;
3373
+ testDeliveryReleases.get(key)?.();
3374
+ testDeliveryReleases.delete(key);
3375
+ return { ok: true };
3376
+ });
3181
3377
  // M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
3182
3378
  // flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
3183
3379
  // without standing up the full libp2p inbound path.
@@ -3343,14 +3539,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
3343
3539
  };
3344
3540
  // Create and start IPC server
3345
3541
  const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
3346
- try {
3347
- await ipcServer.start();
3348
- }
3349
- catch (err) {
3350
- // As above: startDaemon's catch releases the singleton lock for us.
3351
- await removeLockIfOwned(lockFilePath, process.pid, logger);
3352
- throw err;
3353
- }
3542
+ // THE SOCKET OPENS LAST — see the deferred start below. Accepting clients here would accept
3543
+ // `cello_start_agent`, which brings an agent online, creates its standing receiver, and drains
3544
+ // parked content — all of it arriving before the document frame hook exists.
3354
3545
  // MCP-002: Instantiate NotificationDispatcher (wired to IPC server)
3355
3546
  const notificationDispatcher = new NotificationDispatcher({
3356
3547
  logger,
@@ -3420,6 +3611,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
3420
3611
  });
3421
3612
  return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
3422
3613
  },
3614
+ // Read LAZILY: the scheduler is constructed after this layer (it consumes the layer's sweep
3615
+ // targets), so the binding must be resolved at call time rather than captured here.
3616
+ onPeerRefusal: (ownerAgentId, peerAgentId, terminal) => reconcileScheduler?.noteRefusal(ownerAgentId, peerAgentId, terminal),
3423
3617
  // ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
3424
3618
  // how the surface tests passed while the feature did nothing: the test wired this seam to
3425
3619
  // `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
@@ -3499,13 +3693,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
3499
3693
  .map((row) => row.session_id)
3500
3694
  .reverse(),
3501
3695
  openSession: async (agent, peerAgentId, correlationId) => {
3502
- // The same path `cello_initiate_session` takes, now callable without an IPC connection.
3503
- const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
3504
- if (res.ok !== true || typeof res.sessionId !== "string") {
3505
- return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
3696
+ // DOD-M12B-DELIVERY-QUIET-1: THIS is the only place the delivery worker opens a session,
3697
+ // so bracketing it is what makes "who opened this" knowable everywhere else. The window
3698
+ // must cover the whole open the inbound doorbell on a co-resident pair fires DURING
3699
+ // negotiation, and the initiator-side reachability hook fires just before this returns.
3700
+ // Released in `finally`: a throw that left the intent registered would silence this
3701
+ // peer's doorbell for the life of the process, which is a worse outage than the storm.
3702
+ const releaseDeliveryOpen = deliveryOpens.begin(pubkeyOfAgent(agent), peerAgentId);
3703
+ try {
3704
+ // The same path `cello_initiate_session` takes, now callable without an IPC connection.
3705
+ const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
3706
+ if (res.ok !== true || typeof res.sessionId !== "string") {
3707
+ return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
3708
+ }
3709
+ logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
3710
+ return { ok: true, sessionId: res.sessionId };
3711
+ }
3712
+ finally {
3713
+ releaseDeliveryOpen();
3506
3714
  }
3507
- logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
3508
- return { ok: true, sessionId: res.sessionId };
3509
3715
  },
3510
3716
  sealSession: async (agent, sessionId, correlationId) => {
3511
3717
  // §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
@@ -3536,8 +3742,13 @@ async function startDaemonHoldingLock(config, singletonLock) {
3536
3742
  // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3537
3743
  // successful send. See the comment at the call site for why this is delivery-critical and
3538
3744
  // not audit bookkeeping.
3539
- appendLeaf: (agent, sessionId, contentHash, correlationId) => {
3540
- sessionNodeManager.appendSessionLeaf(agent, sessionId, "doc", Buffer.from(contentHash).toString("hex"), correlationId);
3745
+ appendLeaf: (agent, sessionId, contentHash, frameBytes, correlationId, assignedSeq) => {
3746
+ // DOD-M12B-INDEX-1: a document leaf takes a position in the CONVERSATION's sequence space
3747
+ // (deliberate — f75ea09), so it obeys the same discipline a message does — including
3748
+ // being HELD when the position is ahead of the tail, which is why the real frame bytes
3749
+ // have to travel with it.
3750
+ const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc");
3751
+ return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
3541
3752
  },
3542
3753
  });
3543
3754
  documentTransports.set(agentName, transport);
@@ -3673,6 +3884,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
3673
3884
  }, RECONCILE_SWEEP_MS);
3674
3885
  reconcileSweepTimer.unref?.();
3675
3886
  documentOwnerKeyForHook = documentOwnerKeyFor;
3887
+ // ── THE SOCKET OPENS ONLY NOW, and the ordering is load-bearing (2026-08-16). ──
3888
+ //
3889
+ // This used to run ~120 lines earlier, immediately after the server was created — before
3890
+ // `setOnDocumentFrame` above. That opened a window on every daemon start: a client connects,
3891
+ // calls `cello_start_agent`, the agent comes online, its standing receiver drains whatever the
3892
+ // relay parked — and any DOCUMENT frame in that drain found `#onDocumentFrame` still unset. The
3893
+ // routing fork in session-node-manager treats an unconsumed frame as conversation, so those
3894
+ // frames were appended as `msg` leaves, written to the durable transcript, rang the doorbell,
3895
+ // and were handed to the agent by `cello_receive` as raw canonical CBOR.
3896
+ //
3897
+ // Reported from the live fleet by a counterparty who pasted the bytes back
3898
+ // (`dtyperdocument_reconcile…`), reproduced in `j-stale-session.spine.test.ts`, and it is why
3899
+ // documents accumulated unaccepted: the frames were not lost in transit, they were EATEN as
3900
+ // messages, so the document layer never saw them while the sender was told they were delivered.
3901
+ //
3902
+ // The comment at that fork claimed an unwired hook "cannot change the conversation path". It
3903
+ // can, and did. Nothing between here and the server's creation needs a live socket, so the
3904
+ // honest fix is to finish wiring before anyone can knock.
3905
+ try {
3906
+ await ipcServer.start();
3907
+ }
3908
+ catch (err) {
3909
+ // As above: startDaemon's catch releases the singleton lock for us.
3910
+ await removeLockIfOwned(lockFilePath, process.pid, logger);
3911
+ throw err;
3912
+ }
3676
3913
  // MCP-001: Clean up per-connection state when a connection disconnects
3677
3914
  // MCP-002: Also unregister from notification dispatcher
3678
3915
  ipcServer.onDisconnect((connectionId) => {
@@ -3731,6 +3968,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
3731
3968
  let stoppedHookFired = false;
3732
3969
  async function stop(reason) {
3733
3970
  clearInterval(reconcileSweepTimer);
3971
+ // DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
3972
+ // walks every agent, and each step dials a peer and opens a session — which is why a daemon
3973
+ // reported down, with its socket already removed, was still logging `document.reconcile.sweep`
3974
+ // 30 seconds later and had to be signalled to exit. This is what stops the pass in flight, and
3975
+ // it also blocks `onReachable`, so a session tearing down during shutdown cannot hand the
3976
+ // sweeper a fresh reason to dial on the way out.
3977
+ // `?.` is a TYPE requirement, not a runtime one: TypeScript discards the narrowing of a
3978
+ // captured `let` inside a hoisted function declaration, though it keeps it in the arrow a few
3979
+ // lines up. The scheduler is in fact always wired by the time this can run — the IPC socket,
3980
+ // the only route to `stop` besides the returned handle, opens after it.
3981
+ reconcileScheduler?.stop();
3982
+ // DOD-M12B-SHUTDOWN-1: the scheduler is one of FOUR callers of initiateReconcile. `nudgeSeats`
3983
+ // and the two invite notices reach it directly, and every document verb is still served while
3984
+ // the rest of this function runs — the IPC server is the last thing stopped. Refusing at the
3985
+ // choke point is what actually closes "no new outbound work".
3986
+ documentLayer?.stopReconciling();
3734
3987
  // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
3735
3988
  // the generation so the running loop's while-condition fails on its next check.
3736
3989
  stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check