@cello-protocol/daemon 0.0.168 → 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 (111) 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 +406 -303
  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-amendment-store.d.ts +94 -46
  19. package/dist/document-amendment-store.d.ts.map +1 -1
  20. package/dist/document-amendment-store.js +217 -102
  21. package/dist/document-amendment-store.js.map +1 -1
  22. package/dist/document-delivery-transport.d.ts +51 -53
  23. package/dist/document-delivery-transport.d.ts.map +1 -1
  24. package/dist/document-delivery-transport.js +16 -157
  25. package/dist/document-delivery-transport.js.map +1 -1
  26. package/dist/document-frame-router.d.ts +13 -17
  27. package/dist/document-frame-router.d.ts.map +1 -1
  28. package/dist/document-frame-router.js +21 -93
  29. package/dist/document-frame-router.js.map +1 -1
  30. package/dist/document-handlers.d.ts +1 -1
  31. package/dist/document-handlers.d.ts.map +1 -1
  32. package/dist/document-handlers.js +462 -511
  33. package/dist/document-handlers.js.map +1 -1
  34. package/dist/document-handshake.d.ts +4 -1
  35. package/dist/document-handshake.d.ts.map +1 -1
  36. package/dist/document-handshake.js +3 -1
  37. package/dist/document-handshake.js.map +1 -1
  38. package/dist/document-inbound.d.ts +18 -5
  39. package/dist/document-inbound.d.ts.map +1 -1
  40. package/dist/document-inbound.js +94 -110
  41. package/dist/document-inbound.js.map +1 -1
  42. package/dist/document-layer.d.ts +49 -67
  43. package/dist/document-layer.d.ts.map +1 -1
  44. package/dist/document-layer.js +688 -565
  45. package/dist/document-layer.js.map +1 -1
  46. package/dist/document-lifecycle.d.ts +11 -157
  47. package/dist/document-lifecycle.d.ts.map +1 -1
  48. package/dist/document-lifecycle.js +26 -568
  49. package/dist/document-lifecycle.js.map +1 -1
  50. package/dist/document-publish.d.ts +13 -0
  51. package/dist/document-publish.d.ts.map +1 -1
  52. package/dist/document-publish.js +19 -11
  53. package/dist/document-publish.js.map +1 -1
  54. package/dist/document-reconcile-engine.d.ts +66 -0
  55. package/dist/document-reconcile-engine.d.ts.map +1 -0
  56. package/dist/document-reconcile-engine.js +225 -0
  57. package/dist/document-reconcile-engine.js.map +1 -0
  58. package/dist/document-reconcile-scheduler.d.ts +124 -0
  59. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  60. package/dist/document-reconcile-scheduler.js +226 -0
  61. package/dist/document-reconcile-scheduler.js.map +1 -0
  62. package/dist/document-rejection.d.ts +2 -0
  63. package/dist/document-rejection.d.ts.map +1 -1
  64. package/dist/document-rejection.js +5 -1
  65. package/dist/document-rejection.js.map +1 -1
  66. package/dist/document-store.d.ts +18 -247
  67. package/dist/document-store.d.ts.map +1 -1
  68. package/dist/document-store.js +95 -827
  69. package/dist/document-store.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +7 -0
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +27 -6
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/initiate-session-handler.d.ts +5 -0
  75. package/dist/initiate-session-handler.d.ts.map +1 -1
  76. package/dist/initiate-session-handler.js +1 -0
  77. package/dist/initiate-session-handler.js.map +1 -1
  78. package/dist/notification-handlers.d.ts.map +1 -1
  79. package/dist/notification-handlers.js +39 -2
  80. package/dist/notification-handlers.js.map +1 -1
  81. package/dist/session-content-handlers.d.ts.map +1 -1
  82. package/dist/session-content-handlers.js +134 -8
  83. package/dist/session-content-handlers.js.map +1 -1
  84. package/dist/session-node-manager.d.ts +153 -8
  85. package/dist/session-node-manager.d.ts.map +1 -1
  86. package/dist/session-node-manager.js +1376 -35
  87. package/dist/session-node-manager.js.map +1 -1
  88. package/dist/types.d.ts +64 -2
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/package.json +5 -5
  92. package/dist/delivery-sweep-bound.d.ts +0 -79
  93. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  94. package/dist/delivery-sweep-bound.js +0 -109
  95. package/dist/delivery-sweep-bound.js.map +0 -1
  96. package/dist/document-ack-inbound.d.ts +0 -71
  97. package/dist/document-ack-inbound.d.ts.map +0 -1
  98. package/dist/document-ack-inbound.js +0 -210
  99. package/dist/document-ack-inbound.js.map +0 -1
  100. package/dist/document-control-notifier.d.ts +0 -124
  101. package/dist/document-control-notifier.d.ts.map +0 -1
  102. package/dist/document-control-notifier.js +0 -176
  103. package/dist/document-control-notifier.js.map +0 -1
  104. package/dist/document-delivery.d.ts +0 -250
  105. package/dist/document-delivery.d.ts.map +0 -1
  106. package/dist/document-delivery.js +0 -608
  107. package/dist/document-delivery.js.map +0 -1
  108. package/dist/document-join-store.d.ts +0 -80
  109. package/dist/document-join-store.d.ts.map +0 -1
  110. package/dist/document-join-store.js +0 -197
  111. package/dist/document-join-store.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -67,13 +67,10 @@ import { createTelegramDoorbell } from "./telegram-doorbell.js";
67
67
  import { registerSessionContentHandlers } from "./session-content-handlers.js";
68
68
  import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
69
69
  import { registerDocumentHandlers } from "./document-handlers.js";
70
- import { createDocumentControlNotifier } from "./document-control-notifier.js";
71
70
  import { wireContentHash } from "./wire-content-hash.js";
72
71
  import { DocumentPublish } from "./document-publish.js";
73
72
  import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
74
- import { DocumentDelivery } from "./document-delivery.js";
75
- import { sweepAgentEvicting } from "./delivery-sweep-bound.js";
76
- import { encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1 } from "@cello-protocol/protocol-types";
73
+ import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
77
74
  import { createSealFlows } from "./seal-flows.js";
78
75
  import { registerCloseSessionHandler } from "./close-session-handler.js";
79
76
  import { createInboundSessions } from "./inbound-sessions.js";
@@ -94,7 +91,8 @@ import { startRegistryPoll } from "./registry-poll.js";
94
91
  import { CONSENT_ACCEPTED } from "./consent-migration.js";
95
92
  import { TrustSignalStore } from "./trust-signal-store.js";
96
93
  import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
97
- 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";
98
96
  import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
99
97
  import { decodeCbor } from "@cello-protocol/protocol-types";
100
98
  // Minimal no-op KeyProvider stub for session nodes.
@@ -933,6 +931,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
933
931
  logger.info("session.away.mutual.skipped", {
934
932
  agentName,
935
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",
936
940
  impact: "no away reply and no one-shot seal — two away agents must not notarize a conversation nobody had",
937
941
  });
938
942
  return;
@@ -956,7 +960,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
956
960
  // Reviewer F2: token at the END — the daemon's own output must honor the
957
961
  // DOD-SIGNAL-TOKEN-1 append-at-end contract that DOD-WRAP-SUBSTRING-1 detection
958
962
  // is anchored on (a counterparty daemon's end-anchored detector must see this close).
959
- 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]]");
960
966
  const rejectBytes = new TextEncoder().encode(rejectText);
961
967
  const rejectHash = wireContentHash(rejectBytes);
962
968
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
@@ -969,9 +975,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
969
975
  // session is unsealable for good.
970
976
  if (sendResult.ok || sendResult.durable) {
971
977
  const rejectHashHex = Buffer.from(rejectHash).toString("hex");
972
- const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", rejectHashHex, randomUUID());
973
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", rejectBytes, randomUUID());
974
- 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 });
975
987
  }
976
988
  else {
977
989
  // Not queued anywhere — the rejection is gone. We still seal (we are closing regardless),
@@ -1105,6 +1117,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1105
1117
  try {
1106
1118
  // DOD-AWAY-TIER-1: resolve most-specific-first — per-contact away_message → per-tier away
1107
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.
1108
1126
  const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
1109
1127
  ?? (isKnown ? systemDefault : STRANGER_TEXT);
1110
1128
  const draftBytes = new TextEncoder().encode(awayText);
@@ -1123,9 +1141,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
1123
1141
  logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
1124
1142
  return;
1125
1143
  }
1126
- 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
1127
1151
  ? new Uint8Array(awayVerdict.content)
1128
1152
  : draftBytes;
1153
+ const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
1129
1154
  const contentHash = wireContentHash(contentBytes);
1130
1155
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1131
1156
  if (!sendResult.ok && !sendResult.durable) {
@@ -1151,17 +1176,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
1151
1176
  //
1152
1177
  // The dedup guard deliberately STAYS SET: a queued reply is coming, and re-sending on the
1153
1178
  // next arrival would mint a second greeting at a second sequence.
1154
- const { leafIndex: queuedLeaf } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1155
- 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
+ }
1156
1185
  logger.info("session.away.response.deferred", {
1157
- agentName, sessionId, kind, isKnown, sequenceNumber: queuedLeaf,
1186
+ agentName, sessionId, kind, isKnown,
1187
+ sequenceNumber: placedQueued.placed ? placedQueued.leafIndex : placedQueued.heldAt,
1188
+ committed: placedQueued.placed,
1158
1189
  reason: sendResult.reason, cause: sendResult.cause,
1159
1190
  });
1160
1191
  return;
1161
1192
  }
1162
- const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1163
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", contentBytes, randomUUID());
1164
- 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
+ });
1165
1204
  }
1166
1205
  catch (err) {
1167
1206
  // Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
@@ -1213,10 +1252,88 @@ async function startDaemonHoldingLock(config, singletonLock) {
1213
1252
  });
1214
1253
  return { who: resolved.who, whoKnown: resolved.whoKnown };
1215
1254
  }
1255
+ // SYNC-P5: assigned when the document layer is wired (below); the state-change hook runs for
1256
+ // sessions, which exist only after startup completes — the guard covers the boot window where
1257
+ // agent restoration can dispatch state events before the scheduler exists.
1258
+ let reconcileScheduler;
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));
1216
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
+ }
1217
1311
  // MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
1218
- // 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.
1219
1314
  const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
1315
+ // SYNC-P5 (R39): a session coming up IS the party-became-reachable signal — every shared
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.
1318
+ if (reconcileScheduler && documentOwnerKeyForHook && counterpartyPubkey && state === "created") {
1319
+ const ownerAgentId = documentOwnerKeyForHook(agentName);
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
+ });
1328
+ void reconcileScheduler
1329
+ .onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
1330
+ .catch((err) => {
1331
+ logger.warn("document.reconcile.reachable_trigger_failed", {
1332
+ agentName, reason: err instanceof Error ? err.message : String(err),
1333
+ });
1334
+ });
1335
+ }
1336
+ }
1220
1337
  notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey, who);
1221
1338
  void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
1222
1339
  // Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
@@ -1608,7 +1725,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1608
1725
  // down below the module. Construction is synchronous and its deps are all ready — the handlers
1609
1726
  // it registers are fine this early because the handler map already exists.
1610
1727
  // Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
1611
- 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({
1612
1729
  logger,
1613
1730
  sessionNodeManager,
1614
1731
  agents,
@@ -1625,6 +1742,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1625
1742
  sendAwayResponse,
1626
1743
  dispatchSessionStateChangedWithTelegram,
1627
1744
  sendTelegramDoorbell,
1745
+ isDeliveryOpenToAgent,
1628
1746
  });
1629
1747
  if (!sharedSignaling) {
1630
1748
  for (const agent of loadedAgents) {
@@ -1671,6 +1789,29 @@ async function startDaemonHoldingLock(config, singletonLock) {
1671
1789
  // unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
1672
1790
  // / cello_list_sessions (with filter + limit flags).
1673
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
+ }
1674
1815
  function buildInterruptedSessions() {
1675
1816
  return sessionNodeManager
1676
1817
  .getSessionsByStatus("interrupted")
@@ -1685,6 +1826,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1685
1826
  // DOD-SESSION-NAME-1 (AC-A11): an interrupted session is one you may want to resume or seal
1686
1827
  // — the name is how you tell which one it was.
1687
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),
1688
1835
  // DOD-FRONTIER-STRAND-1 AC3: if a seal exchange has already proved the two sides disagree on
1689
1836
  // how many messages this session holds, SAY SO HERE. Otherwise a stranded session is listed
1690
1837
  // exactly like a healthy paused one, and the only way to learn it can never seal is to
@@ -1716,7 +1863,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1716
1863
  continue;
1717
1864
  if (now - row.created_at <= HALF_OPEN_TTL_MS)
1718
1865
  continue; // too young — may just be setting up
1719
- 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")
1720
1872
  continue; // live
1721
1873
  if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
1722
1874
  continue; // counterparty spoke
@@ -1741,13 +1893,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
1741
1893
  // was invisible to the operator.
1742
1894
  function buildActiveSessions() {
1743
1895
  reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
1744
- return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
1745
- sessionId: row.session_id,
1746
- agentName: row.agent_name,
1747
- counterpartyPubkey: row.counterparty_pubkey,
1748
- liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
1749
- sessionName: row.session_name ?? null, // DOD-SESSION-NAME-1 (AC-A11)
1750
- }));
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
+ });
1751
1909
  }
1752
1910
  /**
1753
1911
  * The state an operator sees, for one agent — used by BOTH status surfaces.
@@ -2927,6 +3085,38 @@ async function startDaemonHoldingLock(config, singletonLock) {
2927
3085
  handlers,
2928
3086
  logger,
2929
3087
  sessionNodeManager,
3088
+ // SYNC-P5 (R39, review F5): the INITIATOR-side reachable trigger — the inbound side fires
3089
+ // from the session-state dispatch; without this half, only the answering daemon reconciled
3090
+ // on session-up and the initiator waited out a sweep interval.
3091
+ onSessionOpened: (agentName, counterpartyPubkey) => {
3092
+ if (!reconcileScheduler || !documentOwnerKeyForHook)
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
+ }
3106
+ const ownerAgentId = documentOwnerKeyForHook(agentName);
3107
+ if (ownerAgentId === null)
3108
+ return;
3109
+ logger.info("document.reconcile.reachable_trigger_fired", {
3110
+ agentName, trigger: "outbound_session_opened", peer: counterpartyPubkey.slice(0, 16),
3111
+ });
3112
+ void reconcileScheduler
3113
+ .onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
3114
+ .catch((err) => {
3115
+ logger.warn("document.reconcile.reachable_trigger_failed", {
3116
+ agentName, reason: err instanceof Error ? err.message : String(err),
3117
+ });
3118
+ });
3119
+ },
2930
3120
  getConnState: (connectionId) => perConnectionState.get(connectionId),
2931
3121
  resolveCurrentAgent,
2932
3122
  NO_CURRENT_AGENT_RESPONSE,
@@ -3145,6 +3335,45 @@ async function startDaemonHoldingLock(config, singletonLock) {
3145
3335
  }
3146
3336
  return { ok: true };
3147
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
+ });
3148
3377
  // M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
3149
3378
  // flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
3150
3379
  // without standing up the full libp2p inbound path.
@@ -3310,14 +3539,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
3310
3539
  };
3311
3540
  // Create and start IPC server
3312
3541
  const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
3313
- try {
3314
- await ipcServer.start();
3315
- }
3316
- catch (err) {
3317
- // As above: startDaemon's catch releases the singleton lock for us.
3318
- await removeLockIfOwned(lockFilePath, process.pid, logger);
3319
- throw err;
3320
- }
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.
3321
3545
  // MCP-002: Instantiate NotificationDispatcher (wired to IPC server)
3322
3546
  const notificationDispatcher = new NotificationDispatcher({
3323
3547
  logger,
@@ -3387,6 +3611,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
3387
3611
  });
3388
3612
  return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
3389
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),
3390
3617
  // ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
3391
3618
  // how the surface tests passed while the feature did nothing: the test wired this seam to
3392
3619
  // `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
@@ -3399,38 +3626,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
3399
3626
  return;
3400
3627
  notificationDispatcher.dispatchDocumentWatch(agentName, documentId, paths);
3401
3628
  },
3402
- notifyPeer: createDocumentControlNotifier({
3403
- get store() {
3404
- // Lazily, because `documentLayer` is what is being constructed. The notifier is only ever
3405
- // called from lifecycle, long after construction returns.
3406
- return documentLayer.store;
3407
- },
3408
- owners: () => loadedAgents
3409
- .filter((a) => a.pubkey)
3410
- .map((a) => ({ agentName: a.name, ownerAgentId: a.pubkey.toLowerCase() })),
3411
- // DOD-MP-CONTROL-N-1 — the ONE implementation, on the layer, shared with every test fixture.
3412
- // A closure here instead would be untested by construction and hand-copied into fixtures
3413
- // that cannot disagree with it, which is exactly how the original defect survived.
3414
- holders: (ownerAgentId, documentId) => documentLayer.controlHolders(ownerAgentId, documentId),
3415
- sign: async (agentName, tbs) => {
3416
- const provider = keyProviders.get(agentName);
3417
- if (!provider)
3418
- throw new Error(`document_control_unsigned: no key provider for ${agentName}`);
3419
- return provider.sign(tbs);
3420
- },
3421
- send: (agentName, input) => documentTransportFor(agentName).sendBytes(input),
3422
- // Given a logger so a signing failure is REPORTED rather than swallowed — the reason a
3423
- // local key fault used to reach the operator dressed as an absent peer.
3424
- logger,
3425
- now: () => Date.now(),
3426
- }),
3427
- rollback: () => ({
3428
- // Likewise: withdraw REFUSES rather than claiming a rollback that did not happen. Reporting
3429
- // success having reverted nothing tells an operator their update was retracted while their
3430
- // file still contains it.
3431
- ok: false,
3432
- reason: "document_rollback_not_wired",
3433
- }),
3434
3629
  sign: async (ownerAgentId, tbs) => {
3435
3630
  // Signs as the OWNING agent, over the rejection's canonical preimage (DOD-DOC-REJECT-2).
3436
3631
  //
@@ -3467,14 +3662,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
3467
3662
  // agent, sessions belong to one agent, and `openSessionFor` signs as one agent. A single worker
3468
3663
  // with an agent-shaped hole in it is how a delivery ends up sent under the wrong identity.
3469
3664
  //
3470
- // CACHED per agent. A worker rebuilt every tick has its own re-entry guard (`#inFlight`)
3471
- // constructed away permanently null, and the comment claiming it protected anything was wrong.
3472
- // The sweep's serial loop happens to cover it today; a cached worker means it is covered because
3473
- // the guard exists, not by accident of loop shape.
3474
- const documentDeliveryWorkers = new Map();
3475
- // Cached separately from the worker because the OPERATOR SURFACE needs it too: a proposal is not
3476
- // in the envelope log — there is no document yet — so it has no delivery record to schedule, and
3477
- // it still has to travel the same open-or-reuse-then-seal path an update does.
3665
+ // The frame carrier, cached per agent (SYNC-P4: the delivery worker and its sweep are deleted
3666
+ // scheduling returns as the P5 reconcile triggers).
3478
3667
  const documentTransports = new Map();
3479
3668
  const documentTransportFor = (agentName) => {
3480
3669
  const cached = documentTransports.get(agentName);
@@ -3504,13 +3693,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
3504
3693
  .map((row) => row.session_id)
3505
3694
  .reverse(),
3506
3695
  openSession: async (agent, peerAgentId, correlationId) => {
3507
- // The same path `cello_initiate_session` takes, now callable without an IPC connection.
3508
- const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
3509
- if (res.ok !== true || typeof res.sessionId !== "string") {
3510
- 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();
3511
3714
  }
3512
- logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
3513
- return { ok: true, sessionId: res.sessionId };
3514
3715
  },
3515
3716
  sealSession: async (agent, sessionId, correlationId) => {
3516
3717
  // §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
@@ -3541,97 +3742,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
3541
3742
  // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3542
3743
  // successful send. See the comment at the call site for why this is delivery-critical and
3543
3744
  // not audit bookkeeping.
3544
- appendLeaf: (agent, sessionId, contentHash, correlationId) => {
3545
- sessionNodeManager.appendSessionLeaf(agent, sessionId, "doc", Buffer.from(contentHash).toString("hex"), correlationId);
3546
- },
3547
- encodeEnvelope: (envelope) => {
3548
- const bytes = encodeDocumentUpdateEnvelope({
3549
- type: "document_update",
3550
- document_id: envelope.documentId,
3551
- epoch_id: envelope.epochId,
3552
- doc_prev_hash: envelope.docPrevHash,
3553
- sender_agent_id: envelope.senderAgentId,
3554
- sender_client_id: envelope.senderClientId ?? 0,
3555
- update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
3556
- state_vector: envelope.stateVector,
3557
- update: envelope.payload ?? new Uint8Array(0),
3558
- signature: envelope.signature,
3559
- });
3560
- // Same defect as `sendBytes` had, on the UPDATE path: the receiver recomputes with the
3561
- // `0x00` domain prefix, so a bare hash is discarded at the authenticity check.
3562
- return { bytes, hash: wireContentHash(bytes) };
3563
- },
3564
- // The owner KEY, not the agent name — every document row is scoped by our own pubkey hex
3565
- // (M14-D5), and the store query behind this reads that column. Passing the name here
3566
- // returns no rows and the wait times out on every delivery.
3567
- awaitAck: async (envelopeHash, expectedAckerAgentId, timeoutMs) => {
3568
- const ownerKey = documentOwnerKeyFor(agentName);
3569
- if (ownerKey === null) {
3570
- // NOT "the peer stayed silent". `false` means the grace expired with no answer, and the
3571
- // caller logs exactly that — `graceMs: 10000`, a claim that ten seconds elapsed when
3572
- // none did, about a peer that was never asked. A delivery worker running for an agent
3573
- // whose own pubkey cannot be resolved is a wiring bug, and it says so here rather than
3574
- // arriving downstream dressed as a fact about the counterparty. Same distinction
3575
- // `abandoned_at` exists to keep: giving up is our decision, not evidence about them.
3576
- logger.error("document.delivery.ack_wait_unwired", { agentName, envelopeHash });
3577
- return null;
3578
- }
3579
- return documentLayer.awaitAck(ownerKey, envelopeHash, expectedAckerAgentId, timeoutMs);
3580
- },
3581
- drainHeld: async (sessionId, correlationId) => {
3582
- // ONLY WHEN THERE IS A GAP. `sealReadiness` already answers exactly this question, and
3583
- // gating on it keeps the ordinary delivery — the overwhelming majority — free of a relay
3584
- // round trip it does not need.
3585
- const readiness = sessionNodeManager.sealReadiness(agentName, sessionId);
3586
- if (readiness.heldCount === 0 && readiness.missingLeaves === 0)
3587
- return;
3588
- logger.info("document.delivery.drain_held", {
3589
- agentName,
3590
- sessionId,
3591
- heldCount: readiness.heldCount,
3592
- missingLeaves: readiness.missingLeaves,
3593
- correlationId,
3594
- });
3595
- await autoRecoverForAgent(agentName, "delivery_ack_grace").catch((err) => {
3596
- // CONTAINED. A recovery that fails leaves the ack held and the grace expires — the same
3597
- // outcome as before, and never a reason to fail a delivery whose content already left.
3598
- logger.warn("document.delivery.drain_held_failed", {
3599
- agentName,
3600
- sessionId,
3601
- correlationId,
3602
- error: err instanceof Error ? err.message : String(err),
3603
- });
3604
- });
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 };
3605
3752
  },
3606
3753
  });
3607
3754
  documentTransports.set(agentName, transport);
3608
3755
  return transport;
3609
3756
  };
3610
- const documentDeliveryFor = (agentName) => {
3611
- const existing = documentDeliveryWorkers.get(agentName);
3612
- if (existing)
3613
- return existing;
3614
- const worker = new DocumentDelivery(documentLayer.store, documentTransportFor(agentName), logger,
3615
- // DOD-MP-CONTROL-DURABLE-1 — control frames derive their recipients the way the NOTIFIER
3616
- // does, not the way envelopes do. `holdersFor` returns null for a document with no stored
3617
- // genesis proposal; `controlHolders` has an explicit legacy branch for it. Deriving the two
3618
- // halves differently turned a seeded row into a permanent no-op.
3619
- (documentId) => {
3620
- const ownerAgentId = documentOwnerKeyFor(agentName);
3621
- if (ownerAgentId === null) {
3622
- return { ok: false, reason: "document_owner_key_unavailable" };
3623
- }
3624
- return documentLayer.controlHolders(ownerAgentId, documentId);
3625
- });
3626
- documentDeliveryWorkers.set(agentName, worker);
3627
- return worker;
3628
- };
3629
3757
  // M14 / DOD-DOC-TOOLS-1 — the OPERATOR SURFACE. Registered last of the document wiring, because
3630
3758
  // it is the only part that can create a document, and everything it creates has to have somewhere
3631
3759
  // to go: without the inbound path a peer's answer is unroutable, and without the delivery worker a
3632
3760
  // published update never leaves.
3633
3761
  const documentPublish = new DocumentPublish({
3634
3762
  holdersFor: (ownerAgentId, documentId) => documentLayer.holdersFor(ownerAgentId, documentId),
3763
+ governanceFrontierFor: (ownerAgentId, documentId) => documentLayer.governanceFrontierFor(ownerAgentId, documentId),
3635
3764
  store: documentLayer.store,
3636
3765
  engine: documentLayer.engine,
3637
3766
  logger,
@@ -3651,6 +3780,30 @@ async function startDaemonHoldingLock(config, singletonLock) {
3651
3780
  // different facts that happen to coincide — see DocumentStore.pendingDeliveries.
3652
3781
  senderIdFor: (ownerAgentId) => ownerAgentId,
3653
3782
  canPublish: (ownerAgentId, documentId) => documentLayer.lifecycle.canPublish(ownerAgentId, documentId),
3783
+ // SYNC-P4 (R39's first trigger): each seat gets an initiated reconcile exchange — the
3784
+ // envelope rides the exchange's own difference computation. Fire-and-forget by contract.
3785
+ nudgeSeats: (ownerAgentId, documentId, seats) => {
3786
+ for (const seat of seats) {
3787
+ void documentLayer
3788
+ .initiateReconcile(ownerAgentId, seat, [documentId])
3789
+ .then((sent) => {
3790
+ if (!sent.ok) {
3791
+ // NOT SILENT (review F3): a lost nudge is legal for correctness (any later
3792
+ // exchange recomputes the difference — R40), but until P5's sweeps exist this
3793
+ // line is the only trace an operator has for "my edit never arrived".
3794
+ logger.warn("document.reconcile.nudge_failed", {
3795
+ documentId, peerAgentId: seat, reason: sent.reason,
3796
+ });
3797
+ }
3798
+ })
3799
+ .catch((err) => {
3800
+ logger.warn("document.reconcile.nudge_failed", {
3801
+ documentId, peerAgentId: seat,
3802
+ reason: err instanceof Error ? err.message : String(err),
3803
+ });
3804
+ });
3805
+ }
3806
+ },
3654
3807
  });
3655
3808
  registerDocumentHandlers({
3656
3809
  handlers,
@@ -3668,128 +3821,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
3668
3821
  },
3669
3822
  now: () => Date.now(),
3670
3823
  });
3671
- /**
3672
- * The delivery tick.
3673
- *
3674
- * Interval-driven rather than event-driven because the event we are actually waiting for — the
3675
- * peer coming back online — is not one this daemon observes. There is no presence subscription
3676
- * (parked, M14-P4), so the honest substitute is to ask periodically and let the per-envelope
3677
- * backoff keep the cost down: a peer that has been away an hour is checked at the cap, not every
3678
- * tick.
3679
- *
3680
- * Slow on purpose. Publish is fire-and-forget and nothing waits on this; a document that syncs a
3681
- * minute later is working correctly, while a tight loop over every attended agent is a cost paid
3682
- * forever for a case that is rare.
3683
- */
3684
- //
3685
- // Overridable for tests, the way `CELLO_SEAL_BILATERAL_TIMEOUT_MS` already is. The live enforcers
3686
- // spend their wall clock waiting for this tick — two of them is four minutes — and a test that
3687
- // must sit out a production-paced timer either runs slowly or is written with a window so tight
3688
- // that adding a second test to the file makes the first one fail. Both happened.
3689
- //
3690
- // Floored, so a misread env cannot turn the sweep into a busy loop against the directory.
3691
- const tickOverride = Number(process.env["CELLO_DOCUMENT_DELIVERY_TICK_MS"]);
3692
- const DOCUMENT_DELIVERY_TICK_MS = Number.isFinite(tickOverride) && tickOverride >= 250 ? tickOverride : 60_000;
3693
- // DOD-MP-SWEEP-ALIVE-1 how long ONE agent's pass may run before the sweep stops waiting for
3694
- // it. Generous relative to real work (the ack-grace budget for a whole pass is 30s) and finite,
3695
- // which is the only property that matters: the defect is an unbounded wait, not a slow one.
3696
- // Generous relative to what a HEALTHY pass can legitimately spend: the discovery lookup is
3697
- // bounded at 10s per (document, holder), the whole-pass ack grace is 30s, and only the dial is
3698
- // unbounded. Six pairs against a slow directory is already ~90s, so a 120s bound would warn on
3699
- // the normal case — and a signal that fires when nothing is wrong teaches an operator to ignore
3700
- // the one that matters. Scales with the tick so the fast-tick test path does not sit out minutes.
3701
- const DOCUMENT_DELIVERY_PASS_TIMEOUT_MS = Math.max(180_000, DOCUMENT_DELIVERY_TICK_MS * 3);
3702
- let documentDeliveryRunning = false;
3703
- let documentDeliveryStopping = false;
3704
- let documentDeliveryInFlight = null;
3705
- const documentDeliveryTimer = setInterval(() => {
3706
- // NO OVERLAP. A tick that dials a slow peer can outlast the interval, and a second pass would
3707
- // re-send envelopes already in flight — the worker's own re-entry guard covers one agent, this
3708
- // covers the sweep across all of them.
3709
- if (documentDeliveryRunning)
3710
- return;
3711
- documentDeliveryRunning = true;
3712
- documentDeliveryInFlight = (async () => {
3824
+ // SYNC-P5 (R39–R43) — WHEN reconciling is attempted: the periodic sweep and the party-became-
3825
+ // reachable trigger. All state volatile (R41); a lost tick costs latency, never correctness.
3826
+ reconcileScheduler = new ReconcileScheduler({
3827
+ now: () => Date.now(),
3828
+ logger,
3829
+ sweepTargets: (ownerAgentId) => documentLayer.sweepTargets(ownerAgentId),
3830
+ partySync: (ownerAgentId, documentId, partyAgentId) => documentLayer.partySync(ownerAgentId, documentId, partyAgentId),
3831
+ initiateReconcile: (ownerAgentId, peerAgentId, documentIds) => documentLayer.initiateReconcile(ownerAgentId, peerAgentId, documentIds),
3832
+ ...(Number.isFinite(Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"])) &&
3833
+ Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"]) >= 250
3834
+ ? { backoffBaseMs: Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"]) }
3835
+ : {}),
3836
+ ...(Number.isFinite(Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"])) &&
3837
+ Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"]) >= 250
3838
+ ? { believedCurrentMs: Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"]) }
3839
+ : {}),
3840
+ });
3841
+ // Overridable for tests (the delivery tick precedent); floored so a misread env cannot busy-loop.
3842
+ const reconcileTickOverride = Number(process.env["CELLO_DOCUMENT_RECONCILE_SWEEP_MS"]);
3843
+ const RECONCILE_SWEEP_MS = Number.isFinite(reconcileTickOverride) && reconcileTickOverride >= 250
3844
+ ? reconcileTickOverride
3845
+ : 120_000;
3846
+ // Review F2: the guard is TIME-STAMPED, not a bare boolean a pass that never settles (a
3847
+ // dial with no timeout) would otherwise end the sweep silently for the daemon's lifetime,
3848
+ // the exact R42 stall one level above the module that fixed it. Past the bound the wedge is
3849
+ // WARNED and the guard force-released; the abandoned pass's own scheduler marks are
3850
+ // themselves time-bounded, so double-attempts are latency, never correctness.
3851
+ const RECONCILE_PASS_BOUND_MS = 5 * 60_000;
3852
+ let reconcileSweepStartedAt = null;
3853
+ const reconcileSweepTimer = setInterval(() => {
3854
+ if (reconcileSweepStartedAt !== null) {
3855
+ if (Date.now() - reconcileSweepStartedAt < RECONCILE_PASS_BOUND_MS)
3856
+ return;
3857
+ logger.warn("document.reconcile.sweep_wedged", {
3858
+ heldMs: Date.now() - reconcileSweepStartedAt,
3859
+ impact: "a sweep pass never settled; the guard is force-released so sweeping continues",
3860
+ });
3861
+ }
3862
+ reconcileSweepStartedAt = Date.now();
3863
+ void (async () => {
3713
3864
  try {
3714
- let agentsSwept = 0;
3715
- let stuck = 0;
3716
- let attempted = 0;
3717
- let delivered = 0;
3718
- let failed = 0;
3719
3865
  for (const agentName of perAgentSignaling.keys()) {
3720
- // M2: a tick already running when the daemon stops must not keep dialling into a
3721
- // half-torn-down transport. `clearInterval` stops the NEXT tick, not this one.
3722
- if (documentDeliveryStopping)
3723
- break;
3724
3866
  const ownerAgentId = documentOwnerKeyFor(agentName);
3725
- // M14-D5: the owner key is our own pubkey hex. It is what BOTH halves scope by — the
3726
- // inbound router resolves the same function — and it is what the wire sender id is.
3727
- // Scoping the two halves differently returns nothing pending, reports nothing attempted,
3728
- // and leaves a fully synced document invisible with no error on any path.
3729
3867
  if (ownerAgentId === null)
3730
3868
  continue;
3731
- // COUNTED AFTER THE PASS, not before — an agent whose pass never finished was not swept,
3732
- // and saying it was is what made the stall indistinguishable from an idle daemon.
3733
- // FANOUT-1: targets are the DERIVED holders minus ourselves — never per-sender
3734
- // config, never the genesis peer column (§7-1's silent-divergence hazard).
3735
- const holdersFor = (documentId) => {
3736
- const holders = documentLayer.holdersFor(ownerAgentId, documentId);
3737
- return holders === null ? null : holders.filter((h) => h !== ownerAgentId);
3738
- };
3739
- // DOD-MP-SWEEP-ALIVE-1 — BOUNDED. The `finally` below releases the overlap guard only
3740
- // when this body settles, so an await that never settles ends document delivery for the
3741
- // life of the process, silently: the interval keeps firing, returns at the guard, and
3742
- // logs nothing. Observed live 2026-08-13. One wedged agent now degrades alone and loudly
3743
- // instead of taking every other agent's documents down with it.
3744
- const bounded = await sweepAgentEvicting({
3745
- logger,
3746
- timeoutMs: DOCUMENT_DELIVERY_PASS_TIMEOUT_MS,
3747
- agentName,
3748
- // Evicting the wedged worker is what makes the recovery real: `DocumentDelivery.tick`
3749
- // caches its pass and would otherwise hand back the same hung promise forever, so the
3750
- // agent would never deliver again AND every later pass would pay the full bound
3751
- // waiting for it — a 2x sweep-interval regression for every healthy agent.
3752
- workers: documentDeliveryWorkers,
3753
- run: () => documentDeliveryFor(agentName).tick(ownerAgentId, holdersFor, Date.now(), {
3754
- senderAgentId: ownerAgentId,
3755
- }),
3756
- });
3757
- if (!bounded.completed) {
3758
- stuck += 1;
3759
- continue;
3869
+ const result = await reconcileScheduler.sweep(ownerAgentId);
3870
+ if (result.attempted > 0 || result.failed > 0) {
3871
+ logger.debug("document.reconcile.sweep", { agentName, ...result });
3760
3872
  }
3761
- agentsSwept++;
3762
- const result = bounded.value;
3763
- attempted += result.attempted;
3764
- delivered += result.delivered;
3765
- failed += result.failed;
3766
- }
3767
- // H3: a sweep that delivers nothing was byte-for-byte identical to a healthy idle one, which
3768
- // is what made every scoping bug above invisible. One line per tick, always.
3769
- // `stuck` IS PART OF THE LINE. Counting a wedged agent as swept reported `agents: 3,
3770
- // attempted: 0` — byte-for-byte a healthy idle sweep, which is the exact shape the comment
3771
- // below exists to prevent, on the one occasion it matters most.
3772
- logger.debug("document.delivery.sweep", { agents: agentsSwept, stuck, attempted, delivered, failed });
3773
- if (agentsSwept === 0 && documentLayer.store.anyDocumentExists()) {
3774
- logger.warn("document.delivery.sweep_empty", {
3775
- reason: "documents exist but no agent was swept — nothing will ever be delivered",
3776
- });
3777
3873
  }
3778
3874
  }
3779
3875
  catch (err) {
3780
- // Contained: one agent's delivery failure must not stop the sweep, and an unhandled
3781
- // rejection out of a timer takes the daemon down.
3782
- logger.warn("document.delivery.tick.failed", { reason: extractErrorMessage(err) });
3876
+ logger.warn("document.reconcile.sweep_threw", {
3877
+ reason: err instanceof Error ? err.message : String(err),
3878
+ });
3783
3879
  }
3784
3880
  finally {
3785
- documentDeliveryRunning = false;
3786
- documentDeliveryInFlight = null;
3881
+ reconcileSweepStartedAt = null;
3787
3882
  }
3788
3883
  })();
3789
- void documentDeliveryInFlight;
3790
- }, DOCUMENT_DELIVERY_TICK_MS);
3791
- // Never hold the process open on account of document delivery.
3792
- documentDeliveryTimer.unref?.();
3884
+ }, RECONCILE_SWEEP_MS);
3885
+ reconcileSweepTimer.unref?.();
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
+ }
3793
3913
  // MCP-001: Clean up per-connection state when a connection disconnects
3794
3914
  // MCP-002: Also unregister from notification dispatcher
3795
3915
  ipcServer.onDisconnect((connectionId) => {
@@ -3847,40 +3967,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
3847
3967
  // per daemon no matter how many times stop() is called.
3848
3968
  let stoppedHookFired = false;
3849
3969
  async function stop(reason) {
3850
- // M14: stop the document delivery sweep first — it opens sessions, and a tick landing during
3851
- // teardown would dial into a daemon that is going away.
3852
- clearInterval(documentDeliveryTimer);
3853
- // `clearInterval` stops the NEXT tick; the flag stops the running one at its next agent.
3854
- documentDeliveryStopping = true;
3855
- // BOUNDED. This was a bare `await documentDeliveryInFlight`, and shutdown is the one place that
3856
- // must not block on the network: a sweep mid-dial against an unreachable peer held `stop()`
3857
- // open, so the interrupt-marking writes that follow did not complete before the process died
3858
- // and sessions came back `active` after a restart, which is the exact defect AC-009 exists to
3859
- // catch. It failed on CI, where a directory node times out at 5s, and passed locally, where it
3860
- // does not.
3861
- //
3862
- // A tick still gets a moment to finish so the common case is orderly; after that we proceed
3863
- // without it. What the sweep leaves half-done is safe by construction delivery state is
3864
- // derived from the log, so an interrupted pass is re-derived on the next start.
3865
- // NOT AWAITED AT ALL. Two attempts at waiting for it were both wrong, and the second was worse
3866
- // than the first:
3867
- //
3868
- // 1. a bare await blocked shutdown on the network — a sweep mid-dial against an unreachable
3869
- // peer held `stop()` open past the point where the interrupt-marking writes had to happen;
3870
- // 2. bounding it with `setTimeout(...).unref()` looked like the fix and could HANG FOREVER: an
3871
- // unref'd timer does not hold the event loop open, so while the loop is draining during
3872
- // shutdown it may never fire, the race never settles, and `stop()` never reaches the line
3873
- // that logs `daemon.stopped` — which is exactly what CI showed, a daemon whose log ends at
3874
- // `daemon.started` with no shutdown events at all.
3875
- //
3876
- // There is nothing to wait for. `documentDeliveryStopping` stops the loop at its next agent, and
3877
- // whatever a half-finished sweep leaves behind is safe by construction: delivery state is
3878
- // DERIVED from the envelope log, so an interrupted pass is simply re-derived on the next start —
3879
- // the property the offline enforcer proves against two real daemons.
3880
- //
3881
- // The rule this cost two CI round trips to learn: shutdown may not await anything that can
3882
- // block on I/O, and a timeout that can outlive the event loop is not a bound.
3883
- void documentDeliveryInFlight;
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();
3884
3987
  // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
3885
3988
  // the generation so the running loop's while-condition fails on its next check.
3886
3989
  stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check