@cello-protocol/daemon 0.0.183 → 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 (69) hide show
  1. package/dist/close-session-handler.d.ts.map +1 -1
  2. package/dist/close-session-handler.js +90 -16
  3. package/dist/close-session-handler.js.map +1 -1
  4. package/dist/daemon.d.ts.map +1 -1
  5. package/dist/daemon.js +365 -44
  6. package/dist/daemon.js.map +1 -1
  7. package/dist/frame-values.d.ts.map +1 -1
  8. package/dist/frame-values.js +14 -0
  9. package/dist/frame-values.js.map +1 -1
  10. package/dist/inclusion-proof-handlers.d.ts +43 -0
  11. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  12. package/dist/inclusion-proof-handlers.js +565 -0
  13. package/dist/inclusion-proof-handlers.js.map +1 -0
  14. package/dist/inclusion-proof.d.ts +151 -0
  15. package/dist/inclusion-proof.d.ts.map +1 -0
  16. package/dist/inclusion-proof.js +228 -0
  17. package/dist/inclusion-proof.js.map +1 -0
  18. package/dist/network-directory-node.d.ts +16 -0
  19. package/dist/network-directory-node.d.ts.map +1 -1
  20. package/dist/network-directory-node.js +40 -0
  21. package/dist/network-directory-node.js.map +1 -1
  22. package/dist/notification-handlers.d.ts.map +1 -1
  23. package/dist/notification-handlers.js +71 -2
  24. package/dist/notification-handlers.js.map +1 -1
  25. package/dist/outbound-sessions.d.ts +6 -0
  26. package/dist/outbound-sessions.d.ts.map +1 -1
  27. package/dist/outbound-sessions.js +31 -7
  28. package/dist/outbound-sessions.js.map +1 -1
  29. package/dist/seal-coordinator.d.ts +51 -1
  30. package/dist/seal-coordinator.d.ts.map +1 -1
  31. package/dist/seal-coordinator.js +248 -1
  32. package/dist/seal-coordinator.js.map +1 -1
  33. package/dist/seal-escalation.d.ts.map +1 -1
  34. package/dist/seal-escalation.js +28 -0
  35. package/dist/seal-escalation.js.map +1 -1
  36. package/dist/seal-failure-store.d.ts.map +1 -1
  37. package/dist/seal-failure-store.js +5 -1
  38. package/dist/seal-failure-store.js.map +1 -1
  39. package/dist/sealed-leaf-set.d.ts +92 -0
  40. package/dist/sealed-leaf-set.d.ts.map +1 -0
  41. package/dist/sealed-leaf-set.js +129 -0
  42. package/dist/sealed-leaf-set.js.map +1 -0
  43. package/dist/session-ceremony.d.ts +21 -1
  44. package/dist/session-ceremony.d.ts.map +1 -1
  45. package/dist/session-ceremony.js +83 -7
  46. package/dist/session-ceremony.js.map +1 -1
  47. package/dist/session-node-manager.d.ts +141 -1
  48. package/dist/session-node-manager.d.ts.map +1 -1
  49. package/dist/session-node-manager.js +480 -14
  50. package/dist/session-node-manager.js.map +1 -1
  51. package/dist/session-relay-client.d.ts +47 -1
  52. package/dist/session-relay-client.d.ts.map +1 -1
  53. package/dist/session-relay-client.js +145 -0
  54. package/dist/session-relay-client.js.map +1 -1
  55. package/dist/signal-submission.d.ts +11 -1
  56. package/dist/signal-submission.d.ts.map +1 -1
  57. package/dist/signal-submission.js.map +1 -1
  58. package/dist/submission-retry.d.ts +208 -0
  59. package/dist/submission-retry.d.ts.map +1 -0
  60. package/dist/submission-retry.js +505 -0
  61. package/dist/submission-retry.js.map +1 -0
  62. package/dist/types.d.ts +12 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js.map +1 -1
  65. package/dist/vocabulary.d.ts +13 -3
  66. package/dist/vocabulary.d.ts.map +1 -1
  67. package/dist/vocabulary.js +14 -3
  68. package/dist/vocabulary.js.map +1 -1
  69. 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
@@ -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";
@@ -646,6 +648,127 @@ async function startDaemonHoldingLock(config, singletonLock) {
646
648
  // ensure→drain contract — a re-park needs the receiver that the ensure step rebuilds.
647
649
  flushSender: (agentName) => flushAwaitingContent(agentName),
648
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
+ }
649
772
  // Created HERE, not where the seal code used to sit (~2,500 lines down), because the listeners
650
773
  // are wired into every signaling manager below — and the originals were FUNCTION DECLARATIONS,
651
774
  // so hoisting silently let them be CALLED 1,900 lines before they were DEFINED. A const in their
@@ -655,12 +778,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
655
778
  // Bilateral seal, unilateral escalation, and the returning-absent-party upgrade: five pieces of
656
779
  // state and the listeners that drive them. Already seal-private; now that is enforced by a module
657
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();
658
790
  const { sealKey, sealInterruptedInProgress, pendingSealWaiters, pendingUnilateralWaiters, registerSealListeners, } = createSealCoordinator({
659
791
  logger,
660
792
  sessionNodeManager,
661
793
  getPersistence,
662
794
  getKeyProvider: (agentName) => keyProviders.get(agentName),
663
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"),
664
800
  });
665
801
  // The two seal-initiation flows cello_close_session dispatches into (seal-flows.ts): the
666
802
  // counterparty is gone (seal-interrupted) or live (bilateral). Neither can notarize on its own —
@@ -845,8 +981,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
845
981
  // from every relay it has session history with, so a message parked while signaling was down
846
982
  // is not left until the next agent start. The drain needs the node the ensure builds, which
847
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.
848
989
  onConnected: () => {
849
990
  onSignalingConnected(agentName);
991
+ submissionRetries.onSignalingConnected(agentName);
850
992
  },
851
993
  });
852
994
  const entry = { signaling: mgr, getNode: () => nodeRef };
@@ -869,6 +1011,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
869
1011
  getConsortiumEndpoints: resolveConsortiumRoster,
870
1012
  signaling: mgr,
871
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"),
872
1017
  });
873
1018
  // DOD-SPINE-7: coordinate the SEAL FROST ceremony on this agent's stream too.
874
1019
  wireSealCeremonyHandler({
@@ -885,6 +1030,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
885
1030
  getConsortiumEndpoints: resolveConsortiumRoster,
886
1031
  signaling: mgr,
887
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"),
888
1036
  });
889
1037
  // DOD-SPINE-7: and resolve session_sealed for this agent's sessions on its own stream.
890
1038
  registerSealListeners(mgr, agentName, agentPubkeyHex);
@@ -909,6 +1057,40 @@ async function startDaemonHoldingLock(config, singletonLock) {
909
1057
  return;
910
1058
  void handleTrustSignalPickup(frame, agentKeyProvider, mgr, agentName);
911
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
+ });
912
1094
  // CELLO-M7-CONN-001 (DOD-CONN-2): inbound session_assignment + seal_interrupted_request
913
1095
  // on THIS agent's own stream, so a non-primary agent receives inbound sessions (SPINE-5).
914
1096
  wirePerAgentSessionInbound(mgr);
@@ -953,6 +1135,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
953
1135
  getConsortiumEndpoints: resolveConsortiumRoster,
954
1136
  signaling: mgr,
955
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"),
956
1141
  });
957
1142
  wireSealCeremonyHandler({
958
1143
  agentName: agent.name,
@@ -966,6 +1151,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
966
1151
  getConsortiumEndpoints: resolveConsortiumRoster,
967
1152
  signaling: mgr,
968
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"),
969
1157
  });
970
1158
  wireSessionOfferHandler({
971
1159
  agentName: agent.name,
@@ -1196,14 +1384,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
1196
1384
  * every other one.
1197
1385
  */
1198
1386
  const backgroundSeals = new Set();
1199
- /**
1200
- * DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
1201
- *
1202
- * In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
1203
- * restart seal resolver retry the session. A marker whose lifetime is the process matches the
1204
- * lifetime of the condition it describes.
1205
- */
1206
- const sealFailures = new SealFailureStore();
1207
1387
  // DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
1208
1388
  // the call site so it names the specific away agent.
1209
1389
  // DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
@@ -1748,6 +1928,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1748
1928
  // was looking.
1749
1929
  getUnresolvedNodes,
1750
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"),
1751
1934
  });
1752
1935
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
1753
1936
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
@@ -1790,6 +1973,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
1790
1973
  // DOD-M15-RELAYSLOTS-1: the manager owns the current token and hands the accessor down, so
1791
1974
  // this client reads a fresh one at every auth instead of a snapshot taken here at build time.
1792
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); },
1793
1980
  });
1794
1981
  });
1795
1982
  // DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
@@ -2940,6 +3127,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
2940
3127
  getConsortiumEndpoints: resolveConsortiumRoster,
2941
3128
  signaling: entry.signaling,
2942
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"),
2943
3133
  });
2944
3134
  if (!result.ok) {
2945
3135
  return { ok: false, reason: result.reason, guidance: "Share refresh did not complete — see the daemon log (refresh.ceremony.*) for the cause." };
@@ -3192,12 +3382,79 @@ async function startDaemonHoldingLock(config, singletonLock) {
3192
3382
  // collapsing into a generic send failure that points at the network.
3193
3383
  return { queued: false, reason: composed.reason, guidance: `${context} ${composed.guidance}` };
3194
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);
3195
3388
  const sent = await sendSealedSubmission({
3196
3389
  signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
3197
3390
  submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
3198
3391
  ciphertext: composed.ciphertext, logger,
3199
3392
  });
3200
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
+ }
3201
3458
  return { queued: false, reason: sent.reason, guidance: `${context} ${sent.guidance ?? sent.reason}` };
3202
3459
  }
3203
3460
  // F4: `sendSealedSubmission` ALREADY logs `signal.submission.queued` / `.duplicate`. Logging
@@ -3208,32 +3465,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
3208
3465
  logger.info("signal.submission.attributed", {
3209
3466
  agentName: sel.name, op: opts.op, submissionId: composed.submissionId, stored: sent.stored,
3210
3467
  });
3211
- // KEEP THE HANDLE, or a withdrawal has nothing to name. The submission id is content-derived,
3212
- // so it is reproducible in principle but only by re-composing the exact original body, which
3213
- // the operator no longer has once they have sent it. Recorded in the SHARED path so every verb
3214
- // added after this one is covered by construction, which is the same reasoning as the
3215
- // `storedWarning` below.
3216
- //
3217
- // Best-effort on purpose: the submission IS accepted at this point, and failing the call over
3218
- // a local bookkeeping write would turn a success into a reported failure and invite a re-send
3219
- // of something already queued. Logged loudly instead.
3220
- try {
3221
- const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
3222
- store.recordIssuedSubmission({
3223
- agentId: sessionNodeManager.resolveAgentId(sel.name),
3224
- submissionId: composed.submissionId,
3225
- subjectPubkey: opts.subject,
3226
- op: opts.op,
3227
- intakeKeyId: composed.intakeKeyId,
3228
- stored: sent.stored,
3229
- });
3230
- }
3231
- catch (err) {
3232
- logger.error("signal.submission.record_failed", {
3233
- agentName: sel.name, submissionId: composed.submissionId,
3234
- reason: err instanceof Error ? err.message : String(err),
3235
- });
3236
- }
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
+ });
3237
3484
  // F1: `stored: false` means a node reports it ALREADY HELD this submission id. That is either
3238
3485
  // a benign retry or single-node censorship — an operator pre-inserting garbage under a
3239
3486
  // clear-text id — and they are indistinguishable from here. Reporting it as unqualified
@@ -3390,20 +3637,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
3390
3637
  const sel = resolveSelectedAgent(connectionId);
3391
3638
  if (!sel.ok)
3392
3639
  return sel;
3640
+ const agentId = sessionNodeManager.resolveAgentId(sel.name);
3393
3641
  const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
3394
- const rows = store.listIssuedSubmissions(sessionNodeManager.resolveAgentId(sel.name)).map((r) => ({
3642
+ const rows = store.listIssuedSubmissions(agentId).map((r) => ({
3395
3643
  submission_id: r.submissionId,
3396
3644
  subject_pubkey: r.subjectPubkey,
3397
3645
  op: r.op,
3398
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",
3399
3649
  // FALSE means a node already held this id — a benign retry, or single-node censorship. The
3400
3650
  // operator sees the distinction here rather than only in the moment they submitted.
3401
3651
  stored: r.stored,
3402
3652
  submitted_at: r.submittedAt,
3403
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
+ }));
3404
3678
  return {
3405
3679
  ok: true,
3406
3680
  issued: rows,
3681
+ in_flight: inFlight,
3407
3682
  // NO BODY, and say so rather than letting its absence read as a bug. The text was the
3408
3683
  // operator's own words about a third party; keeping it on disk in the clear is exactly what
3409
3684
  // the sealed-submission path exists to prevent.
@@ -3446,10 +3721,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3446
3721
  op: "submit", subjectKind: "agent", subject, body,
3447
3722
  context: "The signal was NOT submitted:",
3448
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
+ }
3449
3735
  if (!res.queued)
3450
3736
  return { ok: false, reason: res.reason, guidance: res.guidance };
3451
3737
  return {
3452
- 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,
3453
3739
  // Deliberately NOT "issued". Nothing is minted yet: the portal must still drain, authenticate,
3454
3740
  // scan and mint, and the subject must then ACCEPT it before anyone else can see it. Reporting
3455
3741
  // this as a completed endorsement would promise three steps that have not happened.
@@ -3657,6 +3943,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
3657
3943
  op: "refuse", subjectKind: item.subjectKind, subject: item.signalHash, body: message,
3658
3944
  context: "The refusal is recorded. Your message was NOT sent:",
3659
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
+ }
3660
3956
  if (!res.queued) {
3661
3957
  return { ...refused, message_queued: false, message_error: res.reason, guidance: res.guidance };
3662
3958
  }
@@ -3668,7 +3964,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
3668
3964
  // under a clear-text submission_id), and folding them together destroys the only information
3669
3965
  // that could ever tell them apart.
3670
3966
  return {
3671
- ...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,
3672
3969
  ...(res.storedWarning ? { guidance: `The refusal is recorded. ${res.storedWarning}` } : {}),
3673
3970
  };
3674
3971
  });
@@ -3760,6 +4057,17 @@ async function startDaemonHoldingLock(config, singletonLock) {
3760
4057
  body: "",
3761
4058
  context: "The revocation was NOT queued:",
3762
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
+ }
3763
4071
  if (!submitted.queued) {
3764
4072
  return { ok: false, reason: submitted.reason, guidance: submitted.guidance };
3765
4073
  }
@@ -3773,6 +4081,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
3773
4081
  submission_id: submitted.submissionId,
3774
4082
  revoked: false,
3775
4083
  queued: true,
4084
+ delivery: "accepted",
3776
4085
  // M5: CARRIED, not dropped. `submitForAgent`'s own comment says the warning lives in the
3777
4086
  // shared path "because the same omission would otherwise be available to every verb added
3778
4087
  // after this one" — and this was the next verb added. `stored:false` means a node already held
@@ -4084,13 +4393,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
4084
4393
  "replaces, it does not merge.",
4085
4394
  };
4086
4395
  });
4087
- // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
4088
- // These return not_implemented (same as session tools) so LLMs get consistent guidance.
4089
- for (const tool of ["cello_get_inclusion_proof"]) {
4090
- handlers.set(tool, async (_params, _connectionId) => {
4091
- return { ok: false, reason: "not_implemented", guidance: `'${tool}' is not yet implemented in the daemon. This feature will be available in a future milestone.` };
4092
- });
4093
- }
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
+ });
4094
4409
  // DOD-M9B-SURFACE-1: the security layer's control surface. Registered here, defined in its own
4095
4410
  // module — it needs the cello dir, a logger, and the connection's client type, and nothing else
4096
4411
  // about sessions or ceremonies.
@@ -5104,6 +5419,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
5104
5419
  // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
5105
5420
  // this is "stop making new work", not "tear down transports".
5106
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();
5107
5428
  /**
5108
5429
  * DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
5109
5430
  * `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with