@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.
- package/dist/agent-settings-keys.d.ts.map +1 -1
- package/dist/agent-settings-keys.js +18 -0
- package/dist/agent-settings-keys.js.map +1 -1
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +90 -16
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +117 -2
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-encryption-status.d.ts +97 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +122 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +10 -0
- package/dist/content-park-client.d.ts.map +1 -1
- package/dist/content-park-client.js +84 -3
- package/dist/content-park-client.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +184 -13
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +671 -67
- package/dist/daemon.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +119 -0
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-layer.d.ts +0 -2
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/frame-values.d.ts.map +1 -1
- package/dist/frame-values.js +14 -0
- package/dist/frame-values.js.map +1 -1
- package/dist/inclusion-proof-handlers.d.ts +43 -0
- package/dist/inclusion-proof-handlers.d.ts.map +1 -0
- package/dist/inclusion-proof-handlers.js +565 -0
- package/dist/inclusion-proof-handlers.js.map +1 -0
- package/dist/inclusion-proof.d.ts +151 -0
- package/dist/inclusion-proof.d.ts.map +1 -0
- package/dist/inclusion-proof.js +228 -0
- package/dist/inclusion-proof.js.map +1 -0
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +34 -3
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/manifest-deps.d.ts +0 -11
- package/dist/manifest-deps.d.ts.map +1 -1
- package/dist/manifest-deps.js +28 -2
- package/dist/manifest-deps.js.map +1 -1
- package/dist/manifest-validity.d.ts +2 -0
- package/dist/manifest-validity.d.ts.map +1 -1
- package/dist/manifest-validity.js +1 -1
- package/dist/manifest-validity.js.map +1 -1
- package/dist/network-directory-node.d.ts +16 -0
- package/dist/network-directory-node.d.ts.map +1 -1
- package/dist/network-directory-node.js +40 -0
- package/dist/network-directory-node.js.map +1 -1
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +71 -2
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +6 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +50 -7
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +23 -0
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +76 -0
- package/dist/park-envelope.js.map +1 -1
- package/dist/register-handler.d.ts.map +1 -1
- package/dist/register-handler.js +76 -0
- package/dist/register-handler.js.map +1 -1
- package/dist/relay-only.d.ts +133 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +183 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/retry-queue.d.ts +26 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +18 -1
- package/dist/retry-queue.js.map +1 -1
- package/dist/seal-coordinator.d.ts +51 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +248 -1
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-escalation.d.ts.map +1 -1
- package/dist/seal-escalation.js +28 -0
- package/dist/seal-escalation.js.map +1 -1
- package/dist/seal-failure-store.d.ts.map +1 -1
- package/dist/seal-failure-store.js +5 -1
- package/dist/seal-failure-store.js.map +1 -1
- package/dist/sealed-leaf-set.d.ts +92 -0
- package/dist/sealed-leaf-set.d.ts.map +1 -0
- package/dist/sealed-leaf-set.js +129 -0
- package/dist/sealed-leaf-set.js.map +1 -0
- package/dist/session-ceremony.d.ts +27 -1
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +109 -7
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-content-handlers.d.ts +12 -0
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +39 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +387 -4
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3529 -279
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +16 -1
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +163 -1
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +540 -6
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +58 -4
- package/dist/session-salt-agreement.d.ts.map +1 -1
- package/dist/session-salt-agreement.js +110 -3
- package/dist/session-salt-agreement.js.map +1 -1
- package/dist/signal-submission.d.ts +11 -1
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js.map +1 -1
- package/dist/signaling-connect.d.ts +18 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +164 -1
- package/dist/signaling-connect.js.map +1 -1
- package/dist/submission-retry.d.ts +208 -0
- package/dist/submission-retry.d.ts.map +1 -0
- package/dist/submission-retry.js +505 -0
- package/dist/submission-retry.js.map +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts +13 -3
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +14 -3
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +15 -3
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +15 -3
- package/dist/wire-content-hash.js.map +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1889
|
-
//
|
|
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", {
|
|
1914
|
-
|
|
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.
|
|
1976
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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", {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2958
|
-
//
|
|
2959
|
-
//
|
|
2960
|
-
//
|
|
2961
|
-
//
|
|
2962
|
-
//
|
|
2963
|
-
//
|
|
2964
|
-
//
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
3834
|
-
//
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
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
|