@cello-protocol/daemon 0.0.182 → 0.0.184

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 (137) hide show
  1. package/dist/agent-settings-keys.d.ts.map +1 -1
  2. package/dist/agent-settings-keys.js +18 -0
  3. package/dist/agent-settings-keys.js.map +1 -1
  4. package/dist/close-session-handler.d.ts.map +1 -1
  5. package/dist/close-session-handler.js +90 -16
  6. package/dist/close-session-handler.js.map +1 -1
  7. package/dist/consortium-bootstrap.d.ts.map +1 -1
  8. package/dist/consortium-bootstrap.js +117 -2
  9. package/dist/consortium-bootstrap.js.map +1 -1
  10. package/dist/content-encryption-status.d.ts +97 -0
  11. package/dist/content-encryption-status.d.ts.map +1 -0
  12. package/dist/content-encryption-status.js +122 -0
  13. package/dist/content-encryption-status.js.map +1 -0
  14. package/dist/content-park-client.d.ts +10 -0
  15. package/dist/content-park-client.d.ts.map +1 -1
  16. package/dist/content-park-client.js +84 -3
  17. package/dist/content-park-client.js.map +1 -1
  18. package/dist/content-park.d.ts.map +1 -1
  19. package/dist/content-park.js +184 -13
  20. package/dist/content-park.js.map +1 -1
  21. package/dist/daemon.d.ts.map +1 -1
  22. package/dist/daemon.js +671 -67
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/document-handlers.d.ts.map +1 -1
  25. package/dist/document-handlers.js +119 -0
  26. package/dist/document-handlers.js.map +1 -1
  27. package/dist/document-layer.d.ts +0 -2
  28. package/dist/document-layer.d.ts.map +1 -1
  29. package/dist/document-layer.js.map +1 -1
  30. package/dist/frame-values.d.ts.map +1 -1
  31. package/dist/frame-values.js +14 -0
  32. package/dist/frame-values.js.map +1 -1
  33. package/dist/inclusion-proof-handlers.d.ts +43 -0
  34. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  35. package/dist/inclusion-proof-handlers.js +565 -0
  36. package/dist/inclusion-proof-handlers.js.map +1 -0
  37. package/dist/inclusion-proof.d.ts +151 -0
  38. package/dist/inclusion-proof.d.ts.map +1 -0
  39. package/dist/inclusion-proof.js +228 -0
  40. package/dist/inclusion-proof.js.map +1 -0
  41. package/dist/initiate-session-handler.d.ts.map +1 -1
  42. package/dist/initiate-session-handler.js +34 -3
  43. package/dist/initiate-session-handler.js.map +1 -1
  44. package/dist/manifest-deps.d.ts +0 -11
  45. package/dist/manifest-deps.d.ts.map +1 -1
  46. package/dist/manifest-deps.js +28 -2
  47. package/dist/manifest-deps.js.map +1 -1
  48. package/dist/manifest-validity.d.ts +2 -0
  49. package/dist/manifest-validity.d.ts.map +1 -1
  50. package/dist/manifest-validity.js +1 -1
  51. package/dist/manifest-validity.js.map +1 -1
  52. package/dist/network-directory-node.d.ts +16 -0
  53. package/dist/network-directory-node.d.ts.map +1 -1
  54. package/dist/network-directory-node.js +40 -0
  55. package/dist/network-directory-node.js.map +1 -1
  56. package/dist/notification-handlers.d.ts.map +1 -1
  57. package/dist/notification-handlers.js +71 -2
  58. package/dist/notification-handlers.js.map +1 -1
  59. package/dist/outbound-sessions.d.ts +6 -0
  60. package/dist/outbound-sessions.d.ts.map +1 -1
  61. package/dist/outbound-sessions.js +50 -7
  62. package/dist/outbound-sessions.js.map +1 -1
  63. package/dist/park-envelope.d.ts +23 -0
  64. package/dist/park-envelope.d.ts.map +1 -1
  65. package/dist/park-envelope.js +76 -0
  66. package/dist/park-envelope.js.map +1 -1
  67. package/dist/register-handler.d.ts.map +1 -1
  68. package/dist/register-handler.js +76 -0
  69. package/dist/register-handler.js.map +1 -1
  70. package/dist/relay-only.d.ts +133 -0
  71. package/dist/relay-only.d.ts.map +1 -0
  72. package/dist/relay-only.js +183 -0
  73. package/dist/relay-only.js.map +1 -0
  74. package/dist/retry-queue.d.ts +26 -1
  75. package/dist/retry-queue.d.ts.map +1 -1
  76. package/dist/retry-queue.js +18 -1
  77. package/dist/retry-queue.js.map +1 -1
  78. package/dist/seal-coordinator.d.ts +51 -1
  79. package/dist/seal-coordinator.d.ts.map +1 -1
  80. package/dist/seal-coordinator.js +248 -1
  81. package/dist/seal-coordinator.js.map +1 -1
  82. package/dist/seal-escalation.d.ts.map +1 -1
  83. package/dist/seal-escalation.js +28 -0
  84. package/dist/seal-escalation.js.map +1 -1
  85. package/dist/seal-failure-store.d.ts.map +1 -1
  86. package/dist/seal-failure-store.js +5 -1
  87. package/dist/seal-failure-store.js.map +1 -1
  88. package/dist/sealed-leaf-set.d.ts +92 -0
  89. package/dist/sealed-leaf-set.d.ts.map +1 -0
  90. package/dist/sealed-leaf-set.js +129 -0
  91. package/dist/sealed-leaf-set.js.map +1 -0
  92. package/dist/session-ceremony.d.ts +27 -1
  93. package/dist/session-ceremony.d.ts.map +1 -1
  94. package/dist/session-ceremony.js +109 -7
  95. package/dist/session-ceremony.js.map +1 -1
  96. package/dist/session-content-handlers.d.ts +12 -0
  97. package/dist/session-content-handlers.d.ts.map +1 -1
  98. package/dist/session-content-handlers.js +39 -4
  99. package/dist/session-content-handlers.js.map +1 -1
  100. package/dist/session-node-manager.d.ts +387 -4
  101. package/dist/session-node-manager.d.ts.map +1 -1
  102. package/dist/session-node-manager.js +3529 -279
  103. package/dist/session-node-manager.js.map +1 -1
  104. package/dist/session-read-handlers.d.ts.map +1 -1
  105. package/dist/session-read-handlers.js +16 -1
  106. package/dist/session-read-handlers.js.map +1 -1
  107. package/dist/session-relay-client.d.ts +163 -1
  108. package/dist/session-relay-client.d.ts.map +1 -1
  109. package/dist/session-relay-client.js +540 -6
  110. package/dist/session-relay-client.js.map +1 -1
  111. package/dist/session-salt-agreement.d.ts +58 -4
  112. package/dist/session-salt-agreement.d.ts.map +1 -1
  113. package/dist/session-salt-agreement.js +110 -3
  114. package/dist/session-salt-agreement.js.map +1 -1
  115. package/dist/signal-submission.d.ts +11 -1
  116. package/dist/signal-submission.d.ts.map +1 -1
  117. package/dist/signal-submission.js.map +1 -1
  118. package/dist/signaling-connect.d.ts +18 -1
  119. package/dist/signaling-connect.d.ts.map +1 -1
  120. package/dist/signaling-connect.js +164 -1
  121. package/dist/signaling-connect.js.map +1 -1
  122. package/dist/submission-retry.d.ts +208 -0
  123. package/dist/submission-retry.d.ts.map +1 -0
  124. package/dist/submission-retry.js +505 -0
  125. package/dist/submission-retry.js.map +1 -0
  126. package/dist/types.d.ts +33 -0
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/vocabulary.d.ts +13 -3
  130. package/dist/vocabulary.d.ts.map +1 -1
  131. package/dist/vocabulary.js +14 -3
  132. package/dist/vocabulary.js.map +1 -1
  133. package/dist/wire-content-hash.d.ts +15 -3
  134. package/dist/wire-content-hash.d.ts.map +1 -1
  135. package/dist/wire-content-hash.js +15 -3
  136. package/dist/wire-content-hash.js.map +1 -1
  137. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -52,6 +52,7 @@ import { createSignalingConnect } from "./signaling-connect.js";
52
52
  import { DbRegistrationPersistence, DbIdentityStore } from "./db-identity-store.js";
53
53
  import { DbManifestVersionStore } from "./manifest-version-store-db.js";
54
54
  import { composeSealedSubmission, sendSealedSubmission, fetchSubmissionResults } from "./signal-submission.js";
55
+ import { SubmissionRetryQueue, isRetryableSendFailure, DEFAULT_RETRY_WINDOW_MS } from "./submission-retry.js";
55
56
  /**
56
57
  * Cap on a refusal message (M10B-D4). Generous for prose — the point is not to police what the
57
58
  * operator writes, it is that an UNBOUNDED string reaches a signer, a sealer and a transport, and
@@ -72,7 +73,7 @@ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
72
73
  import { registerContactHandlers } from "./contact-handlers.js";
73
74
  import { createSealCoordinator } from "./seal-coordinator.js";
74
75
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
75
- import { registerSessionContentHandlers } from "./session-content-handlers.js";
76
+ import { registerSessionContentHandlers, sentAuthorship } from "./session-content-handlers.js";
76
77
  import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
77
78
  import { isDocumentFrame } from "./document-frame-router.js";
78
79
  import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
@@ -89,6 +90,7 @@ import { registerCloseSessionHandler } from "./close-session-handler.js";
89
90
  import { createInboundSessions } from "./inbound-sessions.js";
90
91
  import { createOutboundSessions } from "./outbound-sessions.js";
91
92
  import { registerSessionReadHandlers } from "./session-read-handlers.js";
93
+ import { registerInclusionProofHandlers } from "./inclusion-proof-handlers.js";
92
94
  import { pullSealCertificate } from "./seal-certificate-pull.js";
93
95
  import { createBackup, inspectBackup } from "./backup-restore.js";
94
96
  import { resolveCurrentAgentFor } from "./agent-selection.js";
@@ -110,6 +112,7 @@ import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
110
112
  import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
111
113
  import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
112
114
  import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
115
+ import { relayOnlyState } from "./relay-only.js";
113
116
  import { decodeCbor } from "@cello-protocol/protocol-types";
114
117
  // Minimal no-op KeyProvider stub for session nodes.
115
118
  // Session nodes don't need signing keys — libp2p generates its own fresh
@@ -162,9 +165,15 @@ export class ProductionSessionNodeFactory {
162
165
  listenAddresses: [listenAddr, ...(config.circuitRelayListenAddrs ?? [])],
163
166
  ...(announce ? { announceAddresses: announce } : {}),
164
167
  connectionGater: config.connectionGater,
168
+ // DOD-M15-RELAYONLY-1: an agent that asked never to be directly reachable must not hole-punch
169
+ // its way to a direct connection. dcutr's job is to UPGRADE a relayed connection, and the
170
+ // INBOUND side starts that upgrade — which is precisely the standing receiver. So filtering
171
+ // what the directory publishes is not enough on its own: the address a peer cannot be TOLD, a
172
+ // hole-punch still REVEALS, and it happens inside libp2p after every assertion has passed.
173
+ ...(config.relayOnly === true ? { holePunch: { enabled: false } } : {}),
165
174
  // Forward the role. After DOD-NAT-REACHABILITY-1, dcutr is on every node
166
- // type; nodeType's remaining transport effect is the HOP gate (client
167
- // types never advertise circuit-relay HOP).
175
+ // type unless relay-only turns it off above; nodeType's remaining transport effect is the HOP
176
+ // gate (client types never advertise circuit-relay HOP).
168
177
  nodeType: config.nodeType,
169
178
  // DOD-M12B-SESSION-SEED-1: forward the caller's transport seed when it supplied one, so a
170
179
  // rebuilt session node returns at the peer id the counterparty already holds. Omitted (rather
@@ -589,6 +598,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
589
598
  for (const a of loadedAgents) {
590
599
  keyProviders.set(a.name, a.keyProvider);
591
600
  }
601
+ // DOD-M15-EPHEMERAL-AUTH-1: the session manager signs each session's throwaway key with the
602
+ // agent's identity, so it needs the same providers. Injected here rather than through the
603
+ // constructor because this map is built after the manager exists — the same reason
604
+ // `setParkedDrainHook` is a setter.
605
+ sessionNodeManager.setKeyProviderResolver((agentName) => keyProviders.get(agentName));
592
606
  // Constructed HERE, before ANY boot-time caller. autoRecoverForAgent is invoked from an agent's
593
607
  // onConnected and from the seal-upgrade content gate — both of which run long before the IPC
594
608
  // handler map exists. Its handlers register later (phase 2), which is what lets this sit up here.
@@ -634,6 +648,127 @@ async function startDaemonHoldingLock(config, singletonLock) {
634
648
  // ensure→drain contract — a re-park needs the receiver that the ensure step rebuilds.
635
649
  flushSender: (agentName) => flushAwaitingContent(agentName),
636
650
  });
651
+ /**
652
+ * DOD-M15-ENDORSE-RETRY-1 — sealed submissions whose send reached no directory node.
653
+ *
654
+ * The consortium has three nodes and a submission used to die with whichever one this daemon
655
+ * happened to be connected to. It is held here instead and re-sent when the SignalingManager
656
+ * reconnects — that reconnect IS the failover, and nothing in this daemon picks a node for a
657
+ * submission (`sendSealedSubmission`'s header rules that out). Safe because `submission_id` is
658
+ * derived from the signed plaintext: a second node stores it once and the portal mints once.
659
+ */
660
+ const submissionRetries = new SubmissionRetryQueue({
661
+ logger,
662
+ send: async (pending) => {
663
+ /**
664
+ * THE AGENT MUST STILL BE HERE TO SIGN, and this failure has its own name.
665
+ *
666
+ * It borrowed `submission_refused_by_node` at first, which states that a directory node
667
+ * decoded, evaluated and refused the submission — none of which happened. Stacked on the
668
+ * give-up reason it produced, the operator saw two labels both pointing at the directory for
669
+ * a cause that is entirely local (review M5).
670
+ *
671
+ * Neither branch is reachable in this daemon today — nothing removes from `keyProviders` or
672
+ * `loadedAgents` — so this is a guard against a future unload path rather than a live case.
673
+ * That is said plainly instead of being implied by a comment describing a state the code
674
+ * cannot reach.
675
+ */
676
+ /**
677
+ * READ the agent's manager; never CREATE one, and this ONE guard is the whole check.
678
+ *
679
+ * `getAgentSignaling` is not a getter — for an agent with no manager it constructs one, which
680
+ * dials, authenticates, and installs an unbounded reconnect loop. `dropAgentSignaling` exists
681
+ * to stop and forget a manager for an agent whose registration failed terminally, and a
682
+ * background retry that silently rebuilt it would undo that decision from a timer nobody is
683
+ * watching.
684
+ *
685
+ * It replaces a `keyProviders` + `loadedAgents` pair that is now dead: the manager was built
686
+ * WITH this agent's key provider and pubkey, so its presence is the accurate statement of
687
+ * "this daemon can still send as this agent", and nothing prunes either of those two maps.
688
+ *
689
+ * There is always a manager here in practice — the first-pass send built one before this
690
+ * submission could ever have been held. Its absence means it was deliberately dropped, and
691
+ * the right answer is to stop trying, not to resurrect it.
692
+ */
693
+ // The SAME resolution `getAgentSignaling` performs, minus the construction: the shared
694
+ // manager first (the in-process path, where `perAgentSignaling` is never populated at all),
695
+ // then this agent's own. Reading only the per-agent map would refuse every retry on the
696
+ // shared path — which is how this fix first failed its own live test.
697
+ const existing = sharedSignaling
698
+ ? { signaling: sharedSignaling }
699
+ : perAgentSignaling.get(pending.agentName);
700
+ if (!existing) {
701
+ return {
702
+ ok: false,
703
+ reason: "submission_agent_unloaded",
704
+ guidance: `The directory connection for '${pending.agentName}' has been torn down, so the held ` +
705
+ "submission cannot be sent. Start the agent with cello_start_agent and issue it again — " +
706
+ "re-sending is safe, the submission id is derived from the content.",
707
+ };
708
+ }
709
+ return sendSealedSubmission({
710
+ signaling: existing.signaling,
711
+ submissionId: pending.submissionId,
712
+ intakeKeyId: pending.intakeKeyId,
713
+ ciphertext: pending.ciphertext,
714
+ logger,
715
+ });
716
+ },
717
+ onAccepted: (pending, stored) => {
718
+ // THE STABLE ID THE ENQUEUE CAPTURED, not a re-resolution from the mutable name (review M6).
719
+ // `agentName` is a display label and is reusable after a retire; re-deriving it here would
720
+ // write the accepted row under a different agent's id if a name were retired and reused
721
+ // inside the retry window. The correct value is already in the struct.
722
+ recordIssuedSubmission(pending.agentName, pending.agentId, {
723
+ submissionId: pending.submissionId,
724
+ subject: pending.subject,
725
+ op: pending.op,
726
+ intakeKeyId: pending.intakeKeyId,
727
+ stored,
728
+ });
729
+ },
730
+ ...(config.submissionRetryIntervalsMs?.staggerMs === undefined
731
+ ? {}
732
+ : { staggerMs: config.submissionRetryIntervalsMs.staggerMs }),
733
+ ...(config.submissionRetryIntervalsMs?.localPreconditionRetryMs === undefined
734
+ ? {}
735
+ : { localPreconditionRetryMs: config.submissionRetryIntervalsMs.localPreconditionRetryMs }),
736
+ });
737
+ /**
738
+ * KEEP THE HANDLE, or a withdrawal has nothing to name. The submission id is content-derived and
739
+ * so reproducible in principle, but only by re-composing the exact original body — which the
740
+ * operator no longer has once they have sent it.
741
+ *
742
+ * Best-effort on purpose: the submission IS accepted by the time this runs, and failing the call
743
+ * over a local bookkeeping write would turn a success into a reported failure and invite a
744
+ * re-send of something already queued. Logged loudly instead.
745
+ *
746
+ * Shared by the first-pass send and the retry, so the two cannot drift about what a landed
747
+ * submission records.
748
+ */
749
+ function recordIssuedSubmission(agentName,
750
+ /** The STABLE key, supplied by the caller. Never re-derived from `agentName` here — that is a
751
+ * display label, and this table is keyed by identity. */
752
+ agentId, s) {
753
+ try {
754
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
755
+ store.recordIssuedSubmission({
756
+ agentId,
757
+ submissionId: s.submissionId,
758
+ subjectPubkey: s.subject,
759
+ op: s.op,
760
+ intakeKeyId: s.intakeKeyId,
761
+ stored: s.stored,
762
+ });
763
+ }
764
+ catch (err) {
765
+ logger.error("signal.submission.record_failed", {
766
+ agentName,
767
+ submissionId: s.submissionId,
768
+ reason: err instanceof Error ? err.message : String(err),
769
+ });
770
+ }
771
+ }
637
772
  // Created HERE, not where the seal code used to sit (~2,500 lines down), because the listeners
638
773
  // are wired into every signaling manager below — and the originals were FUNCTION DECLARATIONS,
639
774
  // so hoisting silently let them be CALLED 1,900 lines before they were DEFINED. A const in their
@@ -643,12 +778,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
643
778
  // Bilateral seal, unilateral escalation, and the returning-absent-party upgrade: five pieces of
644
779
  // state and the listeners that drive them. Already seal-private; now that is enforced by a module
645
780
  // boundary rather than by convention. cello_close_session still drives the waiters directly.
781
+ //
782
+ // DOD-M15-SEAL-FAILED-TERMINAL-1: a seal that ended without a receipt is discoverable rather than
783
+ // being a line in daemon.log. In memory on purpose — a restart makes "failed" the WRONG answer,
784
+ // because the boot sweep plus the restart seal resolver retry the session, so a marker whose
785
+ // lifetime is the process matches the lifetime of the condition it describes.
786
+ //
787
+ // Declared HERE rather than 600 lines below because the seal coordinator writes to it: a
788
+ // directory refusal must survive the close call waiting on it (DOD-M15-SEALPARTIES-1).
789
+ const sealFailures = new SealFailureStore();
646
790
  const { sealKey, sealInterruptedInProgress, pendingSealWaiters, pendingUnilateralWaiters, registerSealListeners, } = createSealCoordinator({
647
791
  logger,
648
792
  sessionNodeManager,
649
793
  getPersistence,
650
794
  getKeyProvider: (agentName) => keyProviders.get(agentName),
651
795
  recoverContent: (agentName) => autoRecoverForAgent(agentName, "seal_upgrade_gate"),
796
+ // DOD-M15-SEALPARTIES-1: a directory refusal has to outlive the close call that is waiting on
797
+ // it — `cello_status` and the receipt surface both read this store, and a close that already
798
+ // returned has nowhere else to leave the answer.
799
+ recordSealFailure: (agentName, sessionId, reason) => sealFailures.record(agentName, sessionId, reason, new Date().toISOString(), "unresolved"),
652
800
  });
653
801
  // The two seal-initiation flows cello_close_session dispatches into (seal-flows.ts): the
654
802
  // counterparty is gone (seal-interrupted) or live (bilateral). Neither can notarize on its own —
@@ -799,6 +947,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
799
947
  onRelayEndpoints: (endpoints) => {
800
948
  sessionNodeManager.setDirectoryRelayEndpoints(agentName, endpoints.map((e) => ({ relayPeerId: e.peerId, relayAddrs: e.addrs })));
801
949
  },
950
+ // DOD-M15-RELAYSLOTS-1: and the credential those relays require. Same frame, same cadence as
951
+ // the endpoints above — every connect and every reconnect, which is what keeps a token that
952
+ // expires within the hour current for a receiver that lives much longer than that.
953
+ onOnlineToken: (token) => {
954
+ sessionNodeManager.setDirectoryOnlineToken(agentName, token);
955
+ },
956
+ // Review M1: and when there is none, WHY — so the operator surface can say "this directory
957
+ // does not know this agent" instead of "check your directory connection" about a connection
958
+ // that just succeeded.
959
+ onOnlineTokenAbsent: (reason) => {
960
+ sessionNodeManager.setDirectoryOnlineTokenAbsent(agentName, reason);
961
+ },
802
962
  });
803
963
  const mgr = new SignalingManager({
804
964
  connect,
@@ -821,8 +981,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
821
981
  // from every relay it has session history with, so a message parked while signaling was down
822
982
  // is not left until the next agent start. The drain needs the node the ensure builds, which
823
983
  // is why it no longer runs beside it.
984
+ //
985
+ // AND THIRD, DOD-M15-ENDORSE-RETRY-1: re-send any sealed submission that reached no node.
986
+ // Deliberately outside `createReconnectDrain`'s ensure→drain contract and after it: that
987
+ // ordering exists because the drain needs the standing receiver the ensure rebuilds, and a
988
+ // submission needs neither — it needs only the stream that just came up.
824
989
  onConnected: () => {
825
990
  onSignalingConnected(agentName);
991
+ submissionRetries.onSignalingConnected(agentName);
826
992
  },
827
993
  });
828
994
  const entry = { signaling: mgr, getNode: () => nodeRef };
@@ -845,6 +1011,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
845
1011
  getConsortiumEndpoints: resolveConsortiumRoster,
846
1012
  signaling: mgr,
847
1013
  logger,
1014
+ // DOD-M15-SEALPARTIES-1: where a dead seal ceremony leaves its mark, so `cello_sealed_receipt`
1015
+ // can say it FAILED and why instead of falling through to "no receipt yet".
1016
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
848
1017
  });
849
1018
  // DOD-SPINE-7: coordinate the SEAL FROST ceremony on this agent's stream too.
850
1019
  wireSealCeremonyHandler({
@@ -861,6 +1030,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
861
1030
  getConsortiumEndpoints: resolveConsortiumRoster,
862
1031
  signaling: mgr,
863
1032
  logger,
1033
+ // DOD-M15-SEALPARTIES-1: where a dead seal ceremony leaves its mark, so `cello_sealed_receipt`
1034
+ // can say it FAILED and why instead of falling through to "no receipt yet".
1035
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
864
1036
  });
865
1037
  // DOD-SPINE-7: and resolve session_sealed for this agent's sessions on its own stream.
866
1038
  registerSealListeners(mgr, agentName, agentPubkeyHex);
@@ -870,6 +1042,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
870
1042
  agentName,
871
1043
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
872
1044
  admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
1045
+ // DOD-M15-RELAYONLY-1: lets the handler tell "no addresses because relay-only filtered them"
1046
+ // from "no addresses yet", which need opposite answers — a refusal, and the pre-existing path.
1047
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agentName, key), sessionNodeManager.hasDatabase()) !== "off",
873
1048
  signaling: mgr,
874
1049
  logger,
875
1050
  });
@@ -882,6 +1057,40 @@ async function startDaemonHoldingLock(config, singletonLock) {
882
1057
  return;
883
1058
  void handleTrustSignalPickup(frame, agentKeyProvider, mgr, agentName);
884
1059
  });
1060
+ /**
1061
+ * DOD-M15-SEALPARTIES-1 Part 0: take the relay credential off `register_success`.
1062
+ *
1063
+ * `onOnlineToken` above catches every signaling auth and reconnect, and misses the one case that
1064
+ * matters most: a brand-new agent. Its daemon opens this very stream in order TO register (the
1065
+ * DKG runs over it), so the auth that created the stream happened while the directory still had
1066
+ * no profile for the key and correctly issued nothing. A healthy stream never re-authenticates,
1067
+ * so without this the agent holds no relay credential for the life of the daemon — no circuit
1068
+ * reservation, unwitnessed leaves, and a close that fails with `seal_persist_failed`.
1069
+ *
1070
+ * A frame with no token is left alone rather than clearing what is held: the directory that
1071
+ * issues here is the same one whose auth_ok issues, so overwriting a good token with an absence
1072
+ * would turn one directory's minting failure into a reachability outage the operator cannot
1073
+ * explain. The absence is already reported by the directory's own `online_token.failed`.
1074
+ */
1075
+ mgr.registerInboundHandler((frame) => {
1076
+ if (frame["type"] !== "register_success")
1077
+ return;
1078
+ const raw = frame["online_token"];
1079
+ const token = raw instanceof Uint8Array ? raw : Buffer.isBuffer(raw) ? new Uint8Array(raw) : undefined;
1080
+ if (token && token.length > 0) {
1081
+ sessionNodeManager.setDirectoryOnlineToken(agentName, token);
1082
+ logger.info("directory.online_token.received", { agentName, source: "register_success", bytes: token.length });
1083
+ return;
1084
+ }
1085
+ logger.warn("directory.online_token.absent", {
1086
+ agentName,
1087
+ source: "register_success",
1088
+ impact: "this agent just registered and was handed no relay online token, so no relay will " +
1089
+ "let it hold a circuit reservation until its directory signaling stream reconnects and " +
1090
+ "re-issues one. Until then it is reachable only over a direct connection, and a session " +
1091
+ "it does hold cannot get its leaves witnessed.",
1092
+ });
1093
+ });
885
1094
  // CELLO-M7-CONN-001 (DOD-CONN-2): inbound session_assignment + seal_interrupted_request
886
1095
  // on THIS agent's own stream, so a non-primary agent receives inbound sessions (SPINE-5).
887
1096
  wirePerAgentSessionInbound(mgr);
@@ -926,6 +1135,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
926
1135
  getConsortiumEndpoints: resolveConsortiumRoster,
927
1136
  signaling: mgr,
928
1137
  logger,
1138
+ // DOD-M15-SEALPARTIES-1: where a dead seal ceremony leaves its mark, so `cello_sealed_receipt`
1139
+ // can say it FAILED and why instead of falling through to "no receipt yet".
1140
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
929
1141
  });
930
1142
  wireSealCeremonyHandler({
931
1143
  agentName: agent.name,
@@ -939,11 +1151,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
939
1151
  getConsortiumEndpoints: resolveConsortiumRoster,
940
1152
  signaling: mgr,
941
1153
  logger,
1154
+ // DOD-M15-SEALPARTIES-1: where a dead seal ceremony leaves its mark, so `cello_sealed_receipt`
1155
+ // can say it FAILED and why instead of falling through to "no receipt yet".
1156
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
942
1157
  });
943
1158
  wireSessionOfferHandler({
944
1159
  agentName: agent.name,
945
1160
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
946
1161
  admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
1162
+ // DOD-M15-RELAYONLY-1 — see the note on the sibling call site above.
1163
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agent.name, key), sessionNodeManager.hasDatabase()) !== "off",
947
1164
  signaling: mgr,
948
1165
  logger,
949
1166
  });
@@ -1167,14 +1384,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
1167
1384
  * every other one.
1168
1385
  */
1169
1386
  const backgroundSeals = new Set();
1170
- /**
1171
- * DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
1172
- *
1173
- * In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
1174
- * restart seal resolver retry the session. A marker whose lifetime is the process matches the
1175
- * lifetime of the condition it describes.
1176
- */
1177
- const sealFailures = new SealFailureStore();
1178
1387
  // DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
1179
1388
  // the call site so it names the specific away agent.
1180
1389
  // DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
@@ -1280,9 +1489,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1280
1489
  // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
1281
1490
  // wrong index does not merely stall the far side, it seals a tree the counterparty can
1282
1491
  // never agree with.
1283
- const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID());
1492
+ // The proof travels with the leaf, not only with the transcript row below: when this
1493
+ // append is HELD behind a gap, the `recordTranscriptMessage` call is skipped entirely
1494
+ // and the held entry is the only thing that reaches the row on release.
1495
+ const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1284
1496
  if (placed.placed) {
1285
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
1497
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID(), sentAuthorship(sendResult));
1286
1498
  }
1287
1499
  logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
1288
1500
  }
@@ -1503,9 +1715,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1503
1715
  // next arrival would mint a second greeting at a second sequence.
1504
1716
  // DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
1505
1717
  // that is where its leaf goes.
1506
- const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1718
+ // Witnessed and SIGNED only the direct hand-off failed — so the proof exists and must
1719
+ // reach the leaf. This is the site `sentAuthorship`'s own comment calls dead-by-construction
1720
+ // under an `ok`-gated read; the same reasoning applies to the leaf, not just the row.
1721
+ const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1507
1722
  if (placedQueued.placed) {
1508
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
1723
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1509
1724
  }
1510
1725
  logger.info("session.away.response.deferred", {
1511
1726
  agentName, sessionId, kind, isKnown,
@@ -1517,9 +1732,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1517
1732
  }
1518
1733
  // DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
1519
1734
  // path most likely to have a gap open under it — exactly where a tail append does damage.
1520
- const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1735
+ const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1521
1736
  if (placedReply.placed) {
1522
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
1737
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1523
1738
  }
1524
1739
  logger.info("session.away.response.sent", {
1525
1740
  agentName, sessionId, kind, isKnown,
@@ -1713,6 +1928,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1713
1928
  // was looking.
1714
1929
  getUnresolvedNodes,
1715
1930
  getDeclaredNodeCount,
1931
+ // DOD-M15-SEALPARTIES-1: the visiting stream runs the seal ceremony too, so it needs the same
1932
+ // failure sink — otherwise a cross-node close that dies leaves no trace while a same-node one does.
1933
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
1716
1934
  });
1717
1935
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
1718
1936
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
@@ -1752,6 +1970,13 @@ async function startDaemonHoldingLock(config, singletonLock) {
1752
1970
  // seal depends on — silently, while reporting success.
1753
1971
  receiptStore: stores.receiptStore,
1754
1972
  sealLeafStore: stores.sealLeafStore,
1973
+ // DOD-M15-RELAYSLOTS-1: the manager owns the current token and hands the accessor down, so
1974
+ // this client reads a fresh one at every auth instead of a snapshot taken here at build time.
1975
+ onlineToken: stores.onlineToken,
1976
+ // DOD-M15-CORROBORATE-1: the DETACHED client needs this as much as the live one — a session
1977
+ // being sealed after a restart is exactly when its operator is not watching.
1978
+ onWitnessAlert: (alert) => { sessionNodeManager.recordRelayWitnessAlert(agentName, alert); },
1979
+ onWitnessUnreadable: (peerId, why) => { sessionNodeManager.recordRelayWitnessUnreadable(agentName, peerId, why); },
1755
1980
  });
1756
1981
  });
1757
1982
  // DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
@@ -1847,6 +2072,54 @@ async function startDaemonHoldingLock(config, singletonLock) {
1847
2072
  logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
1848
2073
  return null;
1849
2074
  });
2075
+ /**
2076
+ * DOD-M15-RELAYABUSE-1: scheduled park-retry timers, tracked so shutdown can clear them.
2077
+ *
2078
+ * ⚠️ Every other daemon timer is cleared in `stop()`; an untracked one lets an in-process restart
2079
+ * leave a stale timer that drains into a torn-down manager. Unref'd already, so it cannot hold the
2080
+ * process open — this is about a clean teardown, not about exiting.
2081
+ */
2082
+ const parkRetryTimers = new Set();
2083
+ /**
2084
+ * DOD-M15-RELAYABUSE-1: schedule ONE drain at the delay the relay asked for.
2085
+ *
2086
+ * ⚠️ ONE implementation, used by both park paths. The live-send path and the drain path both need
2087
+ * this and a second copy is how the clamp ends up on only one of them — which is exactly the shape
2088
+ * review found here (the number had a consumer on one path and was dropped on the other).
2089
+ *
2090
+ * CLAMPED, because the value comes from another party's software and an unclamped one inverts the
2091
+ * fix: Node's `setTimeout` holds an int32, so a relay reporting `3_000_000_000` overflows it and
2092
+ * fires in about ONE MILLISECOND — re-parking instantly into the limit that just refused, and
2093
+ * printing a `TimeoutOverflowWarning` from a daemon whose convention forbids console output.
2094
+ */
2095
+ function scheduleParkRetry(retryAfterMs, filterAgentName, source) {
2096
+ const MIN_RETRY_MS = 1_000;
2097
+ const MAX_RETRY_MS = 5 * 60_000;
2098
+ const delay = Math.min(Math.max(retryAfterMs, MIN_RETRY_MS), MAX_RETRY_MS);
2099
+ if (delay !== retryAfterMs) {
2100
+ logger.warn("content.park.retry.clamped", {
2101
+ source,
2102
+ requestedMs: retryAfterMs,
2103
+ usedMs: delay,
2104
+ impact: "the relay asked for a retry delay outside the range this daemon will schedule, so it was " +
2105
+ "clamped — an unclamped value can overflow the timer and fire immediately, which would " +
2106
+ "re-park straight into the limit that just refused",
2107
+ });
2108
+ }
2109
+ logger.info("content.park.retry.scheduled", { source, delayMs: delay, ...(filterAgentName !== undefined ? { agentName: filterAgentName } : {}) });
2110
+ const timer = setTimeout(() => {
2111
+ parkRetryTimers.delete(timer);
2112
+ void flushAwaitingContent(filterAgentName).catch((err) => {
2113
+ logger.warn("content.park.retry.timer.failed", {
2114
+ source,
2115
+ reason: err instanceof Error ? err.message : String(err),
2116
+ impact: "the scheduled drain threw; the ordinary event triggers (boot, agent start, reconnect) still apply",
2117
+ });
2118
+ });
2119
+ }, delay);
2120
+ timer.unref?.();
2121
+ parkRetryTimers.add(timer);
2122
+ }
1850
2123
  sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
1851
2124
  const node = sessionNodeManager.getStandingReceiverNode();
1852
2125
  if (!node) {
@@ -1885,8 +2158,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
1885
2158
  // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1886
2159
  // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1887
2160
  // is salted is a fact about the message that was sent; what this side holds now says nothing
1888
- // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1889
- // carrying the value that cannot break anything, and only then does the value change.
2161
+ // about it.
2162
+ //
2163
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
2164
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
2165
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
2166
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
2167
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
2168
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
2169
+ // verdict on an honest message.
1890
2170
  const ciphertext = await sealParkEnvelope({
1891
2171
  signer: senderKp,
1892
2172
  sessionIdHex: sessionId,
@@ -1910,8 +2190,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
1910
2190
  logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
1911
2191
  return { ok: true };
1912
2192
  }
1913
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
1914
- return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
2193
+ logger.warn("content.park.deposit.failed", {
2194
+ sessionId,
2195
+ contentHash: contentHashHex,
2196
+ reason: res.reason,
2197
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2198
+ });
2199
+ /**
2200
+ * DOD-M15-RELAYABUSE-1 — **GIVE THE RELAY'S "WHEN" A CONSUMER.**
2201
+ *
2202
+ * A deferred park is otherwise retried only on EVENTS — boot, agent start, the drain hook, a
2203
+ * signaling reconnect. None of those is coming for a throttle: the relay is healthy, the link
2204
+ * never dropped, and the condition clears on a timer nobody is watching. So the one refusal that
2205
+ * self-heals in about a minute was the one that waited longest, purely because the number the
2206
+ * relay had already computed had no reader.
2207
+ *
2208
+ * Scheduled ONCE per refusal, unref'd so it can never hold the process open, and best-effort:
2209
+ * the existing event triggers remain the guarantee, and this is a shortcut on top of them. It
2210
+ * deliberately does not retry-on-retry — a timer that reschedules itself on failure is a
2211
+ * self-inflicted flood, which is what the limiter exists to stop.
2212
+ */
2213
+ if (res.retryAfterMs !== undefined && res.retryAfterMs > 0) {
2214
+ /**
2215
+ * ⚠️ CLAMPED, because the number comes from ANOTHER PARTY'S SOFTWARE and an unclamped one is
2216
+ * not merely wrong — it inverts the fix. Node's `setTimeout` holds an int32: a relay reporting
2217
+ * `3_000_000_000` overflows it and the timer fires in about **one millisecond**, re-parking
2218
+ * instantly into the limit that just refused, and printing a `TimeoutOverflowWarning` on
2219
+ * stderr from a daemon whose convention forbids console output. A reported `1` does the same
2220
+ * thing 250 ms later.
2221
+ *
2222
+ * The floor is a second and the ceiling five minutes: long enough that a shortcut is a
2223
+ * shortcut, short enough that it stays one. A clamp is announced rather than silent — a relay
2224
+ * asking us to wait 35 days is a fact an operator wants.
2225
+ */
2226
+ scheduleParkRetry(res.retryAfterMs, agentName, "send");
2227
+ }
2228
+ return {
2229
+ ok: false,
2230
+ reason: res.reason ?? "relay_deposit_failed",
2231
+ // DOD-M15-RELAYABUSE-1 review MEDIUM-6: the guidance quotes the relay's OWN window instead of
2232
+ // guessing "about a minute", which is a hardcoded assumption about a configurable value.
2233
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2234
+ };
1915
2235
  });
1916
2236
  // CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
1917
2237
  // content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
@@ -1972,8 +2292,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
1972
2292
  // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1973
2293
  // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1974
2294
  // is salted is a fact about the message that was sent; what this side holds now says nothing
1975
- // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1976
- // carrying the value that cannot break anything, and only then does the value change.
2295
+ // about it.
2296
+ //
2297
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
2298
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
2299
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
2300
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
2301
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
2302
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
2303
+ // verdict on an honest message.
1977
2304
  const ciphertext = await sealParkEnvelope({
1978
2305
  signer: senderKp,
1979
2306
  sessionIdHex: entry.sessionId,
@@ -1997,7 +2324,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1997
2324
  logger.info("content.park.deposited", { sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
1998
2325
  return { parked: true };
1999
2326
  }
2000
- return { parked: false, error: res.reason ?? "deposit_failed" };
2327
+ return {
2328
+ parked: false,
2329
+ error: res.reason ?? "deposit_failed",
2330
+ // DOD-M15-RELAYABUSE-1 review HIGH-2: carry the relay's own "when" out of the DRAIN path too.
2331
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2332
+ };
2001
2333
  };
2002
2334
  // Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
2003
2335
  // (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
@@ -2055,7 +2387,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
2055
2387
  let parkedTotal = 0;
2056
2388
  for (const s of sessions) {
2057
2389
  try {
2058
- parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn);
2390
+ parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn, (retryAfterMs) => {
2391
+ /**
2392
+ * DOD-M15-RELAYABUSE-1 review HIGH-2 — **the drain path can now schedule its own
2393
+ * follow-up, which is what makes the retry work for a BACKLOG rather than one message.**
2394
+ *
2395
+ * Before this, only the live send path heard the relay's delay. With a backlog larger than
2396
+ * one rate-limit window, the drain deposited what the window allowed, every remaining item
2397
+ * was refused, and all of them fell back to waiting for an unrelated reconnect — the exact
2398
+ * condition the retry timer was added to remove.
2399
+ *
2400
+ * ONE timer per pass (the drain reports the largest delay it saw, not one per item), and
2401
+ * it deliberately does not chain beyond that: the next pass schedules the next one only if
2402
+ * it is refused again, so a permanently-full relay costs one timer per window rather than
2403
+ * an accelerating stream of them.
2404
+ */
2405
+ scheduleParkRetry(retryAfterMs, filterAgentName, "drain");
2406
+ });
2059
2407
  }
2060
2408
  catch (err) {
2061
2409
  logger.error("content.park.flush.failed", {
@@ -2163,6 +2511,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
2163
2511
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
2164
2512
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
2165
2513
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2514
+ /**
2515
+ * DOD-M15-RELAYSLOTS-1: WHY it is not reachable, and what to do about it.
2516
+ *
2517
+ * `standing_receiver_reachability` says `retrying` or `unreachable` and stops there, which
2518
+ * for the person reading it is indistinguishable from the product being broken. The relay
2519
+ * now refuses for reasons someone can act on — no token from a directory yet, too many
2520
+ * sessions still open, this relay is misconfigured — each with a different next step, and
2521
+ * every one of them is wasted if it only reaches a log file. Absent when the last attempt
2522
+ * succeeded.
2523
+ */
2524
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2525
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2526
+ : {}),
2166
2527
  // DOD-COATTEND-VISIBLE-1 AC2: how many sessions are driving this agent, including this
2167
2528
  // one. Live, not a high-water mark — it drops when a session disconnects. `selected` says
2168
2529
  // whether YOU hold it; this says whether anyone else does too.
@@ -2403,6 +2764,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
2403
2764
  state: agentStateFor(a),
2404
2765
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
2405
2766
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2767
+ // DOD-M15-RELAYSLOTS-1: the same cause-and-advice on the daemon-wide surface — see the note
2768
+ // on the MCP one above. Two surfaces, one reason to exist.
2769
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2770
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2771
+ : {}),
2406
2772
  })),
2407
2773
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
2408
2774
  retryQueueDepth: retryQueue.getTotalDepth(),
@@ -2434,14 +2800,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
2434
2800
  // Permissive by design (D12): an agent that exists goes online regardless of directory
2435
2801
  // registration state (online-without-registration is an established contract). Returns a
2436
2802
  // structured failure so callers can surface agent_start_failed with a real reason + guidance.
2803
+ /**
2804
+ * `standing_receiver` is part of the SUCCESS shape, not an optional extra — `DOD-M15-START-AGENT-
2805
+ * UNAWAITED-1`. A bare `{ ok: true }` claimed the agent was started and reachable when only the
2806
+ * first half was known, and the union makes the two states impossible to conflate at a call site.
2807
+ */
2437
2808
  function startAgentInternal(name) {
2438
2809
  const agent = agents.find((a) => a.name === name);
2439
2810
  if (!agent || agent.state === "load_failed") {
2440
2811
  return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_agents.` };
2441
2812
  }
2442
2813
  if (onlineAgents.has(name)) {
2443
- // Idempotent — already online, no event
2444
- return { ok: true };
2814
+ // Idempotent — already online, no event.
2815
+ //
2816
+ // It still reports REAL readiness rather than a bare ok. "Already online" says this daemon
2817
+ // marked the agent online at some earlier moment; it says nothing about whether the receiver
2818
+ // that ensure was firing ever came up. An operator who calls start twice — which is exactly
2819
+ // what someone does when the first one seemed not to work — would otherwise get the most
2820
+ // reassuring answer in the run on the attempt where something is actually wrong.
2821
+ const readyNow = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2822
+ if (readyNow)
2823
+ return { ok: true, standing_receiver: "ready" };
2824
+ const cause = sessionNodeManager.standingReceiverAbsenceReason(name);
2825
+ return {
2826
+ ok: true,
2827
+ standing_receiver: "starting",
2828
+ standing_receiver_cause: cause,
2829
+ guidance: `'${name}' was already online, and its standing receiver is not up (${cause}). Outbound ` +
2830
+ `sends and cello_initiate_session ensure it on demand. An inbound session arriving before ` +
2831
+ `it is ready is refused with 'standing_receiver_unavailable' — this daemon, not the ` +
2832
+ `counterparty. If it stays this way, stop the agent and start it again.`,
2833
+ };
2445
2834
  }
2446
2835
  onlineAgents.add(name);
2447
2836
  // Pressing start clears the deliberate-offline mark — that is what makes the switch reversible.
@@ -2467,7 +2856,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
2467
2856
  void sessionNodeManager.ensureStandingReceiverForAgent(name)
2468
2857
  .then(() => flushAwaitingContent(name))
2469
2858
  .catch((err) => {
2470
- logger.warn("session.standing_receiver.ensure.failed", { agentName: name, reason: extractErrorMessage(err) });
2859
+ logger.warn("session.standing_receiver.ensure.failed", {
2860
+ agentName: name,
2861
+ reason: extractErrorMessage(err),
2862
+ // `DOD-M15-START-AGENT-UNAWAITED-1`. The operator has ALREADY been told `ok: true` — this
2863
+ // handler answered before this promise settled — so nothing corrects that answer if this
2864
+ // is permanent. Say what it costs them here, because this line is the only account.
2865
+ impact: "cello_start_agent already answered ok for this agent, and its standing receiver did not " +
2866
+ "come up. The agent is online to the directory and CANNOT accept an inbound session: a " +
2867
+ "counterparty dialling it is refused standing_receiver_unavailable. Initiate and accept " +
2868
+ "each re-ensure on demand, so this may still recover on the next attempt; if it does not, " +
2869
+ "stop and restart the agent.",
2870
+ });
2471
2871
  })
2472
2872
  // DOD-MSG-4 (auto-recover-on-reconnect): RECEIVER drains its parked mailbox from every relay it
2473
2873
  // has sessions on (symmetric to the sender re-park). Its own stage so a failure is labelled
@@ -2477,10 +2877,51 @@ async function startDaemonHoldingLock(config, singletonLock) {
2477
2877
  .catch((err) => {
2478
2878
  logger.warn("content.recover.auto.failed", { agentName: name, stage: "agent_start", error: extractErrorMessage(err) });
2479
2879
  });
2480
- logger.info("agent.online", { agentName: name, agentPubkey: agent.pubkey ?? "" });
2880
+ /**
2881
+ * `DOD-M15-START-AGENT-UNAWAITED-1` — SAY WHETHER THE AGENT CAN ACTUALLY HEAR YET.
2882
+ *
2883
+ * The ensure above is fire-and-forget and that is deliberate: initiate and accept both ensure on
2884
+ * demand, and awaiting it here would turn a transient network failure into a failed start. **The
2885
+ * defect was never the timing — it was the CLAIM.** `{ ok: true }` with nothing else reads as
2886
+ * "your agent is running and reachable", and a session landing in the window before the receiver
2887
+ * exists is refused `standing_receiver_unavailable` — a precondition on OUR side, surfacing to
2888
+ * the operator as though the counterparty or the directory were at fault.
2889
+ *
2890
+ * ⚠️ **This field is only worth having because it can genuinely say `ready`.** Computed one line
2891
+ * after firing an async ensure, a naive readiness flag would be `starting` on every call — a
2892
+ * field that can never take its other value, which is the same defect as a log line reporting a
2893
+ * verdict its producer cannot have. It escapes that because `ensureStandingReceiverForAgent` is
2894
+ * IDEMPOTENT: an agent that already holds a receiver (a repeat start, or one whose receiver
2895
+ * survived) has one at this instant and reports `ready` truthfully.
2896
+ *
2897
+ * `cause` is read from the same four-way answer the refusal path uses, so the response and the
2898
+ * eventual error agree instead of describing the same state in two vocabularies.
2899
+ */
2900
+ const receiverReady = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2901
+ const startingCause = receiverReady ? undefined : sessionNodeManager.standingReceiverAbsenceReason(name);
2902
+ logger.info("agent.online", {
2903
+ agentName: name,
2904
+ agentPubkey: agent.pubkey ?? "",
2905
+ standingReceiver: receiverReady ? "ready" : "starting",
2906
+ ...(startingCause !== undefined ? { standingReceiverCause: startingCause } : {}),
2907
+ });
2481
2908
  // MCP-002: Broadcast agent_state_changed to ALL connections
2482
2909
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
2483
- return { ok: true };
2910
+ if (receiverReady)
2911
+ return { ok: true, standing_receiver: "ready" };
2912
+ return {
2913
+ ok: true,
2914
+ standing_receiver: "starting",
2915
+ standing_receiver_cause: startingCause,
2916
+ // Invariant: an agent-facing response carries an affordance. Naming the refusal text is the
2917
+ // load-bearing half — an operator who hits it in the next second can otherwise only conclude
2918
+ // the other side is broken.
2919
+ guidance: `'${name}' is online and its standing receiver is still being built. Outbound sends and ` +
2920
+ `cello_initiate_session ensure it on demand, so ordinary use is fine. A session arriving in ` +
2921
+ `the next moment can be refused with 'standing_receiver_unavailable' — that is this daemon ` +
2922
+ `not being ready yet, NOT the counterparty being unreachable. It clears on its own; ` +
2923
+ `cello_status reports the receiver once it is up.`,
2924
+ };
2484
2925
  }
2485
2926
  // M8C-AUTOSTART-1 (F18): resolve which agent an agent-defaulting tool should act on for this
2486
2927
  // connection: an explicit { agent } wins; else the connection's current agent; else — when EXACTLY one
@@ -2686,6 +3127,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
2686
3127
  getConsortiumEndpoints: resolveConsortiumRoster,
2687
3128
  signaling: entry.signaling,
2688
3129
  logger,
3130
+ // DOD-M15-SEALPARTIES-1: where a dead seal ceremony leaves its mark, so `cello_sealed_receipt`
3131
+ // can say it FAILED and why instead of falling through to "no receipt yet".
3132
+ recordSealFailure: (name, sid, reason) => sealFailures.record(name, sid, reason, new Date().toISOString(), "unresolved"),
2689
3133
  });
2690
3134
  if (!result.ok) {
2691
3135
  return { ok: false, reason: result.reason, guidance: "Share refresh did not complete — see the daemon log (refresh.ceremony.*) for the cause." };
@@ -2938,12 +3382,79 @@ async function startDaemonHoldingLock(config, singletonLock) {
2938
3382
  // collapsing into a generic send failure that points at the network.
2939
3383
  return { queued: false, reason: composed.reason, guidance: `${context} ${composed.guidance}` };
2940
3384
  }
3385
+ // Resolved ONCE, and carried. `agent_name` is a display label and is reusable after a
3386
+ // retire; every row and every queue entry below keys on this stable id instead.
3387
+ const agentId = sessionNodeManager.resolveAgentId(sel.name);
2941
3388
  const sent = await sendSealedSubmission({
2942
3389
  signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
2943
3390
  submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
2944
3391
  ciphertext: composed.ciphertext, logger,
2945
3392
  });
2946
3393
  if (!sent.ok) {
3394
+ // DOD-M15-ENDORSE-RETRY-1 — WORTH RETRYING, OR A VERDICT? The typed failure decides, never
3395
+ // a string match. `submission_refused_by_node` means a node decoded it, evaluated it and
3396
+ // said no, and it falls straight through to the plain failure below; everything else means
3397
+ // no node ever reached a decision, so the daemon keeps it and re-sends on the reconnect.
3398
+ /**
3399
+ * DO NOT HOLD A BLOB PAST ITS OWN INTAKE KEY (review M4).
3400
+ *
3401
+ * The sealed bytes are opened by the portal's intake key from THIS manifest. Holding them
3402
+ * across that key's expiry produces a submission the portal cannot open and cannot even
3403
+ * attribute — poison, with no reply possible — while the operator has been told it is held
3404
+ * and needs nothing from them. The plain failure is the better answer: they re-run it once
3405
+ * a current manifest is loaded, and they know to.
3406
+ *
3407
+ * The manifest is in hand here and nowhere inside the queue, which is why the check lives
3408
+ * at the call site rather than in the module that owns the window.
3409
+ */
3410
+ const manifestExpiresAt = verifiedManifest ? Date.parse(verifiedManifest.expires) : NaN;
3411
+ const keyOutlivesWindow = Number.isFinite(manifestExpiresAt) && manifestExpiresAt - Date.now() > DEFAULT_RETRY_WINDOW_MS;
3412
+ if (!keyOutlivesWindow) {
3413
+ logger.warn("signal.submission.retry.not_held", {
3414
+ agentName: sel.name,
3415
+ submissionId: composed.submissionId,
3416
+ reason: "intake_key_expires_within_retry_window",
3417
+ manifestExpires: verifiedManifest?.expires ?? null,
3418
+ impact: "the operator is told it failed rather than being told it is held",
3419
+ });
3420
+ return {
3421
+ queued: false,
3422
+ reason: sent.reason,
3423
+ guidance: `${context} it did not reach a directory node (${sent.reason}), and the daemon is NOT ` +
3424
+ "holding it to retry: the portal intake key it is sealed to expires too soon, and a " +
3425
+ "submission sent after that expires is one the portal cannot open or even attribute. " +
3426
+ "Load a current consortium manifest (cello_status shows its validity), then send it again.",
3427
+ };
3428
+ }
3429
+ if (isRetryableSendFailure(sent.reason)) {
3430
+ const held = submissionRetries.enqueue({
3431
+ agentName: sel.name,
3432
+ agentId,
3433
+ submissionId: composed.submissionId,
3434
+ intakeKeyId: composed.intakeKeyId,
3435
+ // THE SAME SEALED BYTES, carried rather than re-derived. A re-seal is randomised and a
3436
+ // re-compose would take a new `issued_at` — which changes the content-derived id, and
3437
+ // a changed id is a second endorsement rather than a retry.
3438
+ ciphertext: composed.ciphertext,
3439
+ op: opts.op,
3440
+ subject: opts.subject,
3441
+ }, sent.reason);
3442
+ if (held) {
3443
+ return {
3444
+ queued: false,
3445
+ retrying: true,
3446
+ submissionId: composed.submissionId,
3447
+ reason: sent.reason,
3448
+ guidance: `${context.replace(/:$/, "")} — not yet. It did not reach a directory node ` +
3449
+ `(${sent.reason}), so the daemon is holding it and will send it as soon as the ` +
3450
+ "directory signaling stream is back, on whichever node that is. You do NOT need to " +
3451
+ "run this again. Run cello_attestations_issued to see where it got to. It is held IN " +
3452
+ "MEMORY, so if the daemon restarts before it lands you will have to write it again.",
3453
+ };
3454
+ }
3455
+ // The queue is full, so nothing is holding it and saying otherwise would be a lie the
3456
+ // operator acts on. They get the plain failure and the fact that re-sending is safe.
3457
+ }
2947
3458
  return { queued: false, reason: sent.reason, guidance: `${context} ${sent.guidance ?? sent.reason}` };
2948
3459
  }
2949
3460
  // F4: `sendSealedSubmission` ALREADY logs `signal.submission.queued` / `.duplicate`. Logging
@@ -2954,32 +3465,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
2954
3465
  logger.info("signal.submission.attributed", {
2955
3466
  agentName: sel.name, op: opts.op, submissionId: composed.submissionId, stored: sent.stored,
2956
3467
  });
2957
- // KEEP THE HANDLE, or a withdrawal has nothing to name. The submission id is content-derived,
2958
- // so it is reproducible in principle but only by re-composing the exact original body, which
2959
- // the operator no longer has once they have sent it. Recorded in the SHARED path so every verb
2960
- // added after this one is covered by construction, which is the same reasoning as the
2961
- // `storedWarning` below.
2962
- //
2963
- // Best-effort on purpose: the submission IS accepted at this point, and failing the call over
2964
- // a local bookkeeping write would turn a success into a reported failure and invite a re-send
2965
- // of something already queued. Logged loudly instead.
2966
- try {
2967
- const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
2968
- store.recordIssuedSubmission({
2969
- agentId: sessionNodeManager.resolveAgentId(sel.name),
2970
- submissionId: composed.submissionId,
2971
- subjectPubkey: opts.subject,
2972
- op: opts.op,
2973
- intakeKeyId: composed.intakeKeyId,
2974
- stored: sent.stored,
2975
- });
2976
- }
2977
- catch (err) {
2978
- logger.error("signal.submission.record_failed", {
2979
- agentName: sel.name, submissionId: composed.submissionId,
2980
- reason: err instanceof Error ? err.message : String(err),
2981
- });
2982
- }
3468
+ // KEEP THE HANDLE, or a withdrawal has nothing to name. Recorded in the SHARED path so every
3469
+ // verb added after this one is covered by construction, which is the same reasoning as the
3470
+ // `storedWarning` below and by the same helper the RETRY path uses, so the two cannot drift
3471
+ // about what a landed submission records.
3472
+ // A LANDED SEND RETIRES AN EARLIER GIVE-UP for the same submission. The id is content-derived,
3473
+ // so re-issuing the same words about the same subject produces the same id — this is the "I
3474
+ // wrote it again and it worked" case, and leaving the stale failure on the surface would show
3475
+ // the operator two contradictory states for one submission, forever.
3476
+ submissionRetries.clearGaveUp(agentId, composed.submissionId);
3477
+ recordIssuedSubmission(sel.name, agentId, {
3478
+ submissionId: composed.submissionId,
3479
+ subject: opts.subject,
3480
+ op: opts.op,
3481
+ intakeKeyId: composed.intakeKeyId,
3482
+ stored: sent.stored,
3483
+ });
2983
3484
  // F1: `stored: false` means a node reports it ALREADY HELD this submission id. That is either
2984
3485
  // a benign retry or single-node censorship — an operator pre-inserting garbage under a
2985
3486
  // clear-text id — and they are indistinguishable from here. Reporting it as unqualified
@@ -3136,20 +3637,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
3136
3637
  const sel = resolveSelectedAgent(connectionId);
3137
3638
  if (!sel.ok)
3138
3639
  return sel;
3640
+ const agentId = sessionNodeManager.resolveAgentId(sel.name);
3139
3641
  const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
3140
- const rows = store.listIssuedSubmissions(sessionNodeManager.resolveAgentId(sel.name)).map((r) => ({
3642
+ const rows = store.listIssuedSubmissions(agentId).map((r) => ({
3141
3643
  submission_id: r.submissionId,
3142
3644
  subject_pubkey: r.subjectPubkey,
3143
3645
  op: r.op,
3144
3646
  intake_key_id: r.intakeKeyId,
3647
+ // Every row in this table reached a node. The in-flight ones below have not.
3648
+ delivery: "accepted",
3145
3649
  // FALSE means a node already held this id — a benign retry, or single-node censorship. The
3146
3650
  // operator sees the distinction here rather than only in the moment they submitted.
3147
3651
  stored: r.stored,
3148
3652
  submitted_at: r.submittedAt,
3149
3653
  }));
3654
+ /**
3655
+ * DOD-M15-ENDORSE-RETRY-1 — THE SUBMISSIONS THAT REACHED NO NODE, listed here because this is
3656
+ * the verb whose whole question is "what happened to what I sent?".
3657
+ *
3658
+ * Without them a submission the daemon is retrying is INVISIBLE — the durable table only gets a
3659
+ * row once a node accepted one — so the honest answer to that question was silence, which reads
3660
+ * as "you sent nothing". And a give-up whose only consumer is a warn line in `daemon.log` is
3661
+ * indistinguishable from the submission never having existed.
3662
+ *
3663
+ * These are IN MEMORY and do not survive a daemon restart (see submission-retry.ts). That is
3664
+ * why they are a separate array rather than blended into `issued`: a caller must be able to
3665
+ * tell a durable fact from a live one.
3666
+ */
3667
+ const inFlight = submissionRetries.list(agentId).map((p) => ({
3668
+ submission_id: p.submissionId,
3669
+ subject_pubkey: p.subject,
3670
+ op: p.op,
3671
+ intake_key_id: p.intakeKeyId,
3672
+ delivery: p.delivery.state,
3673
+ attempts: p.delivery.attempts,
3674
+ last_reason: p.delivery.lastReason,
3675
+ ...(p.delivery.state === "gave_up" ? { gave_up_because: p.delivery.gaveUpBecause } : {}),
3676
+ guidance: p.delivery.guidance,
3677
+ }));
3150
3678
  return {
3151
3679
  ok: true,
3152
3680
  issued: rows,
3681
+ in_flight: inFlight,
3153
3682
  // NO BODY, and say so rather than letting its absence read as a bug. The text was the
3154
3683
  // operator's own words about a third party; keeping it on disk in the clear is exactly what
3155
3684
  // the sealed-submission path exists to prevent.
@@ -3192,10 +3721,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3192
3721
  op: "submit", subjectKind: "agent", subject, body,
3193
3722
  context: "The signal was NOT submitted:",
3194
3723
  });
3724
+ // DOD-M15-ENDORSE-RETRY-1 — `ok: true` with `delivery: "retrying"`, and both halves are
3725
+ // deliberate. `ok: false` would send the agent to re-run a command the daemon is already
3726
+ // handling, which is the exact operator work this unit exists to remove; `queued: true` would
3727
+ // claim a directory node accepted something no node has seen. So: not a failure, not an
3728
+ // acceptance, and named.
3729
+ if (!res.queued && res.retrying) {
3730
+ return {
3731
+ ok: true, queued: false, delivery: "retrying",
3732
+ submission_id: res.submissionId, reason: res.reason, guidance: res.guidance,
3733
+ };
3734
+ }
3195
3735
  if (!res.queued)
3196
3736
  return { ok: false, reason: res.reason, guidance: res.guidance };
3197
3737
  return {
3198
- ok: true, queued: true, stored: res.stored, submission_id: res.submissionId,
3738
+ ok: true, queued: true, delivery: "accepted", stored: res.stored, submission_id: res.submissionId,
3199
3739
  // Deliberately NOT "issued". Nothing is minted yet: the portal must still drain, authenticate,
3200
3740
  // scan and mint, and the subject must then ACCEPT it before anyone else can see it. Reporting
3201
3741
  // this as a completed endorsement would promise three steps that have not happened.
@@ -3403,6 +3943,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
3403
3943
  op: "refuse", subjectKind: item.subjectKind, subject: item.signalHash, body: message,
3404
3944
  context: "The refusal is recorded. Your message was NOT sent:",
3405
3945
  });
3946
+ // DOD-M15-ENDORSE-RETRY-1: the refusal itself is already recorded and unaffected either way —
3947
+ // what is in question is only the MESSAGE back to the issuer. `message_delivery: "retrying"` is
3948
+ // not `message_queued`, because no node has it yet, and it is not an error, because nothing is
3949
+ // asked of the operator.
3950
+ if (!res.queued && res.retrying) {
3951
+ return {
3952
+ ...refused, message_queued: false, message_delivery: "retrying",
3953
+ submission_id: res.submissionId, guidance: `The refusal is recorded. ${res.guidance}`,
3954
+ };
3955
+ }
3406
3956
  if (!res.queued) {
3407
3957
  return { ...refused, message_queued: false, message_error: res.reason, guidance: res.guidance };
3408
3958
  }
@@ -3414,7 +3964,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
3414
3964
  // under a clear-text submission_id), and folding them together destroys the only information
3415
3965
  // that could ever tell them apart.
3416
3966
  return {
3417
- ...refused, message_queued: true, stored: res.stored, submission_id: res.submissionId,
3967
+ ...refused, message_queued: true, message_delivery: "accepted",
3968
+ stored: res.stored, submission_id: res.submissionId,
3418
3969
  ...(res.storedWarning ? { guidance: `The refusal is recorded. ${res.storedWarning}` } : {}),
3419
3970
  };
3420
3971
  });
@@ -3506,6 +4057,17 @@ async function startDaemonHoldingLock(config, singletonLock) {
3506
4057
  body: "",
3507
4058
  context: "The revocation was NOT queued:",
3508
4059
  });
4060
+ // DOD-M15-ENDORSE-RETRY-1: the local copy survives either way (see below), so a retrying
4061
+ // revocation is a wait, not a loss — and the operator is told which it is rather than being
4062
+ // sent to re-run a retraction the daemon is already carrying.
4063
+ if (!submitted.queued && submitted.retrying) {
4064
+ return {
4065
+ ok: true, signal_hash: signalHash, submission_id: submitted.submissionId,
4066
+ revoked: false, queued: false, delivery: "retrying",
4067
+ guidance: `Revocation for '${row.type}' is HELD, not yet at a directory. ${submitted.guidance} ` +
4068
+ "Your local copy is KEPT either way, deliberately, so nothing is lost while it waits.",
4069
+ };
4070
+ }
3509
4071
  if (!submitted.queued) {
3510
4072
  return { ok: false, reason: submitted.reason, guidance: submitted.guidance };
3511
4073
  }
@@ -3519,6 +4081,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
3519
4081
  submission_id: submitted.submissionId,
3520
4082
  revoked: false,
3521
4083
  queued: true,
4084
+ delivery: "accepted",
3522
4085
  // M5: CARRIED, not dropped. `submitForAgent`'s own comment says the warning lives in the
3523
4086
  // shared path "because the same omission would otherwise be available to every verb added
3524
4087
  // after this one" — and this was the next verb added. `stored:false` means a node already held
@@ -3830,13 +4393,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
3830
4393
  "replaces, it does not merge.",
3831
4394
  };
3832
4395
  });
3833
- // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
3834
- // These return not_implemented (same as session tools) so LLMs get consistent guidance.
3835
- for (const tool of ["cello_get_inclusion_proof"]) {
3836
- handlers.set(tool, async (_params, _connectionId) => {
3837
- return { ok: false, reason: "not_implemented", guidance: `'${tool}' is not yet implemented in the daemon. This feature will be available in a future milestone.` };
3838
- });
3839
- }
4396
+ // DOD-M15-INCLUSION-1: prove one message sits under the certified root, and check such a proof.
4397
+ //
4398
+ // This replaces the last entry of the MCP-001 `not_implemented` stub loop, which by the end held
4399
+ // exactly one tool `cello_get_inclusion_proof` so the loop goes with it rather than being left
4400
+ // as an empty scaffold that reads like other tools are still pending.
4401
+ registerInclusionProofHandlers({
4402
+ handlers,
4403
+ logger,
4404
+ sessionNodeManager,
4405
+ getConnState: (connectionId) => perConnectionState.get(connectionId),
4406
+ resolveCurrentAgent,
4407
+ NO_CURRENT_AGENT_RESPONSE,
4408
+ });
3840
4409
  // DOD-M9B-SURFACE-1: the security layer's control surface. Registered here, defined in its own
3841
4410
  // module — it needs the cello dir, a logger, and the connection's client type, and nothing else
3842
4411
  // about sessions or ceremonies.
@@ -4535,7 +5104,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
4535
5104
  // (deliberate — f75ea09), so it obeys the same discipline a message does — including
4536
5105
  // being HELD when the position is ahead of the tail, which is why the real frame bytes
4537
5106
  // have to travel with it.
4538
- const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc");
5107
+ const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc",
5108
+ /**
5109
+ * No proof — and the reason I first wrote here was FALSE, which review traced rather
5110
+ * than read.
5111
+ *
5112
+ * ⚠️ IT SAID *"the document transport does not go through `sendContent`, so no Structure-1
5113
+ * was signed."* Both halves are wrong. `document-delivery-transport.ts` calls
5114
+ * `deps.sendContent(...)`, wired straight to `sessionNodeManager.sendContent` a few lines
5115
+ * above this; and `session-relay-client.ts` signs the Structure-1 with no `leafKind` gate
5116
+ * at all, so a `0x04` doc leaf is signed exactly like a message. **A proof exists and is
5117
+ * discarded here.**
5118
+ *
5119
+ * That matters more than a wrong comment usually would, because this unit's whole thesis
5120
+ * is *"`undefined` is a claim the author made rather than one the signature made for
5121
+ * them"* — and the first claim made under the new signature was untrue.
5122
+ *
5123
+ * THE TRUE REASON, which is a better one: **no consumer.** A doc leaf released from a
5124
+ * hold writes no transcript row — `#releaseHeld` skips `recordTranscriptMessage` for
5125
+ * `kind === "doc"` — so there is nothing for the proof to reach. Discarding it is
5126
+ * no-consumer-no-ship, deliberately.
5127
+ *
5128
+ * **If doc rows ever reach the transcript, `appendLeaf` needs an authorship parameter**
5129
+ * and this `undefined` becomes a defect rather than a decision.
5130
+ */
5131
+ undefined);
4539
5132
  return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
4540
5133
  },
4541
5134
  });
@@ -4781,6 +5374,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4781
5374
  async function stop(reason) {
4782
5375
  clearInterval(reconcileSweepTimer);
4783
5376
  clearInterval(revivalBoundSweepTimer);
5377
+ // DOD-M15-RELAYABUSE-1: scheduled park retries. Unref'd, so they never held the process open —
5378
+ // cleared so an in-process restart cannot leave one draining into a torn-down manager.
5379
+ for (const t of parkRetryTimers)
5380
+ clearTimeout(t);
5381
+ parkRetryTimers.clear();
4784
5382
  // DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
4785
5383
  // walks every agent, and each step dials a peer and opens a session — which is why a daemon
4786
5384
  // reported down, with its socket already removed, was still logging `document.reconcile.sweep`
@@ -4821,6 +5419,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
4821
5419
  // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
4822
5420
  // this is "stop making new work", not "tear down transports".
4823
5421
  await restartSealResolver?.stop();
5422
+ // DOD-M15-ENDORSE-RETRY-1: same rule, same place — stop making new outbound work. Nothing is
5423
+ // awaited: a submission send is one frame with an ack, not a ceremony, so cutting it leaves no
5424
+ // counterparty holding a half-finished exchange. What IS lost is the pending queue itself,
5425
+ // which is in memory by design (see submission-retry.ts) — re-sending is safe by the same
5426
+ // content-derived id.
5427
+ submissionRetries.stop();
4824
5428
  /**
4825
5429
  * DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
4826
5430
  * `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with