@cello-protocol/daemon 0.0.132 → 0.0.134

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 (118) hide show
  1. package/dist/bin/cello-daemon.js +25 -0
  2. package/dist/bin/cello-daemon.js.map +1 -1
  3. package/dist/content-park.d.ts.map +1 -1
  4. package/dist/content-park.js +29 -11
  5. package/dist/content-park.js.map +1 -1
  6. package/dist/daemon.d.ts +11 -0
  7. package/dist/daemon.d.ts.map +1 -1
  8. package/dist/daemon.js +455 -17
  9. package/dist/daemon.js.map +1 -1
  10. package/dist/document-ack-inbound.d.ts +57 -0
  11. package/dist/document-ack-inbound.d.ts.map +1 -0
  12. package/dist/document-ack-inbound.js +174 -0
  13. package/dist/document-ack-inbound.js.map +1 -0
  14. package/dist/document-control-notifier.d.ts +61 -0
  15. package/dist/document-control-notifier.d.ts.map +1 -0
  16. package/dist/document-control-notifier.js +70 -0
  17. package/dist/document-control-notifier.js.map +1 -0
  18. package/dist/document-delivery-transport.d.ts +94 -0
  19. package/dist/document-delivery-transport.d.ts.map +1 -0
  20. package/dist/document-delivery-transport.js +179 -0
  21. package/dist/document-delivery-transport.js.map +1 -0
  22. package/dist/document-delivery.d.ts +181 -0
  23. package/dist/document-delivery.d.ts.map +1 -0
  24. package/dist/document-delivery.js +289 -0
  25. package/dist/document-delivery.js.map +1 -0
  26. package/dist/document-frame-router.d.ts +210 -0
  27. package/dist/document-frame-router.d.ts.map +1 -0
  28. package/dist/document-frame-router.js +396 -0
  29. package/dist/document-frame-router.js.map +1 -0
  30. package/dist/document-handlers.d.ts +47 -0
  31. package/dist/document-handlers.d.ts.map +1 -0
  32. package/dist/document-handlers.js +657 -0
  33. package/dist/document-handlers.js.map +1 -0
  34. package/dist/document-handshake.d.ts +156 -0
  35. package/dist/document-handshake.d.ts.map +1 -0
  36. package/dist/document-handshake.js +398 -0
  37. package/dist/document-handshake.js.map +1 -0
  38. package/dist/document-inbound.d.ts +91 -0
  39. package/dist/document-inbound.d.ts.map +1 -0
  40. package/dist/document-inbound.js +290 -0
  41. package/dist/document-inbound.js.map +1 -0
  42. package/dist/document-layer.d.ts +137 -0
  43. package/dist/document-layer.d.ts.map +1 -0
  44. package/dist/document-layer.js +255 -0
  45. package/dist/document-layer.js.map +1 -0
  46. package/dist/document-lifecycle.d.ts +125 -0
  47. package/dist/document-lifecycle.d.ts.map +1 -0
  48. package/dist/document-lifecycle.js +433 -0
  49. package/dist/document-lifecycle.js.map +1 -0
  50. package/dist/document-live-docs.d.ts +58 -0
  51. package/dist/document-live-docs.d.ts.map +1 -0
  52. package/dist/document-live-docs.js +126 -0
  53. package/dist/document-live-docs.js.map +1 -0
  54. package/dist/document-notify.d.ts +173 -0
  55. package/dist/document-notify.d.ts.map +1 -0
  56. package/dist/document-notify.js +438 -0
  57. package/dist/document-notify.js.map +1 -0
  58. package/dist/document-publish.d.ts +67 -0
  59. package/dist/document-publish.d.ts.map +1 -0
  60. package/dist/document-publish.js +149 -0
  61. package/dist/document-publish.js.map +1 -0
  62. package/dist/document-reachability.d.ts +42 -0
  63. package/dist/document-reachability.d.ts.map +1 -0
  64. package/dist/document-reachability.js +80 -0
  65. package/dist/document-reachability.js.map +1 -0
  66. package/dist/document-rejection.d.ts +240 -0
  67. package/dist/document-rejection.d.ts.map +1 -0
  68. package/dist/document-rejection.js +407 -0
  69. package/dist/document-rejection.js.map +1 -0
  70. package/dist/document-store.d.ts +154 -8
  71. package/dist/document-store.d.ts.map +1 -1
  72. package/dist/document-store.js +462 -4
  73. package/dist/document-store.js.map +1 -1
  74. package/dist/document-write-path.d.ts.map +1 -1
  75. package/dist/document-write-path.js +10 -43
  76. package/dist/document-write-path.js.map +1 -1
  77. package/dist/inbound-sessions.d.ts.map +1 -1
  78. package/dist/inbound-sessions.js +4 -0
  79. package/dist/inbound-sessions.js.map +1 -1
  80. package/dist/initiate-session-handler.d.ts +24 -1
  81. package/dist/initiate-session-handler.d.ts.map +1 -1
  82. package/dist/initiate-session-handler.js +35 -9
  83. package/dist/initiate-session-handler.js.map +1 -1
  84. package/dist/ipc-server.d.ts +11 -1
  85. package/dist/ipc-server.d.ts.map +1 -1
  86. package/dist/ipc-server.js +7 -1
  87. package/dist/ipc-server.js.map +1 -1
  88. package/dist/line-lcs.d.ts +51 -0
  89. package/dist/line-lcs.d.ts.map +1 -0
  90. package/dist/line-lcs.js +71 -0
  91. package/dist/line-lcs.js.map +1 -0
  92. package/dist/notification-handlers.d.ts.map +1 -1
  93. package/dist/notification-handlers.js +31 -14
  94. package/dist/notification-handlers.js.map +1 -1
  95. package/dist/outbound-sessions.d.ts +2 -0
  96. package/dist/outbound-sessions.d.ts.map +1 -1
  97. package/dist/outbound-sessions.js +11 -1
  98. package/dist/outbound-sessions.js.map +1 -1
  99. package/dist/session-content-handlers.d.ts.map +1 -1
  100. package/dist/session-content-handlers.js +3 -2
  101. package/dist/session-content-handlers.js.map +1 -1
  102. package/dist/session-node-manager.d.ts +26 -4
  103. package/dist/session-node-manager.d.ts.map +1 -1
  104. package/dist/session-node-manager.js +201 -15
  105. package/dist/session-node-manager.js.map +1 -1
  106. package/dist/session-read-handlers.js +1 -1
  107. package/dist/session-read-handlers.js.map +1 -1
  108. package/dist/types.d.ts +40 -0
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js.map +1 -1
  111. package/dist/vocabulary.d.ts.map +1 -1
  112. package/dist/vocabulary.js +16 -0
  113. package/dist/vocabulary.js.map +1 -1
  114. package/dist/wire-content-hash.d.ts +27 -0
  115. package/dist/wire-content-hash.d.ts.map +1 -0
  116. package/dist/wire-content-hash.js +37 -0
  117. package/dist/wire-content-hash.js.map +1 -0
  118. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * f. Exit 0
27
27
  */
28
28
  import { mkdir } from "node:fs/promises";
29
- import { randomUUID, createHash } from "node:crypto";
29
+ import { randomUUID } from "node:crypto";
30
30
  import { dirname, join } from "node:path";
31
31
  import { loadAgents } from "./agent-loader.js";
32
32
  import { acquireLock, removeLockIfOwned } from "./lock-file.js";
@@ -65,6 +65,14 @@ import { registerContactHandlers } from "./contact-handlers.js";
65
65
  import { createSealCoordinator } from "./seal-coordinator.js";
66
66
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
67
67
  import { registerSessionContentHandlers } from "./session-content-handlers.js";
68
+ import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
69
+ import { registerDocumentHandlers } from "./document-handlers.js";
70
+ import { createDocumentControlNotifier } from "./document-control-notifier.js";
71
+ import { wireContentHash } from "./wire-content-hash.js";
72
+ import { DocumentPublish } from "./document-publish.js";
73
+ import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
74
+ import { DocumentDelivery } from "./document-delivery.js";
75
+ import { encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1 } from "@cello-protocol/protocol-types";
68
76
  import { createSealFlows } from "./seal-flows.js";
69
77
  import { registerCloseSessionHandler } from "./close-session-handler.js";
70
78
  import { createInboundSessions } from "./inbound-sessions.js";
@@ -910,7 +918,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
910
918
  // is anchored on (a counterparty daemon's end-anchored detector must see this close).
911
919
  const rejectText = "This inbox only accepts one message per visit. Closing. [[WRAP]]";
912
920
  const rejectBytes = new TextEncoder().encode(rejectText);
913
- const rejectHash = createHash("sha256").update(new Uint8Array([0x00])).update(rejectBytes).digest();
921
+ const rejectHash = wireContentHash(rejectBytes);
914
922
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
915
923
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
916
924
  // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
@@ -1078,7 +1086,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1078
1086
  const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
1079
1087
  ? new Uint8Array(awayVerdict.content)
1080
1088
  : draftBytes;
1081
- const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
1089
+ const contentHash = wireContentHash(contentBytes);
1082
1090
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1083
1091
  if (!sendResult.ok && !sendResult.durable) {
1084
1092
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
@@ -1204,7 +1212,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1204
1212
  new LocalAutoNatStub();
1205
1213
  // The OUTBOUND session path (outbound-sessions.ts): discovery, the session request, and cross-node
1206
1214
  // setup via a transient VISITING connection to the counterparty's home node.
1207
- const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator } = createOutboundSessions({
1215
+ const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator, runDiscoveryLookup } = createOutboundSessions({
1208
1216
  logger,
1209
1217
  sessionNodeManager,
1210
1218
  getKeyProvider: (agentName) => keyProviders.get(agentName),
@@ -2725,7 +2733,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
2725
2733
  }
2726
2734
  // cello_initiate_session (initiate-session-handler.ts). The relay witness is BEST-EFFORT: a
2727
2735
  // session with no relay still runs on the direct content path. Degraded, never blocked.
2728
- registerInitiateSessionHandler({
2736
+ const { openSessionFor } = registerInitiateSessionHandler({
2729
2737
  handlers,
2730
2738
  logger,
2731
2739
  sessionNodeManager,
@@ -3053,17 +3061,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
3053
3061
  //
3054
3062
  // Wrapping the handler map is the ONE choke point that has both the response and the connection's
3055
3063
  // clientType. Doing it per-handler would mean 60+ call sites, and the 61st would forget.
3056
- const renderedHandlers = new Map();
3057
- for (const [method, handler] of handlers) {
3058
- renderedHandlers.set(method, async (params, connectionId) => {
3059
- const result = await handler(params, connectionId);
3060
- // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and the
3061
- // CLI verb is the safe answer — it is at least a real command an operator can run, whereas an
3062
- // MCP tool name is useless in a terminal.
3063
- const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3064
- return renderForSurface(result, surface);
3065
- });
3066
- }
3064
+ //
3065
+ // RESOLVED AT DISPATCH, not copied at construction. This was a `for…of` that snapshotted
3066
+ // `handlers` into a second map, which made the ORDER of registration load-bearing in a file
3067
+ // 3,500 lines long: anything registered after this line was written into a map nothing
3068
+ // dispatched from. The document surface landed 245 lines below it and every `cello_doc_*` verb
3069
+ // answered `method_not_found` — whose guidance blames version skew between the shim and the
3070
+ // daemon, so an operator would re-pin, reinstall and restart, and find both sides matching.
3071
+ //
3072
+ // A comment saying "register above this line" would have been one more rule to remember. Late
3073
+ // binding makes the ordering unrepresentable instead: the map below reads `handlers` when a
3074
+ // request arrives, so a handler registered at any point before the first request is dispatchable.
3075
+ const renderedHandlers = {
3076
+ get(method) {
3077
+ const handler = handlers.get(method);
3078
+ if (!handler)
3079
+ return undefined;
3080
+ return async (params, connectionId) => {
3081
+ const result = await handler(params, connectionId);
3082
+ // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
3083
+ // the CLI verb is the safe answer — it is at least a real command an operator can run,
3084
+ // whereas an MCP tool name is useless in a terminal.
3085
+ const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3086
+ return renderForSurface(result, surface);
3087
+ };
3088
+ },
3089
+ };
3067
3090
  // Create and start IPC server
3068
3091
  const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
3069
3092
  try {
@@ -3099,6 +3122,337 @@ async function startDaemonHoldingLock(config, singletonLock) {
3099
3122
  // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
3100
3123
  void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
3101
3124
  });
3125
+ // M14 / DOD-DOC-INBOUND-2: the document layer, wired to the session content path.
3126
+ //
3127
+ // Built as ONE thing (see `document-layer.ts`): every half-wiring is a distinct silent failure —
3128
+ // an inbound path with no ack producer leaves the peer retrying until their document stalls, a
3129
+ // delivery worker with no inbound counterpart publishes envelopes nobody can answer.
3130
+ //
3131
+ // It shares the daemon's SQLCipher handle rather than opening its own. One encrypted database,
3132
+ // one key, one file to back up — and a second opener is a second thing that has to agree about
3133
+ // the key, which is how a store ends up plaintext.
3134
+ // THE owner key. One function, used by the inbound router and the delivery sweep alike, because
3135
+ // the two halves scoping differently is a silent-empty-query bug rather than a visible one.
3136
+ //
3137
+ // Reads `loadedAgents`, which is the live registry — `cello_create_agent` pushes into it at
3138
+ // runtime — rather than the boot-time snapshot, so an agent registered after startup resolves
3139
+ // without a restart. Lower-cased because the store compares owner keys as strings and a peer's
3140
+ // id arrives off the wire in whatever case they sent.
3141
+ const documentOwnerKeyFor = (agentName) => loadedAgents.find((a) => a.name === agentName)?.pubkey?.toLowerCase() ?? null;
3142
+ const documentLayer = createDocumentLayer({
3143
+ db: sessionNodeManager.getDb(),
3144
+ logger,
3145
+ // M14-D5: a remote agent's id IS its K_local pubkey hex, so this needs no lookup — and a lookup
3146
+ // on the critical path of every signature check is precisely what it must not have.
3147
+ publicKeyFor: agentPublicKeyFromId,
3148
+ ownerKeyFor: documentOwnerKeyFor,
3149
+ // Documents materialize as files under the operator's CELLO_DIR, alongside the database that
3150
+ // is their source of truth. Not the current working directory: a daemon serves many agents and
3151
+ // outlives any shell, so a relative root would scatter one operator's documents across
3152
+ // wherever they happened to launch it from.
3153
+ workspaceRoot: join(config.celloDir, "documents"),
3154
+ // The ack's road to the peer — the same open-or-reuse-then-seal path every other document frame
3155
+ // takes. Resolved from the owner KEY back to the agent name, because the transport is per agent.
3156
+ sendFrame: async (ownerAgentId, peerAgentId, bytes) => {
3157
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3158
+ if (!agentName)
3159
+ return { ok: false, reason: "document_ack_no_agent" };
3160
+ const sent = await documentTransportFor(agentName).sendBytes({
3161
+ peerAgentId,
3162
+ documentId: "ack",
3163
+ bytes,
3164
+ correlationId: randomUUID(),
3165
+ });
3166
+ return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
3167
+ },
3168
+ // ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
3169
+ // how the surface tests passed while the feature did nothing: the test wired this seam to
3170
+ // `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
3171
+ // the near side did.
3172
+ notifyPeer: createDocumentControlNotifier({
3173
+ get store() {
3174
+ // Lazily, because `documentLayer` is what is being constructed. The notifier is only ever
3175
+ // called from lifecycle, long after construction returns.
3176
+ return documentLayer.store;
3177
+ },
3178
+ owners: () => loadedAgents
3179
+ .filter((a) => a.pubkey)
3180
+ .map((a) => ({ agentName: a.name, ownerAgentId: a.pubkey.toLowerCase() })),
3181
+ sign: async (agentName, tbs) => {
3182
+ const provider = keyProviders.get(agentName);
3183
+ if (!provider)
3184
+ throw new Error(`document_control_unsigned: no key provider for ${agentName}`);
3185
+ return provider.sign(tbs);
3186
+ },
3187
+ send: (agentName, input) => documentTransportFor(agentName).sendBytes(input),
3188
+ now: () => Date.now(),
3189
+ }),
3190
+ rollback: () => ({
3191
+ // Likewise: withdraw REFUSES rather than claiming a rollback that did not happen. Reporting
3192
+ // success having reverted nothing tells an operator their update was retracted while their
3193
+ // file still contains it.
3194
+ ok: false,
3195
+ reason: "document_rollback_not_wired",
3196
+ }),
3197
+ sign: async (ownerAgentId, tbs) => {
3198
+ // Signs as the OWNING agent, over the rejection's canonical preimage (DOD-DOC-REJECT-2).
3199
+ //
3200
+ // `ownerAgentId` here is the OWNER KEY — pubkey hex — because that is what the layer is
3201
+ // scoped by, and `keyProviders` is keyed by agent NAME. This did `keyProviders.get(ownerAgentId)`
3202
+ // and the comment above it asserted the two were the same thing. They are not: the lookup
3203
+ // missed on every call, so every auto-rejection threw `document_rejection_unsigned` and NO
3204
+ // rejection was ever signed or sent. A peer whose update we refused was never told why —
3205
+ // their sender retried into a gate that would refuse it every time, until the document
3206
+ // stalled for a reason neither operator could see.
3207
+ //
3208
+ // Invisible to every test: the unit and e2e fixtures alike wire `sign: (_o, tbs) => keys.sign(tbs)`,
3209
+ // discarding the agent argument, so nothing could disagree about which key was resolved.
3210
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3211
+ const provider = agentName ? keyProviders.get(agentName) : undefined;
3212
+ if (!provider) {
3213
+ // Still throws rather than substituting another agent's key — an earlier version took no
3214
+ // agent at all and reached for whichever provider was first in the map, which is fabricated
3215
+ // crypto wearing a real signature.
3216
+ throw new Error(`document_rejection_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
3217
+ `so its rejection cannot be signed — refusing rather than writing an unsigned leaf ` +
3218
+ `into an append-only log`);
3219
+ }
3220
+ return provider.sign(tbs);
3221
+ },
3222
+ });
3223
+ sessionNodeManager.setOnDocumentFrame(documentLayer.onDocumentFrame);
3224
+ // M14 / DOD-DOC-DELIVERY-2 — the outbound half, wired only now that INBOUND-2 is. The ordering
3225
+ // constraint on the DoD line is real and this is where it is honoured: a delivery worker with no
3226
+ // inbound counterpart publishes envelopes nobody can answer, so every document would stall at the
3227
+ // unacked ceiling.
3228
+ //
3229
+ // PER AGENT, because every capability below is: the signaling stream is authenticated as one
3230
+ // agent, sessions belong to one agent, and `openSessionFor` signs as one agent. A single worker
3231
+ // with an agent-shaped hole in it is how a delivery ends up sent under the wrong identity.
3232
+ //
3233
+ // CACHED per agent. A worker rebuilt every tick has its own re-entry guard (`#inFlight`)
3234
+ // constructed away — permanently null, and the comment claiming it protected anything was wrong.
3235
+ // The sweep's serial loop happens to cover it today; a cached worker means it is covered because
3236
+ // the guard exists, not by accident of loop shape.
3237
+ const documentDeliveryWorkers = new Map();
3238
+ // Cached separately from the worker because the OPERATOR SURFACE needs it too: a proposal is not
3239
+ // in the envelope log — there is no document yet — so it has no delivery record to schedule, and
3240
+ // it still has to travel the same open-or-reuse-then-seal path an update does.
3241
+ const documentTransports = new Map();
3242
+ const documentTransportFor = (agentName) => {
3243
+ const cached = documentTransports.get(agentName);
3244
+ if (cached)
3245
+ return cached;
3246
+ const transport = createDocumentDeliveryTransport({
3247
+ agentName,
3248
+ logger,
3249
+ // The SAME 3-state discovery answer cello_initiate_session uses, from the same closure —
3250
+ // online / offline / unknown_agent, kept distinct from "the lookup itself failed". A second
3251
+ // implementation of that distinction drifts until one of them reports a directory outage as
3252
+ // the peer being offline.
3253
+ lookupPeer: async (peerAgentId, correlationId) => {
3254
+ const entry = perAgentSignaling.get(agentName);
3255
+ if (!entry) {
3256
+ // Our OWN stream is not up. A transport fault, never the peer being away — the
3257
+ // reachability mapper turns this into a throw, which the worker logs as lookup_failed.
3258
+ return { kind: "send_failed", reason: "signaling_unavailable" };
3259
+ }
3260
+ return runDiscoveryLookup(entry.signaling, peerAgentId, 10_000, correlationId);
3261
+ },
3262
+ // Most recent LAST — §16.4 reuses the most recent active session, and getSessionsForAgent
3263
+ // returns newest first.
3264
+ activeSessionsWith: (agent, peerAgentId) => sessionNodeManager
3265
+ .getSessionsForAgent(agent)
3266
+ .filter((row) => row.status === "active" && row.counterparty_pubkey === peerAgentId)
3267
+ .map((row) => row.session_id)
3268
+ .reverse(),
3269
+ openSession: async (agent, peerAgentId, correlationId) => {
3270
+ // The same path `cello_initiate_session` takes, now callable without an IPC connection.
3271
+ const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
3272
+ if (res.ok !== true || typeof res.sessionId !== "string") {
3273
+ return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
3274
+ }
3275
+ logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
3276
+ return { ok: true, sessionId: res.sessionId };
3277
+ },
3278
+ sealSession: async (agent, sessionId, correlationId) => {
3279
+ // §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
3280
+ // seal does not. Only ever called for a session this worker OPENED; one it reused belongs
3281
+ // to whoever started that conversation.
3282
+ const close = handlers.get("cello_close_session");
3283
+ if (!close) {
3284
+ // Reported, not swallowed. The outcome of a missing handler is a live session node the
3285
+ // operator never started, with no sealed record — the exact thing the seal exists to
3286
+ // prevent, and previously indistinguishable from a clean seal.
3287
+ logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
3288
+ return;
3289
+ }
3290
+ const sealed = (await close({ session_id: sessionId, agent }, `doc-delivery-${correlationId}`));
3291
+ if (sealed?.ok !== true) {
3292
+ // `cello_close_session` has distinct failure codes — session_already_sealed,
3293
+ // seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
3294
+ logger.warn("document.delivery.seal_failed", { agent, sessionId, reason: sealed?.reason ?? "unknown", correlationId });
3295
+ }
3296
+ },
3297
+ sendContent: (agent, sessionId, content, contentHash, correlationId) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId),
3298
+ // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3299
+ // successful send. See the comment at the call site for why this is delivery-critical and
3300
+ // not audit bookkeeping.
3301
+ appendLeaf: (agent, sessionId, contentHash, correlationId) => {
3302
+ sessionNodeManager.appendSessionLeaf(agent, sessionId, "doc", Buffer.from(contentHash).toString("hex"), correlationId);
3303
+ },
3304
+ encodeEnvelope: (envelope) => {
3305
+ const bytes = encodeDocumentUpdateEnvelope({
3306
+ type: "document_update",
3307
+ document_id: envelope.documentId,
3308
+ epoch_id: envelope.epochId,
3309
+ doc_prev_hash: envelope.docPrevHash,
3310
+ sender_agent_id: envelope.senderAgentId,
3311
+ sender_client_id: envelope.senderClientId ?? 0,
3312
+ update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
3313
+ state_vector: envelope.stateVector,
3314
+ update: envelope.payload ?? new Uint8Array(0),
3315
+ signature: envelope.signature,
3316
+ });
3317
+ // Same defect as `sendBytes` had, on the UPDATE path: the receiver recomputes with the
3318
+ // `0x00` domain prefix, so a bare hash is discarded at the authenticity check.
3319
+ return { bytes, hash: wireContentHash(bytes) };
3320
+ },
3321
+ });
3322
+ documentTransports.set(agentName, transport);
3323
+ return transport;
3324
+ };
3325
+ const documentDeliveryFor = (agentName) => {
3326
+ const existing = documentDeliveryWorkers.get(agentName);
3327
+ if (existing)
3328
+ return existing;
3329
+ const worker = new DocumentDelivery(documentLayer.store, documentTransportFor(agentName), logger);
3330
+ documentDeliveryWorkers.set(agentName, worker);
3331
+ return worker;
3332
+ };
3333
+ // M14 / DOD-DOC-TOOLS-1 — the OPERATOR SURFACE. Registered last of the document wiring, because
3334
+ // it is the only part that can create a document, and everything it creates has to have somewhere
3335
+ // to go: without the inbound path a peer's answer is unroutable, and without the delivery worker a
3336
+ // published update never leaves.
3337
+ const documentPublish = new DocumentPublish({
3338
+ store: documentLayer.store,
3339
+ engine: documentLayer.engine,
3340
+ logger,
3341
+ sign: async (ownerAgentId, tbs) => {
3342
+ // `ownerAgentId` is the owner KEY here, and keyProviders is keyed by NAME — so it is resolved
3343
+ // back through the same map the owner key came from rather than guessed. A miss throws: an
3344
+ // unsigned envelope in an append-only log is worse than a failed publish.
3345
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3346
+ const provider = agentName ? keyProviders.get(agentName) : undefined;
3347
+ if (!provider) {
3348
+ throw new Error(`document_publish_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
3349
+ `so this update cannot be signed`);
3350
+ }
3351
+ return provider.sign(tbs);
3352
+ },
3353
+ // M14-D5: our wire sender id IS the owner key. Kept as its own callback because they are
3354
+ // different facts that happen to coincide — see DocumentStore.pendingDeliveries.
3355
+ senderIdFor: (ownerAgentId) => ownerAgentId,
3356
+ canPublish: (ownerAgentId, documentId) => documentLayer.lifecycle.canPublish(ownerAgentId, documentId),
3357
+ });
3358
+ registerDocumentHandlers({
3359
+ handlers,
3360
+ logger,
3361
+ layer: documentLayer,
3362
+ publish: documentPublish,
3363
+ transportFor: documentTransportFor,
3364
+ resolveAgent: (connectionId, explicit) => resolveCurrentAgent(perConnectionState.get(connectionId), explicit),
3365
+ ownerKeyFor: documentOwnerKeyFor,
3366
+ sign: async (agentName, tbs) => {
3367
+ const provider = keyProviders.get(agentName);
3368
+ if (!provider)
3369
+ throw new Error(`document_proposal_unsigned: no key provider for ${agentName}`);
3370
+ return provider.sign(tbs);
3371
+ },
3372
+ now: () => Date.now(),
3373
+ });
3374
+ /**
3375
+ * The delivery tick.
3376
+ *
3377
+ * Interval-driven rather than event-driven because the event we are actually waiting for — the
3378
+ * peer coming back online — is not one this daemon observes. There is no presence subscription
3379
+ * (parked, M14-P4), so the honest substitute is to ask periodically and let the per-envelope
3380
+ * backoff keep the cost down: a peer that has been away an hour is checked at the cap, not every
3381
+ * tick.
3382
+ *
3383
+ * Slow on purpose. Publish is fire-and-forget and nothing waits on this; a document that syncs a
3384
+ * minute later is working correctly, while a tight loop over every attended agent is a cost paid
3385
+ * forever for a case that is rare.
3386
+ */
3387
+ //
3388
+ // Overridable for tests, the way `CELLO_SEAL_BILATERAL_TIMEOUT_MS` already is. The live enforcers
3389
+ // spend their wall clock waiting for this tick — two of them is four minutes — and a test that
3390
+ // must sit out a production-paced timer either runs slowly or is written with a window so tight
3391
+ // that adding a second test to the file makes the first one fail. Both happened.
3392
+ //
3393
+ // Floored, so a misread env cannot turn the sweep into a busy loop against the directory.
3394
+ const tickOverride = Number(process.env["CELLO_DOCUMENT_DELIVERY_TICK_MS"]);
3395
+ const DOCUMENT_DELIVERY_TICK_MS = Number.isFinite(tickOverride) && tickOverride >= 250 ? tickOverride : 60_000;
3396
+ let documentDeliveryRunning = false;
3397
+ let documentDeliveryStopping = false;
3398
+ let documentDeliveryInFlight = null;
3399
+ const documentDeliveryTimer = setInterval(() => {
3400
+ // NO OVERLAP. A tick that dials a slow peer can outlast the interval, and a second pass would
3401
+ // re-send envelopes already in flight — the worker's own re-entry guard covers one agent, this
3402
+ // covers the sweep across all of them.
3403
+ if (documentDeliveryRunning)
3404
+ return;
3405
+ documentDeliveryRunning = true;
3406
+ documentDeliveryInFlight = (async () => {
3407
+ try {
3408
+ let agentsSwept = 0;
3409
+ let attempted = 0;
3410
+ let delivered = 0;
3411
+ let failed = 0;
3412
+ for (const agentName of perAgentSignaling.keys()) {
3413
+ // M2: a tick already running when the daemon stops must not keep dialling into a
3414
+ // half-torn-down transport. `clearInterval` stops the NEXT tick, not this one.
3415
+ if (documentDeliveryStopping)
3416
+ break;
3417
+ const ownerAgentId = documentOwnerKeyFor(agentName);
3418
+ // M14-D5: the owner key is our own pubkey hex. It is what BOTH halves scope by — the
3419
+ // inbound router resolves the same function — and it is what the wire sender id is.
3420
+ // Scoping the two halves differently returns nothing pending, reports nothing attempted,
3421
+ // and leaves a fully synced document invisible with no error on any path.
3422
+ if (ownerAgentId === null)
3423
+ continue;
3424
+ agentsSwept++;
3425
+ const peerFor = (documentId) => documentLayer.store.getDocument(ownerAgentId, documentId)?.peerAgentId ?? null;
3426
+ const result = await documentDeliveryFor(agentName).tick(ownerAgentId, peerFor, Date.now(), {
3427
+ senderAgentId: ownerAgentId,
3428
+ });
3429
+ attempted += result.attempted;
3430
+ delivered += result.delivered;
3431
+ failed += result.failed;
3432
+ }
3433
+ // H3: a sweep that delivers nothing was byte-for-byte identical to a healthy idle one, which
3434
+ // is what made every scoping bug above invisible. One line per tick, always.
3435
+ logger.debug("document.delivery.sweep", { agents: agentsSwept, attempted, delivered, failed });
3436
+ if (agentsSwept === 0 && documentLayer.store.anyDocumentExists()) {
3437
+ logger.warn("document.delivery.sweep_empty", {
3438
+ reason: "documents exist but no agent was swept — nothing will ever be delivered",
3439
+ });
3440
+ }
3441
+ }
3442
+ catch (err) {
3443
+ // Contained: one agent's delivery failure must not stop the sweep, and an unhandled
3444
+ // rejection out of a timer takes the daemon down.
3445
+ logger.warn("document.delivery.tick.failed", { reason: extractErrorMessage(err) });
3446
+ }
3447
+ finally {
3448
+ documentDeliveryRunning = false;
3449
+ documentDeliveryInFlight = null;
3450
+ }
3451
+ })();
3452
+ void documentDeliveryInFlight;
3453
+ }, DOCUMENT_DELIVERY_TICK_MS);
3454
+ // Never hold the process open on account of document delivery.
3455
+ documentDeliveryTimer.unref?.();
3102
3456
  // MCP-001: Clean up per-connection state when a connection disconnects
3103
3457
  // MCP-002: Also unregister from notification dispatcher
3104
3458
  ipcServer.onDisconnect((connectionId) => {
@@ -3152,7 +3506,44 @@ async function startDaemonHoldingLock(config, singletonLock) {
3152
3506
  // unauthenticated HTTP (startHttpManifestPoll above), NOT over a signaling stream. Its
3153
3507
  // lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
3154
3508
  // Graceful shutdown
3509
+ // DOD-LOGOUT-EXIT-1: the onStopped hook ends the process in the binary, so it fires at most once
3510
+ // per daemon no matter how many times stop() is called.
3511
+ let stoppedHookFired = false;
3155
3512
  async function stop(reason) {
3513
+ // M14: stop the document delivery sweep first — it opens sessions, and a tick landing during
3514
+ // teardown would dial into a daemon that is going away.
3515
+ clearInterval(documentDeliveryTimer);
3516
+ // `clearInterval` stops the NEXT tick; the flag stops the running one at its next agent.
3517
+ documentDeliveryStopping = true;
3518
+ // BOUNDED. This was a bare `await documentDeliveryInFlight`, and shutdown is the one place that
3519
+ // must not block on the network: a sweep mid-dial against an unreachable peer held `stop()`
3520
+ // open, so the interrupt-marking writes that follow did not complete before the process died —
3521
+ // and sessions came back `active` after a restart, which is the exact defect AC-009 exists to
3522
+ // catch. It failed on CI, where a directory node times out at 5s, and passed locally, where it
3523
+ // does not.
3524
+ //
3525
+ // A tick still gets a moment to finish so the common case is orderly; after that we proceed
3526
+ // without it. What the sweep leaves half-done is safe by construction — delivery state is
3527
+ // derived from the log, so an interrupted pass is re-derived on the next start.
3528
+ // NOT AWAITED AT ALL. Two attempts at waiting for it were both wrong, and the second was worse
3529
+ // than the first:
3530
+ //
3531
+ // 1. a bare await blocked shutdown on the network — a sweep mid-dial against an unreachable
3532
+ // peer held `stop()` open past the point where the interrupt-marking writes had to happen;
3533
+ // 2. bounding it with `setTimeout(...).unref()` looked like the fix and could HANG FOREVER: an
3534
+ // unref'd timer does not hold the event loop open, so while the loop is draining during
3535
+ // shutdown it may never fire, the race never settles, and `stop()` never reaches the line
3536
+ // that logs `daemon.stopped` — which is exactly what CI showed, a daemon whose log ends at
3537
+ // `daemon.started` with no shutdown events at all.
3538
+ //
3539
+ // There is nothing to wait for. `documentDeliveryStopping` stops the loop at its next agent, and
3540
+ // whatever a half-finished sweep leaves behind is safe by construction: delivery state is
3541
+ // DERIVED from the envelope log, so an interrupted pass is simply re-derived on the next start —
3542
+ // the property the offline enforcer proves against two real daemons.
3543
+ //
3544
+ // The rule this cost two CI round trips to learn: shutdown may not await anything that can
3545
+ // block on I/O, and a timeout that can outlive the event loop is not a bound.
3546
+ void documentDeliveryInFlight;
3156
3547
  // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
3157
3548
  // the generation so the running loop's while-condition fails on its next check.
3158
3549
  stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check
@@ -3168,6 +3559,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
3168
3559
  config.registryPollScheduler.cancel();
3169
3560
  }
3170
3561
  logger.info("daemon.stopped", { pid: process.pid, reason });
3562
+ // DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
3563
+ // exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never
3564
+ // marked interrupted, database never checkpointed — would exit 0 and `cello logout` would
3565
+ // print "Daemon stopped.", which is this unit's own defect one level up.
3566
+ let teardownError;
3171
3567
  try {
3172
3568
  // stopAllSignaling() stops the shared manager AND every per-agent manager (best-effort). Do
3173
3569
  // not add a separate per-agent stop loop beside it: it would be redundant, and an unguarded
@@ -3177,6 +3573,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
3177
3573
  await sessionNodeManager.gracefulShutdown();
3178
3574
  await ipcServer.stop();
3179
3575
  }
3576
+ catch (err) {
3577
+ // Recorded, then RE-THROWN below by the bare `throw` — the existing contract that a failed
3578
+ // stop() rejects is unchanged. This only makes the failure visible to onStopped as well.
3579
+ teardownError = err instanceof Error ? err : new Error(String(err));
3580
+ throw err;
3581
+ }
3180
3582
  finally {
3181
3583
  // DOD-M9B-WIRE-1: tear down whatever the composition root started alongside us — today the
3182
3584
  // screening sidecar. Two reasons it is HERE and not only in the bin's signal handler:
@@ -3216,6 +3618,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
3216
3618
  // Released LAST: while we hold it, no successor daemon can start, which is what lets
3217
3619
  // ipcServer.stop()'s socket re-check above be race-free.
3218
3620
  singletonLock.release();
3621
+ // DOD-LOGOUT-EXIT-1: signal that this daemon is DONE — the binary's handler is
3622
+ // `process.exit(0)`, so nothing may be sequenced after this line.
3623
+ //
3624
+ // AFTER singletonLock.release(), never before: the process is about to end, and dying while
3625
+ // still holding the kernel lock would make the next `cello login` refuse to start beside a
3626
+ // daemon that no longer exists.
3627
+ //
3628
+ // Inside the `finally` so a throw anywhere above still ends the process. A shutdown that
3629
+ // fails halfway and leaves the daemon alive and on the network is the exact defect this line
3630
+ // closes — "it threw" is not a reason to keep talking to a directory.
3631
+ //
3632
+ // Guarded: `stop()` has no idempotence of its own, and a caller that stops twice (an IPC
3633
+ // shutdown followed by an embedder's own stop) must not exit twice.
3634
+ // The hook's own failure is NOT caught. It is the only call that ends the process, so
3635
+ // swallowing a throw here would leave the daemon alive and handle-free while every check
3636
+ // logout makes agrees it is gone — the original defect, reached through the new code. Letting
3637
+ // it propagate makes stop() reject, which is what lets logout time out and say so.
3638
+ if (!stoppedHookFired) {
3639
+ stoppedHookFired = true;
3640
+ await config.onStopped?.({ ok: teardownError === undefined, error: teardownError });
3641
+ }
3219
3642
  }
3220
3643
  }
3221
3644
  function getSessionNodeManager() {
@@ -3233,6 +3656,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3233
3656
  // M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
3234
3657
  // prior run, without waiting for any agent to come online or any client to attach.
3235
3658
  startTelegramPollerIfConfigured();
3236
- return { stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry };
3659
+ /**
3660
+ * The live handler map.
3661
+ *
3662
+ * Exposed so the LATE-BINDING property can be asserted rather than assumed: dispatch resolves
3663
+ * from this map when a request arrives, and the only way to prove that is to register something
3664
+ * after `start()` has resolved and call it. The property is not decorative — a snapshot copy here
3665
+ * once made every `cello_doc_*` verb unreachable while the whole suite stayed green.
3666
+ *
3667
+ * Sits alongside `getSessionNodeManager` and `getTypeRegistry`, which are exposed for the same
3668
+ * reason. Production code has no business mutating it after boot.
3669
+ */
3670
+ const getHandlers = () => handlers;
3671
+ return {
3672
+ stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry,
3673
+ getHandlers,
3674
+ };
3237
3675
  }
3238
3676
  //# sourceMappingURL=daemon.js.map