@cello-protocol/daemon 0.0.197 → 0.0.199

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/agent-admin-handlers.d.ts +56 -0
  2. package/dist/agent-admin-handlers.d.ts.map +1 -0
  3. package/dist/agent-admin-handlers.js +70 -0
  4. package/dist/agent-admin-handlers.js.map +1 -0
  5. package/dist/agent-handlers.d.ts +8 -7
  6. package/dist/agent-handlers.d.ts.map +1 -1
  7. package/dist/agent-handlers.js +10 -1
  8. package/dist/agent-handlers.js.map +1 -1
  9. package/dist/agent-selection-root.d.ts +29 -0
  10. package/dist/agent-selection-root.d.ts.map +1 -0
  11. package/dist/agent-selection-root.js +126 -0
  12. package/dist/agent-selection-root.js.map +1 -0
  13. package/dist/attendance-wiring.d.ts +44 -0
  14. package/dist/attendance-wiring.d.ts.map +1 -0
  15. package/dist/attendance-wiring.js +432 -0
  16. package/dist/attendance-wiring.js.map +1 -0
  17. package/dist/backup-restore-handlers.d.ts +10 -0
  18. package/dist/backup-restore-handlers.d.ts.map +1 -0
  19. package/dist/backup-restore-handlers.js +82 -0
  20. package/dist/backup-restore-handlers.js.map +1 -0
  21. package/dist/boot-agents.d.ts +64 -0
  22. package/dist/boot-agents.d.ts.map +1 -0
  23. package/dist/boot-agents.js +241 -0
  24. package/dist/boot-agents.js.map +1 -0
  25. package/dist/boot-connection-state.d.ts +40 -0
  26. package/dist/boot-connection-state.d.ts.map +1 -0
  27. package/dist/boot-connection-state.js +136 -0
  28. package/dist/boot-connection-state.js.map +1 -0
  29. package/dist/boot-core.d.ts +36 -0
  30. package/dist/boot-core.d.ts.map +1 -0
  31. package/dist/boot-core.js +292 -0
  32. package/dist/boot-core.js.map +1 -0
  33. package/dist/boot-parked-content.d.ts +34 -0
  34. package/dist/boot-parked-content.d.ts.map +1 -0
  35. package/dist/boot-parked-content.js +527 -0
  36. package/dist/boot-parked-content.js.map +1 -0
  37. package/dist/boot-sweeps.d.ts +22 -0
  38. package/dist/boot-sweeps.d.ts.map +1 -0
  39. package/dist/boot-sweeps.js +50 -0
  40. package/dist/boot-sweeps.js.map +1 -0
  41. package/dist/connection-agents.d.ts +16 -0
  42. package/dist/connection-agents.d.ts.map +1 -0
  43. package/dist/connection-agents.js +68 -0
  44. package/dist/connection-agents.js.map +1 -0
  45. package/dist/content-park.d.ts.map +1 -1
  46. package/dist/content-park.js +274 -25
  47. package/dist/content-park.js.map +1 -1
  48. package/dist/daemon-handle.d.ts +57 -0
  49. package/dist/daemon-handle.d.ts.map +1 -0
  50. package/dist/daemon-handle.js +2 -0
  51. package/dist/daemon-handle.js.map +1 -0
  52. package/dist/daemon-status-report.d.ts +40 -0
  53. package/dist/daemon-status-report.d.ts.map +1 -0
  54. package/dist/daemon-status-report.js +78 -0
  55. package/dist/daemon-status-report.js.map +1 -0
  56. package/dist/daemon.d.ts +5 -66
  57. package/dist/daemon.d.ts.map +1 -1
  58. package/dist/daemon.js +332 -4894
  59. package/dist/daemon.js.map +1 -1
  60. package/dist/delivery-open-registry.d.ts +1 -1
  61. package/dist/delivery-open-registry.js +1 -1
  62. package/dist/directory-connect.d.ts +27 -0
  63. package/dist/directory-connect.d.ts.map +1 -0
  64. package/dist/directory-connect.js +98 -0
  65. package/dist/directory-connect.js.map +1 -0
  66. package/dist/disconnect-cleanup.d.ts +45 -0
  67. package/dist/disconnect-cleanup.d.ts.map +1 -0
  68. package/dist/disconnect-cleanup.js +65 -0
  69. package/dist/disconnect-cleanup.js.map +1 -0
  70. package/dist/document-surface.d.ts +32 -0
  71. package/dist/document-surface.d.ts.map +1 -0
  72. package/dist/document-surface.js +150 -0
  73. package/dist/document-surface.js.map +1 -0
  74. package/dist/document-wiring.d.ts +53 -0
  75. package/dist/document-wiring.d.ts.map +1 -0
  76. package/dist/document-wiring.js +323 -0
  77. package/dist/document-wiring.js.map +1 -0
  78. package/dist/inbound-sessions.d.ts +9 -1
  79. package/dist/inbound-sessions.d.ts.map +1 -1
  80. package/dist/inbound-sessions.js +9 -1
  81. package/dist/inbound-sessions.js.map +1 -1
  82. package/dist/index.d.ts +2 -2
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +4 -2
  85. package/dist/index.js.map +1 -1
  86. package/dist/ipc-surface.d.ts +44 -0
  87. package/dist/ipc-surface.d.ts.map +1 -0
  88. package/dist/ipc-surface.js +106 -0
  89. package/dist/ipc-surface.js.map +1 -0
  90. package/dist/network-directory-node.d.ts +0 -18
  91. package/dist/network-directory-node.d.ts.map +1 -1
  92. package/dist/network-directory-node.js +1 -38
  93. package/dist/network-directory-node.js.map +1 -1
  94. package/dist/notification-handlers.d.ts.map +1 -1
  95. package/dist/notification-handlers.js +2 -0
  96. package/dist/notification-handlers.js.map +1 -1
  97. package/dist/onboarding-guidance.d.ts +79 -0
  98. package/dist/onboarding-guidance.d.ts.map +1 -0
  99. package/dist/onboarding-guidance.js +95 -0
  100. package/dist/onboarding-guidance.js.map +1 -0
  101. package/dist/operator-guidance.d.ts +25 -0
  102. package/dist/operator-guidance.d.ts.map +1 -0
  103. package/dist/operator-guidance.js +50 -0
  104. package/dist/operator-guidance.js.map +1 -0
  105. package/dist/park-recovery.d.ts +22 -1
  106. package/dist/park-recovery.d.ts.map +1 -1
  107. package/dist/park-recovery.js.map +1 -1
  108. package/dist/park-refusals.d.ts +147 -0
  109. package/dist/park-refusals.d.ts.map +1 -0
  110. package/dist/park-refusals.js +331 -0
  111. package/dist/park-refusals.js.map +1 -0
  112. package/dist/refusal-notices.d.ts.map +1 -1
  113. package/dist/refusal-notices.js +22 -2
  114. package/dist/refusal-notices.js.map +1 -1
  115. package/dist/refusal-reasons.d.ts +13 -0
  116. package/dist/refusal-reasons.d.ts.map +1 -1
  117. package/dist/refusal-reasons.js +19 -0
  118. package/dist/refusal-reasons.js.map +1 -1
  119. package/dist/register-handler.d.ts +8 -7
  120. package/dist/register-handler.d.ts.map +1 -1
  121. package/dist/register-handler.js.map +1 -1
  122. package/dist/session-content-handlers.d.ts.map +1 -1
  123. package/dist/session-content-handlers.js +2 -0
  124. package/dist/session-content-handlers.js.map +1 -1
  125. package/dist/session-content-ingest.d.ts +1 -0
  126. package/dist/session-content-ingest.d.ts.map +1 -1
  127. package/dist/session-content-ingest.js +2 -2
  128. package/dist/session-content-ingest.js.map +1 -1
  129. package/dist/session-node-factory.d.ts +18 -0
  130. package/dist/session-node-factory.d.ts.map +1 -0
  131. package/dist/session-node-factory.js +182 -0
  132. package/dist/session-node-factory.js.map +1 -0
  133. package/dist/session-node-manager.d.ts +1 -0
  134. package/dist/session-node-manager.d.ts.map +1 -1
  135. package/dist/session-node-manager.js.map +1 -1
  136. package/dist/session-node-types.d.ts +8 -0
  137. package/dist/session-node-types.d.ts.map +1 -1
  138. package/dist/session-node-types.js.map +1 -1
  139. package/dist/session-notify.d.ts +46 -0
  140. package/dist/session-notify.d.ts.map +1 -0
  141. package/dist/session-notify.js +115 -0
  142. package/dist/session-notify.js.map +1 -0
  143. package/dist/session-read-handlers.d.ts.map +1 -1
  144. package/dist/session-read-handlers.js +0 -1
  145. package/dist/session-read-handlers.js.map +1 -1
  146. package/dist/session-salt-agreement.d.ts +0 -7
  147. package/dist/session-salt-agreement.d.ts.map +1 -1
  148. package/dist/session-salt-agreement.js +0 -7
  149. package/dist/session-salt-agreement.js.map +1 -1
  150. package/dist/session-views.d.ts +46 -0
  151. package/dist/session-views.d.ts.map +1 -0
  152. package/dist/session-views.js +197 -0
  153. package/dist/session-views.js.map +1 -0
  154. package/dist/signal-handlers.d.ts +66 -0
  155. package/dist/signal-handlers.d.ts.map +1 -0
  156. package/dist/signal-handlers.js +975 -0
  157. package/dist/signal-handlers.js.map +1 -0
  158. package/dist/signaling-wiring.d.ts +100 -0
  159. package/dist/signaling-wiring.d.ts.map +1 -0
  160. package/dist/signaling-wiring.js +392 -0
  161. package/dist/signaling-wiring.js.map +1 -0
  162. package/dist/start-agent.d.ts +64 -0
  163. package/dist/start-agent.d.ts.map +1 -0
  164. package/dist/start-agent.js +136 -0
  165. package/dist/start-agent.js.map +1 -0
  166. package/dist/status-handler.d.ts +46 -0
  167. package/dist/status-handler.d.ts.map +1 -0
  168. package/dist/status-handler.js +58 -0
  169. package/dist/status-handler.js.map +1 -0
  170. package/dist/test-handlers.d.ts +62 -0
  171. package/dist/test-handlers.d.ts.map +1 -0
  172. package/dist/test-handlers.js +239 -0
  173. package/dist/test-handlers.js.map +1 -0
  174. package/dist/types.d.ts +0 -11
  175. package/dist/types.d.ts.map +1 -1
  176. package/dist/types.js +0 -12
  177. package/dist/types.js.map +1 -1
  178. package/dist/unresolved-nodes-report.d.ts +14 -0
  179. package/dist/unresolved-nodes-report.d.ts.map +1 -0
  180. package/dist/unresolved-nodes-report.js +96 -0
  181. package/dist/unresolved-nodes-report.js.map +1 -0
  182. package/dist/who-resolver.d.ts +15 -0
  183. package/dist/who-resolver.d.ts.map +1 -0
  184. package/dist/who-resolver.js +46 -0
  185. package/dist/who-resolver.js.map +1 -0
  186. package/dist/wire-content-hash.d.ts +0 -2
  187. package/dist/wire-content-hash.d.ts.map +1 -1
  188. package/dist/wire-content-hash.js +0 -4
  189. package/dist/wire-content-hash.js.map +1 -1
  190. package/package.json +5 -5
@@ -0,0 +1,975 @@
1
+ /**
2
+ * The trust-signal surface: listing, viewing, enabling, disabling and revoking an agent's signals,
3
+ * issuing an attestation about someone else, the three consent verbs, and the results fetch that
4
+ * asks every directory in the roster what it holds.
5
+ *
6
+ * Twelve handlers plus the two helpers only they use — the selected-agent resolver and
7
+ * `submitForAgent`, the one path that composes, seals, sends and retries a submission. They lived
8
+ * inside `startDaemonHoldingLock` and closed over the daemon's state; here they NAME what they need.
9
+ *
10
+ * ⚠️ FOURTEEN MEMBERS, TWO OVER THE ORDER'S BOUND, AND THERE *IS* A SEAM. Measured, because the
11
+ * first version of this note asserted there was none — which is how a wrong comment survives. Four
12
+ * members have one consumer between them: roster, visiting connection and `waitForSignalingConnected`
13
+ * are `wallet_fetch_results` alone, the manifest is `submitForAgent` alone. Lift the results fetch
14
+ * out and eleven remain, under the bound. It is NOT lifted: it calls `resolveSelectedAgent`, so the
15
+ * split exports a deliberately-private helper or adds a third module, and each module costs the root
16
+ * another ~15-line call site — the failure the order names in bold: extraction that makes the root
17
+ * BIGGER. Two members are not worth that. Recorded so nobody has to re-measure it.
18
+ *
19
+ * Behavior is unchanged — bodies moved verbatim, comments included. The one edit: the per-connection
20
+ * state Map became `getConnState`, since these handlers only ever read one entry and the Map would
21
+ * hand this surface power to mutate connection state. Same narrowing as `contact-handlers.ts`.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { decodeCbor } from "@cello-protocol/protocol-types";
25
+ import { CONSENT_ACCEPTED } from "./consent-migration.js";
26
+ import { revocabilityOf } from "./signal-revocability.js";
27
+ import { composeSealedSubmission, fetchSubmissionResults, sendSealedSubmission } from "./signal-submission.js";
28
+ import { DEFAULT_RETRY_WINDOW_MS, isRetryableSendFailure } from "./submission-retry.js";
29
+ import { TrustSignalStore } from "./trust-signal-store.js";
30
+ /**
31
+ * Cap on a refusal message (M10B-D4). Generous for prose — the point is not to police what the
32
+ * operator writes, it is that an UNBOUNDED string reaches a signer, a sealer and a transport, and
33
+ * fails in the transport where the error names the wrong subsystem.
34
+ */
35
+ const MAX_SUBMISSION_BODY_CHARS = 4000;
36
+ export function registerSignalHandlers(deps) {
37
+ const { handlers, logger, sessionNodeManager, keyProviders, onlineAgents, loadedAgents, getConnState, getAgentSignaling, openVisitingConnection, waitForSignalingConnected, resolveConsortiumRoster, verifiedManifest, submissionRetries, recordIssuedSubmission, } = deps;
38
+ handlers.set("wallet_list_signals", async (_params, _connectionId) => {
39
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
40
+ const rows = store.listAllWalletSignals().map((r) => ({
41
+ type: r.type,
42
+ signal_hash: r.signalHash,
43
+ subject_kind: r.subjectKind,
44
+ subject: r.subject,
45
+ issuer_kind: r.issuerKind,
46
+ /**
47
+ * DOD-M15-SAMEOP-FALSEPOS-1: WHO ISSUED IT. The listing carried `subject` — who the signal is
48
+ * ABOUT — and never who SAID it, so an operator could see "someone endorsed me" and not see
49
+ * who. For the decision this list exists to support (do I rely on this endorsement?) the
50
+ * author is the primary fact, and an endorsement nobody can attribute is worth roughly
51
+ * nothing.
52
+ *
53
+ * `issuer_kind` reads like it answers this and does not: it says `"agent"` or `"portal"`, a
54
+ * category. Elsewhere a field literally named `issuer` holds `"peer-claimed"` /
55
+ * `"platform-verified"` — also a category. Two fields whose names promise identity, neither
56
+ * carrying it.
57
+ *
58
+ * It also unblocks an investigation this omission stalled: four endorsements in a wallet all
59
+ * read `same_operator: true`, and **nothing in the response could say whether the stranger's
60
+ * was among them** — so "a stranger is flagged as self-dealing" and "the stranger's
61
+ * endorsement never arrived" were indistinguishable from the listing. Different bugs, in
62
+ * different places, and the field that separates them was already in the row.
63
+ *
64
+ * Discloses nothing new: the issuer pubkey is inside the notarized envelope the recipient
65
+ * already holds and can already present.
66
+ */
67
+ issuer_pubkey: r.issuerPubkey,
68
+ status: r.status,
69
+ issued_at: r.issuedAt,
70
+ expires_at: r.expiresAt,
71
+ supersedes_hash: r.supersedesHash,
72
+ default_present: r.defaultPresent,
73
+ // M10B / DOD-END-ACCEPT-1 review F4. Without this the operator cannot distinguish a signal
74
+ // that will be presented from one awaiting their decision — or one they already refused —
75
+ // because `default_present: true` looks identical in all three cases. `default_present`
76
+ // answers "include it by default"; this answers the prior question, "may it be presented at
77
+ // all".
78
+ consent_state: r.consentState,
79
+ // M10B / DOD-END-COUNT-1. The operator holds an endorsement whose worth is CAPPED — a
80
+ // recipient's floor excludes it from `min_count` — and until now nothing told them so. Two
81
+ // endorsements looked identical in this list while one could clear a counterparty's bar and the
82
+ // other could not, which is the kind of invisible difference that reads as the protocol being
83
+ // arbitrary. It is a portal-attested envelope field, so surfacing it discloses nothing the
84
+ // recipient will not already see.
85
+ same_operator: r.sameOperator,
86
+ }));
87
+ return { ok: true, signals: rows };
88
+ });
89
+ handlers.set("wallet_view_signal", async (params, _connectionId) => {
90
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
91
+ if (!prefix || prefix.length < 8) {
92
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
93
+ }
94
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
95
+ let row;
96
+ try {
97
+ row = store.getWalletSignalByPrefix(prefix);
98
+ }
99
+ catch (err) {
100
+ return { ok: false, reason: "ambiguous_prefix", guidance: err instanceof Error ? err.message : String(err) };
101
+ }
102
+ if (!row) {
103
+ return { ok: false, reason: "signal_not_found", guidance: `No wallet signal with hash prefix '${prefix}'.` };
104
+ }
105
+ let payload;
106
+ try {
107
+ payload = decodeCbor(row.payload);
108
+ }
109
+ catch {
110
+ payload = Buffer.from(row.payload).toString("hex");
111
+ }
112
+ return {
113
+ ok: true,
114
+ type: row.type,
115
+ signal_hash: row.signalHash,
116
+ subject_kind: row.subjectKind,
117
+ subject: row.subject,
118
+ issuer_kind: row.issuerKind,
119
+ issuer_pubkey: row.issuerPubkey,
120
+ schema_version: row.schemaVersion,
121
+ status: row.status,
122
+ default_present: row.defaultPresent,
123
+ consent_state: row.consentState, // review F4 — see wallet_list_signals
124
+ issued_at: row.issuedAt,
125
+ expires_at: row.expiresAt,
126
+ supersedes_hash: row.supersedesHash,
127
+ payload,
128
+ };
129
+ });
130
+ handlers.set("wallet_enable_signal", async (params, _connectionId) => {
131
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
132
+ if (!prefix || prefix.length < 8) {
133
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
134
+ }
135
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
136
+ let row;
137
+ try {
138
+ row = store.getWalletSignalByPrefix(prefix);
139
+ }
140
+ catch (err) {
141
+ return { ok: false, reason: "ambiguous_prefix", guidance: err instanceof Error ? err.message : String(err) };
142
+ }
143
+ if (!row) {
144
+ return { ok: false, reason: "signal_not_found", guidance: `No wallet signal with hash prefix '${prefix}'.` };
145
+ }
146
+ // M10B / DOD-END-ACCEPT-1 review F4. Enabling a signal the subject has not accepted returned
147
+ // `{ok: true, default_present: true}` — the daemon affirming it will now be presented, when it
148
+ // never will. `default_present` selects from what is ELIGIBLE, and an unconsented signal is not
149
+ // eligible; saying yes here is a hollow success on the one verb that does respond.
150
+ if (row.consentState !== CONSENT_ACCEPTED) {
151
+ return {
152
+ ok: false,
153
+ reason: "consent_pending",
154
+ guidance: `This signal is '${row.consentState ?? "unset"}', not accepted, so it cannot be presented ` +
155
+ "regardless of the default-present flag. Accept it first; enabling it changes nothing until then.",
156
+ };
157
+ }
158
+ store.setDefaultPresent(row.signalHash, true);
159
+ return { ok: true, signal_hash: row.signalHash, default_present: true };
160
+ });
161
+ handlers.set("wallet_disable_signal", async (params, _connectionId) => {
162
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
163
+ if (!prefix || prefix.length < 8) {
164
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
165
+ }
166
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
167
+ let row;
168
+ try {
169
+ row = store.getWalletSignalByPrefix(prefix);
170
+ }
171
+ catch (err) {
172
+ return { ok: false, reason: "ambiguous_prefix", guidance: err instanceof Error ? err.message : String(err) };
173
+ }
174
+ if (!row) {
175
+ return { ok: false, reason: "signal_not_found", guidance: `No wallet signal with hash prefix '${prefix}'.` };
176
+ }
177
+ store.setDefaultPresent(row.signalHash, false);
178
+ return { ok: true, signal_hash: row.signalHash, default_present: false };
179
+ });
180
+ // ── M10B / DOD-END-SURFACE-1 — the consent verbs (D-23, M10B-D5) ───────────────────────────────
181
+ //
182
+ // SCOPED TO THE SELECTED AGENT, not to "the first loaded agent" the way wallet_revoke_signal is.
183
+ // Consent is a decision the SUBJECT makes about an object a third party wrote concerning them, so
184
+ // answering it on the wrong agent's behalf is not a cosmetic error — it is one agent deciding for
185
+ // another. The presenting-agent pubkey is also what the store's queries scope on, and passing the
186
+ // device-local agent_id instead now REFUSES rather than silently returning an empty queue.
187
+ const resolveSelectedAgent = (connectionId) => {
188
+ const name = getConnState(connectionId)?.currentAgent ?? null;
189
+ if (!name) {
190
+ return {
191
+ ok: false,
192
+ reason: "no_current_agent",
193
+ // NOT "a consent decision belongs to…" — this resolver is shared with the attestation verbs
194
+ // now, and `cello attestations issued` answered a plain "what happened to what I sent?" with
195
+ // an explanation about consent. Guidance that describes a different verb is worse than none:
196
+ // it sends the reader to fix something that was never wrong.
197
+ guidance: "Select an agent first (cello_use_agent) — these act AS a specific agent, and the wrong one would answer on another agent's behalf.",
198
+ };
199
+ }
200
+ const rec = loadedAgents.find((a) => a.name === name);
201
+ if (!rec) {
202
+ return { ok: false, reason: "agent_not_loaded", guidance: `Agent '${name}' is selected but not loaded on this daemon.` };
203
+ }
204
+ return { ok: true, name, pubkey: rec.pubkey };
205
+ };
206
+ /**
207
+ * Compose → seal → send ONE submission on behalf of the selected agent, applying every guard that
208
+ * must hold for any of them.
209
+ *
210
+ * Extracted because `refuse` and `issue` are the same journey with a different `op`, and a second
211
+ * hand-written copy is how two paths that must agree stop agreeing. The guards are the point: an
212
+ * agent that is not started must not be brought online by a side effect, an unbounded body must
213
+ * not reach the transport, and the CAUSE of a refusal must survive to the operator. Duplicating
214
+ * those means the next verb gets whichever subset its author remembered.
215
+ *
216
+ * INV-ATTRIBUTION holds BY CONSTRUCTION, and it did not before: this used to take the resolved
217
+ * `sel` as a parameter, which is exactly a parameter through which a caller could name a different
218
+ * identity. Both call sites happened to pass the right one, so the invariant held by CONVENTION
219
+ * while the comment claimed structure — and the test that "pinned" it asserted the absence of two
220
+ * identifiers that had never existed, so it could not fail. It now takes `connectionId` and
221
+ * resolves the selection itself. There is no identity input left to get wrong.
222
+ */
223
+ async function submitForAgent(opts) {
224
+ const { context } = opts;
225
+ const resolved = resolveSelectedAgent(opts.connectionId);
226
+ if (!resolved.ok)
227
+ return { queued: false, reason: resolved.reason, guidance: `${context} ${resolved.guidance}` };
228
+ const sel = resolved;
229
+ if (opts.body.length > MAX_SUBMISSION_BODY_CHARS) {
230
+ return { queued: false, reason: "message_too_long",
231
+ guidance: `${context} it is ${opts.body.length} characters and the limit is ${MAX_SUBMISSION_BODY_CHARS}.` };
232
+ }
233
+ // `getAgentSignaling` is NOT a getter — for an agent with no manager it CONSTRUCTS one, which
234
+ // dials and authenticates to the directory immediately and installs an unbounded reconnect loop.
235
+ // Calling it on a stopped agent would silently bring it online: the directory would route
236
+ // sessions to it while no standing receiver exists (`standing_receiver_unavailable`), and
237
+ // `cello status` would still report it offline.
238
+ if (!onlineAgents.has(sel.name)) {
239
+ return { queued: false, reason: "agent_offline",
240
+ guidance: `${context} agent '${sel.name}' is not started. Run cello_start_agent and try again — re-sending is safe, the submission id is derived from the content.` };
241
+ }
242
+ const kp = keyProviders.get(sel.name);
243
+ if (!kp) {
244
+ logger.warn("signal.submission.refused", { agentName: sel.name, reason: "key_provider_absent", op: opts.op });
245
+ return { queued: false, reason: "key_provider_absent",
246
+ guidance: `${context} no signing key is loaded for '${sel.name}'.` };
247
+ }
248
+ try {
249
+ const composed = await composeSealedSubmission({
250
+ manifest: verifiedManifest, keyProvider: kp, op: opts.op,
251
+ subjectKind: opts.subjectKind, subject: opts.subject, body: opts.body,
252
+ issuedAt: Math.floor(Date.now() / 1000), logger,
253
+ });
254
+ if (!composed.ok) {
255
+ // ERRORS NAME THEIR CAUSE: manifest_unavailable / manifest_expired / intake_key_absent /
256
+ // intake_key_malformed each say WHICH check refused, and that survives rather than
257
+ // collapsing into a generic send failure that points at the network.
258
+ return { queued: false, reason: composed.reason, guidance: `${context} ${composed.guidance}` };
259
+ }
260
+ // Resolved ONCE, and carried. `agent_name` is a display label and is reusable after a
261
+ // retire; every row and every queue entry below keys on this stable id instead.
262
+ const agentId = sessionNodeManager.resolveAgentId(sel.name);
263
+ const sent = await sendSealedSubmission({
264
+ signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
265
+ submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
266
+ ciphertext: composed.ciphertext, logger,
267
+ });
268
+ if (!sent.ok) {
269
+ // DOD-M15-ENDORSE-RETRY-1 — WORTH RETRYING, OR A VERDICT? The typed failure decides, never
270
+ // a string match. `submission_refused_by_node` means a node decoded it, evaluated it and
271
+ // said no, and it falls straight through to the plain failure below; everything else means
272
+ // no node ever reached a decision, so the daemon keeps it and re-sends on the reconnect.
273
+ /**
274
+ * DO NOT HOLD A BLOB PAST ITS OWN INTAKE KEY (review M4).
275
+ *
276
+ * The sealed bytes are opened by the portal's intake key from THIS manifest. Holding them
277
+ * across that key's expiry produces a submission the portal cannot open and cannot even
278
+ * attribute — poison, with no reply possible — while the operator has been told it is held
279
+ * and needs nothing from them. The plain failure is the better answer: they re-run it once
280
+ * a current manifest is loaded, and they know to.
281
+ *
282
+ * The manifest is in hand here and nowhere inside the queue, which is why the check lives
283
+ * at the call site rather than in the module that owns the window.
284
+ */
285
+ const manifestExpiresAt = verifiedManifest ? Date.parse(verifiedManifest.expires) : NaN;
286
+ const keyOutlivesWindow = Number.isFinite(manifestExpiresAt) && manifestExpiresAt - Date.now() > DEFAULT_RETRY_WINDOW_MS;
287
+ if (!keyOutlivesWindow) {
288
+ logger.warn("signal.submission.retry.not_held", {
289
+ agentName: sel.name,
290
+ submissionId: composed.submissionId,
291
+ reason: "intake_key_expires_within_retry_window",
292
+ manifestExpires: verifiedManifest?.expires ?? null,
293
+ impact: "the operator is told it failed rather than being told it is held",
294
+ });
295
+ return {
296
+ queued: false,
297
+ reason: sent.reason,
298
+ guidance: `${context} it did not reach a directory node (${sent.reason}), and the daemon is NOT ` +
299
+ "holding it to retry: the portal intake key it is sealed to expires too soon, and a " +
300
+ "submission sent after that expires is one the portal cannot open or even attribute. " +
301
+ "Load a current consortium manifest (cello_status shows its validity), then send it again.",
302
+ };
303
+ }
304
+ if (isRetryableSendFailure(sent.reason)) {
305
+ const held = submissionRetries.enqueue({
306
+ agentName: sel.name,
307
+ agentId,
308
+ submissionId: composed.submissionId,
309
+ intakeKeyId: composed.intakeKeyId,
310
+ // THE SAME SEALED BYTES, carried rather than re-derived. A re-seal is randomised and a
311
+ // re-compose would take a new `issued_at` — which changes the content-derived id, and
312
+ // a changed id is a second endorsement rather than a retry.
313
+ ciphertext: composed.ciphertext,
314
+ op: opts.op,
315
+ subject: opts.subject,
316
+ }, sent.reason);
317
+ if (held) {
318
+ return {
319
+ queued: false,
320
+ retrying: true,
321
+ submissionId: composed.submissionId,
322
+ reason: sent.reason,
323
+ guidance: `${context.replace(/:$/, "")} — not yet. It did not reach a directory node ` +
324
+ `(${sent.reason}), so the daemon is holding it and will send it as soon as the ` +
325
+ "directory signaling stream is back, on whichever node that is. You do NOT need to " +
326
+ "run this again. Run cello_attestations_issued to see where it got to. It is held IN " +
327
+ "MEMORY, so if the daemon restarts before it lands you will have to write it again.",
328
+ };
329
+ }
330
+ // The queue is full, so nothing is holding it and saying otherwise would be a lie the
331
+ // operator acts on. They get the plain failure and the fact that re-sending is safe.
332
+ }
333
+ return { queued: false, reason: sent.reason, guidance: `${context} ${sent.guidance ?? sent.reason}` };
334
+ }
335
+ // F4: `sendSealedSubmission` ALREADY logs `signal.submission.queued` / `.duplicate`. Logging
336
+ // `queued` again here doubled every count-based alarm and, worse, emitted `queued` right after
337
+ // `duplicate` — partially erasing the very distinction the directory's queue repository exists
338
+ // to preserve. A distinct name, only for what the send layer does not know (which agent, which
339
+ // op).
340
+ logger.info("signal.submission.attributed", {
341
+ agentName: sel.name, op: opts.op, submissionId: composed.submissionId, stored: sent.stored,
342
+ });
343
+ // KEEP THE HANDLE, or a withdrawal has nothing to name. Recorded in the SHARED path so every
344
+ // verb added after this one is covered by construction, which is the same reasoning as the
345
+ // `storedWarning` below — and by the same helper the RETRY path uses, so the two cannot drift
346
+ // about what a landed submission records.
347
+ // A LANDED SEND RETIRES AN EARLIER GIVE-UP for the same submission. The id is content-derived,
348
+ // so re-issuing the same words about the same subject produces the same id — this is the "I
349
+ // wrote it again and it worked" case, and leaving the stale failure on the surface would show
350
+ // the operator two contradictory states for one submission, forever.
351
+ submissionRetries.clearGaveUp(agentId, composed.submissionId);
352
+ recordIssuedSubmission(sel.name, agentId, {
353
+ submissionId: composed.submissionId,
354
+ subject: opts.subject,
355
+ op: opts.op,
356
+ intakeKeyId: composed.intakeKeyId,
357
+ stored: sent.stored,
358
+ });
359
+ // F1: `stored: false` means a node reports it ALREADY HELD this submission id. That is either
360
+ // a benign retry or single-node censorship — an operator pre-inserting garbage under a
361
+ // clear-text id — and they are indistinguishable from here. Reporting it as unqualified
362
+ // success is what makes the attack silent. The warning lives in the SHARED path, not at one
363
+ // call site, because the refuse verb had it and the issue verb did not: the same omission
364
+ // would otherwise be available to every verb added after this one.
365
+ return {
366
+ queued: true, stored: sent.stored, submissionId: composed.submissionId,
367
+ ...(sent.stored ? {} : {
368
+ storedWarning: "A directory node accepted it but reports it already held this submission id. If nothing arrives for the recipient, send it again — re-sending is safe, the submission id is derived from the content.",
369
+ }),
370
+ };
371
+ }
372
+ catch (err) {
373
+ const reason = err instanceof Error ? err.message : String(err);
374
+ logger.warn("signal.submission.refused", { agentName: sel.name, op: opts.op, reason });
375
+ return { queued: false, reason, guidance: `${context} ${reason}` };
376
+ }
377
+ }
378
+ /**
379
+ * M10B / DOD-END-SURFACE-1 — issue a trust signal ABOUT a counterparty.
380
+ *
381
+ * NOTE WHAT IS NOT HERE: a `type` parameter, and the word "endorsement" anywhere in the path. The
382
+ * submission wire carries no type field — the PORTAL decides what it mints from a submission — so
383
+ * a second client-sourced type needs no new verb, no new parameter and no client change. That is
384
+ * INV-ZEROBUMP holding by construction rather than by discipline, and it is what
385
+ * `DOD-END-PLAYBOOK-1` has to prove with an empty diff.
386
+ *
387
+ * The subject is the counterparty's K_local pubkey: the only identifier a contact actually holds.
388
+ * No account identifier crosses the wire — the portal resolves agent → account at intake, and the
389
+ * directory is hash-only by design.
390
+ */
391
+ // M10B / DOD-END-SURFACE-1 — "see what I have submitted about others". The wallet list answers
392
+ // "what do people say about ME"; this answers the other direction, and it is the prerequisite for
393
+ // withdrawal: you cannot withdraw a submission you cannot name.
394
+ // M10B / `M10B-D25r2` — collect this agent's outcomes from the directory and open any sealed
395
+ // message with k_local. Separate from `wallet_list_issued` because it is a NETWORK call: listing
396
+ // what you submitted must keep working when the directory is unreachable, and folding a fetch into
397
+ // it would make a local read fail for a remote reason.
398
+ handlers.set("wallet_fetch_results", async (_params, connectionId) => {
399
+ const sel = resolveSelectedAgent(connectionId);
400
+ if (!sel.ok)
401
+ return sel;
402
+ const kp = keyProviders.get(sel.name);
403
+ if (!kp) {
404
+ return { ok: false, reason: "agent_not_loaded",
405
+ guidance: `Agent '${sel.name}' has no key loaded, so a sealed result could not be opened. Restart the daemon and select the agent again.` };
406
+ }
407
+ // ── ASK EVERY NODE, AND SAY WHICH ONES DID NOT ANSWER ─────────────────────────────────────────
408
+ // An outcome is recorded on whichever node accepted the submission, and this agent is connected
409
+ // to ONE node — routinely not the same one. Asking only home turns "your refusal is on another
410
+ // node" into "you have no results", which is the answer that makes a counterparty look silent
411
+ // when they were not.
412
+ //
413
+ // A NODE THAT DOES NOT ANSWER IS `unreachable`, NEVER an empty result. If a timeout collapsed
414
+ // into "nothing here", a down node could silently produce a negative answer — the same lie in a
415
+ // new place. The caller is told what was actually covered.
416
+ const seen = new Map();
417
+ const unreachable = [];
418
+ function mapResult(r) {
419
+ return {
420
+ submission_id: r.submissionId,
421
+ outcome: r.outcome,
422
+ reason: r.reason,
423
+ signal_hash: r.signalHash,
424
+ message: r.message,
425
+ created_at: r.createdAt,
426
+ };
427
+ }
428
+ const opener = kp;
429
+ // Home node first — it needs no connection and answers fastest.
430
+ const home = await fetchSubmissionResults({
431
+ signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
432
+ keyProvider: opener,
433
+ logger,
434
+ });
435
+ if (home.ok)
436
+ for (const r of home.results)
437
+ seen.set(r.submissionId, mapResult(r));
438
+ else
439
+ unreachable.push("home");
440
+ // Then every OTHER node in the consortium, over a transient visiting connection — the same
441
+ // mechanism a cross-node session uses. Each is independent: one node refusing to answer must not
442
+ // stop the others being asked.
443
+ const roster = (await resolveConsortiumRoster().catch(() => null)) ?? [];
444
+ for (const node of roster) {
445
+ let visit = null;
446
+ try {
447
+ visit = openVisitingConnection(sel.name, kp, sel.pubkey, { peerId: node.peerId, multiaddr: node.multiaddr }, randomUUID(), node.nodeId);
448
+ // WAIT FOR THE CONNECTION BEFORE USING IT. openVisitingConnection returns SYNCHRONOUSLY and the
449
+ // manager dials in the background, so asking it for results on the next line finds it still
450
+ // connecting and gets `signaling_reconnecting` — every node, instantly. Observed live once the
451
+ // environment was awake: three regions "unreachable" within 3ms of each other, which no real
452
+ // network failure looks like. The seal-broker path above already does this; this one did not.
453
+ if (!(await waitForSignalingConnected(visit.mgr, 10_000))) {
454
+ logger.warn("signal.results.node.unreachable", { nodeId: node.nodeId, reason: "visiting_connect_timeout" });
455
+ unreachable.push(node.nodeId);
456
+ continue;
457
+ }
458
+ const r = await fetchSubmissionResults({ signaling: visit.mgr, keyProvider: opener, logger });
459
+ if (r.ok)
460
+ for (const x of r.results) {
461
+ if (!seen.has(x.submissionId))
462
+ seen.set(x.submissionId, mapResult(x));
463
+ }
464
+ else {
465
+ // SAY WHY. `unreachable` is a list of node ids and nothing else, so a sweep that fails
466
+ // everywhere reports "all three unreachable" with the cause discarded at the exact point it
467
+ // was known — leaving the only evidence to be guessed at afterwards.
468
+ logger.warn("signal.results.node.unreachable", { nodeId: node.nodeId, reason: r.reason });
469
+ unreachable.push(node.nodeId);
470
+ }
471
+ }
472
+ catch (err) {
473
+ logger.warn("signal.results.node.unreachable", {
474
+ nodeId: node.nodeId,
475
+ reason: err instanceof Error ? err.message : String(err),
476
+ });
477
+ unreachable.push(node.nodeId);
478
+ }
479
+ finally {
480
+ // ALWAYS torn down. A visiting connection left open holds a stream the directory will drain
481
+ // its durable notification queue down — the bug this connection type has already caused once.
482
+ await visit?.stop("results fetch complete").catch(() => { });
483
+ }
484
+ }
485
+ if (seen.size === 0 && unreachable.length > 0 && unreachable.length >= roster.length) {
486
+ // EVERY node we tried failed. Reporting an empty list here would be the exact lie this fan-out
487
+ // exists to prevent.
488
+ //
489
+ // AN EMPTY ROSTER IS ITS OWN ANSWER. `resolveConsortiumRoster()` returns null when there is no
490
+ // current manifest, and `?? []` turns "I do not know of any other nodes" into "there are no
491
+ // other nodes" — so a daemon that cannot resolve the consortium at all reported "No directory
492
+ // node answered (home)", which reads as one bad node rather than as no map. Chasing that cost
493
+ // real time against a hibernated environment on 2026-07-31.
494
+ const noRoster = roster.length === 0;
495
+ return {
496
+ ok: false,
497
+ reason: noRoster ? "consortium_unresolved" : "results_unreachable",
498
+ guidance: noRoster
499
+ ? `This daemon cannot resolve any directory node right now, so there was nowhere to ask (${unreachable.join(", ")} also failed). Check that the directory is reachable — 'directory.consortium.node.unresolved' in the daemon log names each endpoint and why. Your outcomes are held until you collect them; nothing is lost.`
500
+ : `No directory node answered (${unreachable.join(", ")}). Your outcomes are held until you collect them — nothing is lost. Retry when connectivity returns.`,
501
+ };
502
+ }
503
+ return {
504
+ ok: true,
505
+ // WHAT WAS ACTUALLY COVERED. An empty list from a partial sweep means "nothing on the nodes we
506
+ // reached", which is a different claim from "nothing exists".
507
+ ...(unreachable.length > 0 ? { unreachable_nodes: unreachable } : {}),
508
+ results: [...seen.values()],
509
+ };
510
+ });
511
+ handlers.set("wallet_list_issued", async (_params, connectionId) => {
512
+ const sel = resolveSelectedAgent(connectionId);
513
+ if (!sel.ok)
514
+ return sel;
515
+ const agentId = sessionNodeManager.resolveAgentId(sel.name);
516
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
517
+ const rows = store.listIssuedSubmissions(agentId).map((r) => ({
518
+ submission_id: r.submissionId,
519
+ subject_pubkey: r.subjectPubkey,
520
+ op: r.op,
521
+ intake_key_id: r.intakeKeyId,
522
+ // Every row in this table reached a node. The in-flight ones below have not.
523
+ delivery: "accepted",
524
+ // FALSE means a node already held this id — a benign retry, or single-node censorship. The
525
+ // operator sees the distinction here rather than only in the moment they submitted.
526
+ stored: r.stored,
527
+ submitted_at: r.submittedAt,
528
+ }));
529
+ /**
530
+ * DOD-M15-ENDORSE-RETRY-1 — THE SUBMISSIONS THAT REACHED NO NODE, listed here because this is
531
+ * the verb whose whole question is "what happened to what I sent?".
532
+ *
533
+ * Without them a submission the daemon is retrying is INVISIBLE — the durable table only gets a
534
+ * row once a node accepted one — so the honest answer to that question was silence, which reads
535
+ * as "you sent nothing". And a give-up whose only consumer is a warn line in `daemon.log` is
536
+ * indistinguishable from the submission never having existed.
537
+ *
538
+ * These are IN MEMORY and do not survive a daemon restart (see submission-retry.ts). That is
539
+ * why they are a separate array rather than blended into `issued`: a caller must be able to
540
+ * tell a durable fact from a live one.
541
+ */
542
+ const inFlight = submissionRetries.list(agentId).map((p) => ({
543
+ submission_id: p.submissionId,
544
+ subject_pubkey: p.subject,
545
+ op: p.op,
546
+ intake_key_id: p.intakeKeyId,
547
+ delivery: p.delivery.state,
548
+ attempts: p.delivery.attempts,
549
+ last_reason: p.delivery.lastReason,
550
+ ...(p.delivery.state === "gave_up" ? { gave_up_because: p.delivery.gaveUpBecause } : {}),
551
+ guidance: p.delivery.guidance,
552
+ }));
553
+ return {
554
+ ok: true,
555
+ issued: rows,
556
+ in_flight: inFlight,
557
+ // NO BODY, and say so rather than letting its absence read as a bug. The text was the
558
+ // operator's own words about a third party; keeping it on disk in the clear is exactly what
559
+ // the sealed-submission path exists to prevent.
560
+ note: "The text you wrote is NOT stored locally — only the handle, subject and verb. That is deliberate: your words about someone else are sealed to the portal and are not kept in the clear on this machine.",
561
+ };
562
+ });
563
+ handlers.set("cello_attestations_issue", async (params, connectionId) => {
564
+ const sel = resolveSelectedAgent(connectionId);
565
+ if (!sel.ok)
566
+ return sel;
567
+ const subject = typeof params?.subject_pubkey === "string" ? params.subject_pubkey.toLowerCase() : "";
568
+ if (!/^[0-9a-f]{64}$/.test(subject)) {
569
+ return { ok: false, reason: "invalid_subject",
570
+ guidance: "subject_pubkey must be the counterparty's 32-byte public key as 64 hex characters — run cello_contacts to see the peers you know." };
571
+ }
572
+ const body = typeof params?.body === "string" ? params.body.trim() : "";
573
+ if (body.length === 0) {
574
+ return { ok: false, reason: "empty_body",
575
+ guidance: "An issued signal needs text — it is the claim you are making about them, in your own words." };
576
+ }
577
+ // SELF-ISSUANCE IS REFUSED AT THE SOURCE, and across EVERY agent on this daemon — not just the
578
+ // selected one. The check used to compare against `sel.pubkey` alone, which let an operator
579
+ // running two of their own agents issue from one about the other and sail through a guard whose
580
+ // comment claimed certainty. That configuration is not exotic: solo multi-agent is CELLO's first
581
+ // wedge, so it is the most likely way to hit this, not the least.
582
+ //
583
+ // The portal remains the real enforcer of INV-NO-SELF-STANDING — only it can see account
584
+ // linkage, and only it can catch two agents under one account on different machines. But the
585
+ // daemon knows its OWN agents with certainty, and refusing here gives the operator a real answer
586
+ // now instead of a silent rejection at intake minutes later.
587
+ const localSelf = loadedAgents.find((a) => a.pubkey.toLowerCase() === subject);
588
+ if (localSelf) {
589
+ return { ok: false, reason: "self_subject",
590
+ guidance: localSelf.name === sel.name
591
+ ? "An agent cannot issue a trust signal about itself — standing has to come from somebody else."
592
+ : `'${sel.name}' and '${localSelf.name}' are both your agents on this machine, so a signal from one about the other would be you vouching for yourself. Standing has to come from somebody else.` };
593
+ }
594
+ const res = await submitForAgent({
595
+ connectionId,
596
+ op: "submit", subjectKind: "agent", subject, body,
597
+ context: "The signal was NOT submitted:",
598
+ });
599
+ // DOD-M15-ENDORSE-RETRY-1 — `ok: true` with `delivery: "retrying"`, and both halves are
600
+ // deliberate. `ok: false` would send the agent to re-run a command the daemon is already
601
+ // handling, which is the exact operator work this unit exists to remove; `queued: true` would
602
+ // claim a directory node accepted something no node has seen. So: not a failure, not an
603
+ // acceptance, and named.
604
+ if (!res.queued && res.retrying) {
605
+ return {
606
+ ok: true, queued: false, delivery: "retrying",
607
+ submission_id: res.submissionId, reason: res.reason, guidance: res.guidance,
608
+ };
609
+ }
610
+ if (!res.queued)
611
+ return { ok: false, reason: res.reason, guidance: res.guidance };
612
+ return {
613
+ ok: true, queued: true, delivery: "accepted", stored: res.stored, submission_id: res.submissionId,
614
+ // Deliberately NOT "issued". Nothing is minted yet: the portal must still drain, authenticate,
615
+ // scan and mint, and the subject must then ACCEPT it before anyone else can see it. Reporting
616
+ // this as a completed endorsement would promise three steps that have not happened.
617
+ guidance: res.storedWarning
618
+ ? `Submitted for '${sel.name}'. ${res.storedWarning}`
619
+ : `Submitted for '${sel.name}'. The portal will scan and mint it, and it stays invisible to everyone unless the subject ACCEPTS it — they are free to refuse, and a signal they have not accepted is inert. Nothing here is final until they decide.`,
620
+ };
621
+ });
622
+ /**
623
+ * M10B / DOD-END-SURFACE-1 — per-counterparty presentation choice.
624
+ *
625
+ * `present: null` CLEARS the choice rather than setting it false. Those are different states and
626
+ * the surface must keep them apart: cleared means "no opinion, use the signal's default", false
627
+ * means "specifically not this person". An operator who could only toggle true/false would be
628
+ * unable to undo an omission without first knowing what the default had been.
629
+ */
630
+ handlers.set("cello_contact_set_signal", async (params, connectionId) => {
631
+ const sel = resolveSelectedAgent(connectionId);
632
+ if (!sel.ok)
633
+ return sel;
634
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey.toLowerCase() : "";
635
+ if (!/^[0-9a-f]{64}$/.test(pubkey)) {
636
+ return { ok: false, reason: "invalid_pubkey", guidance: "pubkey must be the counterparty's 32-byte public key as 64 hex characters." };
637
+ }
638
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : "";
639
+ if (prefix.length < 8) {
640
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters — see cello_trust_signals_list." };
641
+ }
642
+ const present = params?.present === null ? null : typeof params?.present === "boolean" ? params.present : undefined;
643
+ if (present === undefined) {
644
+ return { ok: false, reason: "invalid_present", guidance: "present must be true (show it to them), false (never show it to them), or null (clear the choice and fall back to the signal's default)." };
645
+ }
646
+ // Resolve the prefix against signals this agent actually holds, so a typo cannot silently write
647
+ // a preference about a hash that does not exist and sit there doing nothing forever.
648
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
649
+ const match = store.listAllWalletSignals().filter((r) => r.signalHash.startsWith(prefix));
650
+ if (match.length === 0) {
651
+ return { ok: false, reason: "signal_not_found", guidance: `No signal in this wallet starts with '${prefix}'.` };
652
+ }
653
+ if (match.length > 1) {
654
+ return { ok: false, reason: "ambiguous_prefix", guidance: `'${prefix}' matches ${match.length} signals — use more characters.` };
655
+ }
656
+ sessionNodeManager.setContactSignalPref(sel.name, pubkey, match[0].signalHash, present);
657
+ return {
658
+ ok: true, signal_hash: match[0].signalHash, pubkey, present,
659
+ guidance: present === null
660
+ ? "Choice cleared — this signal now follows its own default for this contact."
661
+ : present
662
+ ? "This signal will be presented to this contact when a session forms, if you have accepted it."
663
+ : "This signal will NOT be presented to this contact, whatever its default.",
664
+ };
665
+ });
666
+ handlers.set("cello_attestation_consent_list", async (_params, connectionId) => {
667
+ const sel = resolveSelectedAgent(connectionId);
668
+ if (!sel.ok)
669
+ return sel;
670
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
671
+ const items = store.listPendingConsent(sel.pubkey).map((r) => {
672
+ // THE PAYLOAD IS THE POINT OF THIS CALL. The operator is being asked to stand behind a claim
673
+ // somebody else wrote about them, and they cannot make that decision from a byte count. An
674
+ // earlier version returned `payload_bytes` while both surfaces instructed the operator to
675
+ // "read the plaintext before accepting" — so following the instruction produced a number, and
676
+ // accepting was necessarily blind. Decoded here, exactly as `wallet_view_signal` does it.
677
+ //
678
+ // Undecodable payloads fall back to hex rather than throwing: one unreadable item must not
679
+ // make every other pending decision unreachable, and hex is honest about what it is.
680
+ let payload;
681
+ try {
682
+ payload = decodeCbor(r.payload);
683
+ }
684
+ catch {
685
+ payload = Buffer.from(r.payload).toString("hex");
686
+ }
687
+ return {
688
+ signal_hash: r.signalHash,
689
+ type: r.type,
690
+ subject_kind: r.subjectKind,
691
+ issuer_kind: r.issuerKind,
692
+ issuer_pubkey: r.issuerPubkey,
693
+ issued_at: r.issuedAt,
694
+ // UNTRUSTED, and labelled as such on the way out. These are the issuer's own words, carried
695
+ // verbatim and never restated in any other voice (INV-UNTRUSTED). A consuming model must
696
+ // quote and attribute them — "<issuer> says: …" — never adopt them as its own statement.
697
+ payload,
698
+ payload_is_untrusted_text: true,
699
+ };
700
+ });
701
+ // Seeing the list IS being told. Marking here rather than in cello_use_agent means the operator
702
+ // is never marked notified about something they were not actually shown.
703
+ store.markConsentNotified(sel.pubkey);
704
+ return { ok: true, agent: sel.name, pending: items };
705
+ });
706
+ handlers.set("cello_attestation_consent_accept", async (params, connectionId) => {
707
+ const sel = resolveSelectedAgent(connectionId);
708
+ if (!sel.ok)
709
+ return sel;
710
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
711
+ if (!prefix || prefix.length < 8) {
712
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
713
+ }
714
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
715
+ const item = store.listPendingConsent(sel.pubkey).find((r) => r.signalHash.startsWith(prefix));
716
+ if (!item) {
717
+ // Deliberately does NOT fall back to a wallet-wide lookup: a hash this agent has no pending
718
+ // decision on is not this agent's to accept, and finding it anyway would be the cross-agent
719
+ // decision this scoping exists to prevent.
720
+ return { ok: false, reason: "not_pending_for_agent", guidance: `No pending consent item for '${sel.name}' with prefix '${prefix}'.` };
721
+ }
722
+ // The write RESULT is checked, not assumed. `setConsentState` returns false when zero rows
723
+ // changed; reporting "accepted" regardless would tell the operator a decision was recorded that
724
+ // was not, and the next presentation would silently omit it.
725
+ if (!store.setConsentState(item.signalHash, "accepted")) {
726
+ return { ok: false, reason: "consent_write_failed",
727
+ guidance: `The acceptance was NOT recorded — the signal row changed underneath this call. Run cello_attestation_consent_list and retry.` };
728
+ }
729
+ return { ok: true, signal_hash: item.signalHash, consent_state: "accepted" };
730
+ });
731
+ handlers.set("cello_attestation_consent_refuse", async (params, connectionId) => {
732
+ const sel = resolveSelectedAgent(connectionId);
733
+ if (!sel.ok)
734
+ return sel;
735
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
736
+ if (!prefix || prefix.length < 8) {
737
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
738
+ }
739
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
740
+ // REFUSAL IS REACHABLE AFTER ACCEPTANCE, not only while pending.
741
+ //
742
+ // "I accepted this endorsement and now I want it gone" had NO path: refusal was pending-only and
743
+ // revocation is the issuer's to perform, not the subject's. Refusing an accepted item is the
744
+ // answer, and it is the better one — the decision is RECORDED rather than erased, so the trail
745
+ // stays honest, and a refused signal is already inert everywhere it is checked.
746
+ //
747
+ // Peer-issued only (`issuer_kind <> 'portal'`, enforced in the store). Refusal makes a signal
748
+ // inert, so allowing it on portal-issued signals would be a back door to suppressing a MANDATORY
749
+ // track record — achieving by consent exactly what revocation is forbidden from doing.
750
+ const item = store.findDecidableConsent(sel.pubkey, prefix);
751
+ if (!item) {
752
+ return {
753
+ ok: false,
754
+ reason: "not_decidable_for_agent",
755
+ guidance: `No pending or accepted attestation for '${sel.name}' with prefix '${prefix}'. Refusal ` +
756
+ `applies to attestations another party issued ABOUT you — signals the portal issued (your ` +
757
+ `track record, verified email and phone, GitHub links, security factors) are not refused ` +
758
+ `here. Run cello_attestation_consent_list to see what is decidable.`,
759
+ };
760
+ }
761
+ const wasAccepted = item.consentState === "accepted";
762
+ // ORDER IS LOAD-BEARING: the refusal is recorded FIRST and is never conditional on the message
763
+ // getting out. A refusal that only takes effect if the network cooperates would leave a signal
764
+ // Alice believes she rejected sitting in an unrefused state — the exact failure INV-CONSENT
765
+ // exists to prevent. The message is a courtesy layered on top of a decision already made.
766
+ //
767
+ // And the write is CHECKED. The ordering above is worth nothing if nothing confirms the record
768
+ // happened: without this, the code would go on to sign and send Bob a refusal message about a
769
+ // decision that is not in the database.
770
+ if (!store.setConsentState(item.signalHash, "refused")) {
771
+ return { ok: false, reason: "consent_write_failed",
772
+ guidance: `The refusal was NOT recorded — the signal row changed underneath this call. Run cello_attestation_consent_list and retry.` };
773
+ }
774
+ const refused = {
775
+ ok: true,
776
+ signal_hash: item.signalHash,
777
+ consent_state: "refused",
778
+ // WITHDRAWN vs REFUSED, told apart in the response, because they are different acts and the
779
+ // operator needs to know which one just happened. Also flags the supersession consequence:
780
+ // accepting a re-issue supersedes what it replaced, and refusing it afterwards does NOT bring
781
+ // the predecessor back. Withdrawing consent from a replacement can therefore leave you with
782
+ // neither — surfaced rather than discovered.
783
+ ...(wasAccepted
784
+ ? {
785
+ withdrawn_after_acceptance: true,
786
+ guidance: `Consent WITHDRAWN — you had accepted this and it is now refused, so it stops being ` +
787
+ `presented anywhere. The record of the decision remains, which is what keeps the trail ` +
788
+ `honest. Note: if this attestation superseded an earlier one when you accepted it, the ` +
789
+ `earlier one stays superseded — withdrawing from a replacement does not restore what it ` +
790
+ `replaced.`,
791
+ }
792
+ : {}),
793
+ };
794
+ // M10B-D4: the message back to the issuer is the subject's CHOICE. Silence is the default, and a
795
+ // silent refusal tells Bob NOTHING — which is what keeps D-24 intact for anyone who wants it.
796
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
797
+ if (message.length === 0)
798
+ return { ...refused, message_queued: false };
799
+ // ACCOUNT-SUBJECT ITEMS DO NOT GET A MESSAGE YET, and this is a refusal, not an oversight.
800
+ //
801
+ // `listPendingConsent` scopes with `(subject_kind <> 'agent' OR lower(subject) = ?)`, so EVERY
802
+ // agent on this daemon can see — and therefore refuse — an account-subject item. The refusal
803
+ // itself is defensible (it is the account's own decision, and any of its agents speaks for it),
804
+ // but the MESSAGE is signed with THIS agent's K_local, so the issuer would receive a signed
805
+ // statement from an agent that was not the subject of anything. Which agent may speak for an
806
+ // account is an open question this milestone has not answered, and signing is not the place to
807
+ // guess at it. So the decision stands and the courtesy is withheld, with the reason named.
808
+ if (item.subjectKind !== "agent") {
809
+ return { ...refused, message_queued: false, message_error: "account_subject_message_unsupported",
810
+ guidance: `The refusal is recorded. Your message was NOT sent: this signal is about the ACCOUNT rather than about '${sel.name}', and a message would be signed by this agent alone — which agent may speak for an account is not yet settled.` };
811
+ }
812
+ // Rides the submission queue as the `refuse` op. The SUBJECT is the target signal hash (as it is
813
+ // for a withdrawal — both verbs act on an existing signal), and `subject_kind` is carried from
814
+ // the row rather than hardcoded: it is inside the TBS, so a hardcoded value would be a SIGNED
815
+ // field asserting something false.
816
+ const res = await submitForAgent({
817
+ connectionId,
818
+ op: "refuse", subjectKind: item.subjectKind, subject: item.signalHash, body: message,
819
+ context: "The refusal is recorded. Your message was NOT sent:",
820
+ });
821
+ // DOD-M15-ENDORSE-RETRY-1: the refusal itself is already recorded and unaffected either way —
822
+ // what is in question is only the MESSAGE back to the issuer. `message_delivery: "retrying"` is
823
+ // not `message_queued`, because no node has it yet, and it is not an error, because nothing is
824
+ // asked of the operator.
825
+ if (!res.queued && res.retrying) {
826
+ return {
827
+ ...refused, message_queued: false, message_delivery: "retrying",
828
+ submission_id: res.submissionId, guidance: `The refusal is recorded. ${res.guidance}`,
829
+ };
830
+ }
831
+ if (!res.queued) {
832
+ return { ...refused, message_queued: false, message_error: res.reason, guidance: res.guidance };
833
+ }
834
+ // `message_queued`, NOT `issuer_notified`. A directory node acked a sealed blob; the portal has
835
+ // not drained it, scanned it, minted it, or delivered anything to the issuer.
836
+ //
837
+ // `stored` is carried through rather than collapsed into plain success: it is the ONE signal
838
+ // separating a benign duplicate from single-node censorship (an operator pre-inserting garbage
839
+ // under a clear-text submission_id), and folding them together destroys the only information
840
+ // that could ever tell them apart.
841
+ return {
842
+ ...refused, message_queued: true, message_delivery: "accepted",
843
+ stored: res.stored, submission_id: res.submissionId,
844
+ ...(res.storedWarning ? { guidance: `The refusal is recorded. ${res.storedWarning}` } : {}),
845
+ };
846
+ });
847
+ handlers.set("wallet_revoke_signal", async (params, connectionId) => {
848
+ const hashPrefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
849
+ if (!hashPrefix || hashPrefix.length < 8) {
850
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters." };
851
+ }
852
+ const resolvedAgent = resolveSelectedAgent(connectionId);
853
+ if (!resolvedAgent.ok)
854
+ return resolvedAgent;
855
+ const sel = resolvedAgent;
856
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
857
+ let row;
858
+ try {
859
+ row = store.getWalletSignalByPrefix(hashPrefix);
860
+ }
861
+ catch (err) {
862
+ return { ok: false, reason: "ambiguous_prefix", guidance: err instanceof Error ? err.message : String(err) };
863
+ }
864
+ if (!row) {
865
+ return { ok: false, reason: "signal_not_found", guidance: `No wallet signal with hash prefix '${hashPrefix}'.` };
866
+ }
867
+ const signalHash = row.signalHash;
868
+ // ── CATEGORY CHECK, BEFORE ANYTHING IS DESTROYED ───────────────────────────────────────────
869
+ //
870
+ // This handler used to accept ANY signal in the wallet by hash and go — no type check at all.
871
+ // The signal used in the 2026-08-10 live test was a `track_record`, and the tool accepted the
872
+ // instruction to destroy it and deleted the local copy. Had the directory path been working,
873
+ // an operator could have deleted their own behavioural history: precisely what the
874
+ // mandatory-signal rule exists to prevent.
875
+ //
876
+ // Placed BEFORE the signing and before the local delete, because the local delete is
877
+ // unconditional further down — a refusal that happens after it would still have destroyed the
878
+ // operator's copy.
879
+ //
880
+ // This is a courtesy, NOT the enforcement: an operator can edit this file. The portal refuses
881
+ // mandatory revocations server-side, and the directory already makes a non-issuer's tombstone
882
+ // inert for attestations. See signal-revocability.ts.
883
+ // M7: THE WALLET LOOKUP IS NOT AGENT-SCOPED, so refuse here rather than queue under the wrong
884
+ // key. `getWalletSignalByPrefix` matches on hash alone, and several agents share this daemon's
885
+ // database — so the SELECTED agent could sign a revocation for a signal that is another agent's.
886
+ // The portal blocks it across accounts, but within one account it would silently succeed under
887
+ // the wrong agent's key, and across accounts the operator waits for an async `not_authorized`
888
+ // instead of being told immediately.
889
+ if (row.subjectKind === "agent" && row.subject.toLowerCase() !== sel.pubkey.toLowerCase()) {
890
+ return {
891
+ ok: false,
892
+ reason: "not_your_signal",
893
+ guidance: `That signal is about a different agent, not '${sel.name}'. Select the agent it belongs to ` +
894
+ `with cello_use_agent and retry.`,
895
+ };
896
+ }
897
+ const revocability = revocabilityOf(row.type);
898
+ if (!revocability.revocable) {
899
+ return {
900
+ ok: false,
901
+ reason: revocability.category === "mandatory" ? "signal_not_revocable" : "revoke_via_portal",
902
+ signal_type: row.type,
903
+ guidance: revocability.guidance,
904
+ };
905
+ }
906
+ // ── VIA THE PORTAL'S SUBMISSION QUEUE, NOT A DIRECT CALL TO A DIRECTORY ────────────────────
907
+ //
908
+ // What was here POSTed `/internal/signal/revoke` to port 9090 — the HEALTH port — took the 404
909
+ // as an answer, returned `ok: true` regardless, and hard-deleted the local copy "regardless of
910
+ // directory result". Measured 2026-08-10 against the live fleet: all three nodes unchanged, the
911
+ // operator's copy gone, and the tool reporting success. It also asked ONE node under a comment
912
+ // claiming it asked all three.
913
+ //
914
+ // The route is not the fix for a wrong port. The real route lives on the internal API, which is
915
+ // firewalled to the VPC subnets and unreachable from an operator's machine by any URL. And the
916
+ // deciding reason is ENFORCEMENT, not reachability: the directory deliberately cannot tell a
917
+ // `track_record` from a `github_id` (opaque `type`, no enum, so a new signal type never needs a
918
+ // directory deploy), and both are portal-issued, so `issuer_kind` does not separate them. A
919
+ // direct verb would revoke a behavioural record on request with only an editable client in the
920
+ // way. The PORTAL minted the signal and knows what it is, so the category rule can be real there
921
+ // rather than advisory here.
922
+ //
923
+ // Rides the EXISTING sealed submission queue — same path as an endorsement, same results
924
+ // channel. No new wire verb.
925
+ const submitted = await submitForAgent({
926
+ connectionId,
927
+ op: "revoke",
928
+ // The TARGET SIGNAL HASH, exactly as `refuse` and `withdraw` carry it — this acts on an
929
+ // existing signal rather than asserting a fact about a party.
930
+ subjectKind: row.subjectKind,
931
+ subject: signalHash,
932
+ body: "",
933
+ context: "The revocation was NOT queued:",
934
+ });
935
+ // DOD-M15-ENDORSE-RETRY-1: the local copy survives either way (see below), so a retrying
936
+ // revocation is a wait, not a loss — and the operator is told which it is rather than being
937
+ // sent to re-run a retraction the daemon is already carrying.
938
+ if (!submitted.queued && submitted.retrying) {
939
+ return {
940
+ ok: true, signal_hash: signalHash, submission_id: submitted.submissionId,
941
+ revoked: false, queued: false, delivery: "retrying",
942
+ guidance: `Revocation for '${row.type}' is HELD, not yet at a directory. ${submitted.guidance} ` +
943
+ "Your local copy is KEPT either way, deliberately, so nothing is lost while it waits.",
944
+ };
945
+ }
946
+ if (!submitted.queued) {
947
+ return { ok: false, reason: submitted.reason, guidance: submitted.guidance };
948
+ }
949
+ // THE LOCAL COPY SURVIVES. It used to be deleted unconditionally, so a failed retraction also
950
+ // destroyed the ability to retry — and since the directory half never worked, that was every
951
+ // retraction. The signal stays until the portal confirms the revocation; the operator can see
952
+ // the outcome with cello_attestations_issued and the wallet reflects it on the next refresh.
953
+ return {
954
+ ok: true,
955
+ signal_hash: signalHash,
956
+ submission_id: submitted.submissionId,
957
+ revoked: false,
958
+ queued: true,
959
+ delivery: "accepted",
960
+ // M5: CARRIED, not dropped. `submitForAgent`'s own comment says the warning lives in the
961
+ // shared path "because the same omission would otherwise be available to every verb added
962
+ // after this one" — and this was the next verb added. `stored:false` means a node already held
963
+ // this id: usually a benign retry, but also what single-node censorship looks like, and
964
+ // without it that reads as unqualified success.
965
+ stored: submitted.stored,
966
+ ...(submitted.storedWarning ? { stored_warning: submitted.storedWarning } : {}),
967
+ guidance: `Revocation QUEUED for '${row.type}' — not yet revoked. The portal opens it, checks the ` +
968
+ `signal is one you may retract, and revokes it at the directory; the outcome comes back on ` +
969
+ `the results channel. Your local copy is KEPT — deliberately, so a failure leaves you able to ` +
970
+ `retry. Nothing removes it automatically even on success: check the outcome with ` +
971
+ `cello_attestations_issued.`,
972
+ };
973
+ });
974
+ }
975
+ //# sourceMappingURL=signal-handlers.js.map