@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.
- 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/daemon.d.ts.map +1 -1
- package/dist/daemon.js +365 -44
- package/dist/daemon.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/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 +31 -7
- package/dist/outbound-sessions.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 +21 -1
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +83 -7
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-node-manager.d.ts +141 -1
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +480 -14
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-relay-client.d.ts +47 -1
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +145 -0
- package/dist/session-relay-client.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/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 +12 -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/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.
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
4088
|
-
//
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
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
|