@cello-protocol/daemon 0.0.83 → 0.0.84

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.
@@ -1 +1 @@
1
- {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAMpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAgB7G,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAsB,MAAM,2BAA2B,CAAC;AAO5G,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAkBnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQlD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;IACrC;;OAEG;IACH,eAAe,IAAI,YAAY,CAAC;CACjC;AAYD,qBAAa,4BAA6B,YAAW,mBAAmB;IAChE,UAAU,CAAC,MAAM,EAAE,iBAAiB;CA8B3C;AAID,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAS7E"}
1
+ {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAMpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAiB7G,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAsB,MAAM,2BAA2B,CAAC;AAO5G,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAkBnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQlD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;IACrC;;OAEG;IACH,eAAe,IAAI,YAAY,CAAC;CACjC;AAYD,qBAAa,4BAA6B,YAAW,mBAAmB;IAChE,UAAU,CAAC,MAAM,EAAE,iBAAiB;CA8B3C;AAID,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAS7E"}
package/dist/daemon.js CHANGED
@@ -49,7 +49,7 @@ import { composeSealedSubmission, sendSealedSubmission } from "./signal-submissi
49
49
  * operator writes, it is that an UNBOUNDED string reaches a signer, a sealer and a transport, and
50
50
  * fails in the transport where the error names the wrong subsystem.
51
51
  */
52
- const MAX_REFUSAL_MESSAGE_CHARS = 4000;
52
+ const MAX_SUBMISSION_BODY_CHARS = 4000;
53
53
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
54
54
  // at the send point here (the receive point lives in the transport content decode).
55
55
  import { sealParkEnvelope } from "./park-envelope.js";
@@ -1782,6 +1782,200 @@ async function startDaemonHoldingLock(config, singletonLock) {
1782
1782
  }
1783
1783
  return { ok: true, name, pubkey: rec.pubkey };
1784
1784
  };
1785
+ /**
1786
+ * Compose → seal → send ONE submission on behalf of the selected agent, applying every guard that
1787
+ * must hold for any of them.
1788
+ *
1789
+ * Extracted because `refuse` and `issue` are the same journey with a different `op`, and a second
1790
+ * hand-written copy is how two paths that must agree stop agreeing. The guards are the point: an
1791
+ * agent that is not started must not be brought online by a side effect, an unbounded body must
1792
+ * not reach the transport, and the CAUSE of a refusal must survive to the operator. Duplicating
1793
+ * those means the next verb gets whichever subset its author remembered.
1794
+ *
1795
+ * INV-ATTRIBUTION holds BY CONSTRUCTION, and it did not before: this used to take the resolved
1796
+ * `sel` as a parameter, which is exactly a parameter through which a caller could name a different
1797
+ * identity. Both call sites happened to pass the right one, so the invariant held by CONVENTION
1798
+ * while the comment claimed structure — and the test that "pinned" it asserted the absence of two
1799
+ * identifiers that had never existed, so it could not fail. It now takes `connectionId` and
1800
+ * resolves the selection itself. There is no identity input left to get wrong.
1801
+ */
1802
+ async function submitForAgent(opts) {
1803
+ const { context } = opts;
1804
+ const resolved = resolveSelectedAgent(opts.connectionId);
1805
+ if (!resolved.ok)
1806
+ return { queued: false, reason: resolved.reason, guidance: `${context} ${resolved.guidance}` };
1807
+ const sel = resolved;
1808
+ if (opts.body.length > MAX_SUBMISSION_BODY_CHARS) {
1809
+ return { queued: false, reason: "message_too_long",
1810
+ guidance: `${context} it is ${opts.body.length} characters and the limit is ${MAX_SUBMISSION_BODY_CHARS}.` };
1811
+ }
1812
+ // `getAgentSignaling` is NOT a getter — for an agent with no manager it CONSTRUCTS one, which
1813
+ // dials and authenticates to the directory immediately and installs an unbounded reconnect loop.
1814
+ // Calling it on a stopped agent would silently bring it online: the directory would route
1815
+ // sessions to it while no standing receiver exists (`standing_receiver_unavailable`), and
1816
+ // `cello status` would still report it offline.
1817
+ if (!onlineAgents.has(sel.name)) {
1818
+ return { queued: false, reason: "agent_offline",
1819
+ 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.` };
1820
+ }
1821
+ const kp = keyProviders.get(sel.name);
1822
+ if (!kp) {
1823
+ logger.warn("signal.submission.refused", { agentName: sel.name, reason: "key_provider_absent", op: opts.op });
1824
+ return { queued: false, reason: "key_provider_absent",
1825
+ guidance: `${context} no signing key is loaded for '${sel.name}'.` };
1826
+ }
1827
+ try {
1828
+ const composed = await composeSealedSubmission({
1829
+ manifest: verifiedManifest, keyProvider: kp, op: opts.op,
1830
+ subjectKind: opts.subjectKind, subject: opts.subject, body: opts.body,
1831
+ issuedAt: Math.floor(Date.now() / 1000), logger,
1832
+ });
1833
+ if (!composed.ok) {
1834
+ // ERRORS NAME THEIR CAUSE: manifest_unavailable / manifest_expired / intake_key_absent /
1835
+ // intake_key_malformed each say WHICH check refused, and that survives rather than
1836
+ // collapsing into a generic send failure that points at the network.
1837
+ return { queued: false, reason: composed.reason, guidance: `${context} ${composed.guidance}` };
1838
+ }
1839
+ const sent = await sendSealedSubmission({
1840
+ signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
1841
+ submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
1842
+ ciphertext: composed.ciphertext, logger,
1843
+ });
1844
+ if (!sent.ok) {
1845
+ return { queued: false, reason: sent.reason, guidance: `${context} ${sent.guidance ?? sent.reason}` };
1846
+ }
1847
+ // F4: `sendSealedSubmission` ALREADY logs `signal.submission.queued` / `.duplicate`. Logging
1848
+ // `queued` again here doubled every count-based alarm and, worse, emitted `queued` right after
1849
+ // `duplicate` — partially erasing the very distinction the directory's queue repository exists
1850
+ // to preserve. A distinct name, only for what the send layer does not know (which agent, which
1851
+ // op).
1852
+ logger.info("signal.submission.attributed", {
1853
+ agentName: sel.name, op: opts.op, submissionId: composed.submissionId, stored: sent.stored,
1854
+ });
1855
+ // F1: `stored: false` means a node reports it ALREADY HELD this submission id. That is either
1856
+ // a benign retry or single-node censorship — an operator pre-inserting garbage under a
1857
+ // clear-text id — and they are indistinguishable from here. Reporting it as unqualified
1858
+ // success is what makes the attack silent. The warning lives in the SHARED path, not at one
1859
+ // call site, because the refuse verb had it and the issue verb did not: the same omission
1860
+ // would otherwise be available to every verb added after this one.
1861
+ return {
1862
+ queued: true, stored: sent.stored, submissionId: composed.submissionId,
1863
+ ...(sent.stored ? {} : {
1864
+ 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.",
1865
+ }),
1866
+ };
1867
+ }
1868
+ catch (err) {
1869
+ const reason = err instanceof Error ? err.message : String(err);
1870
+ logger.warn("signal.submission.refused", { agentName: sel.name, op: opts.op, reason });
1871
+ return { queued: false, reason, guidance: `${context} ${reason}` };
1872
+ }
1873
+ }
1874
+ /**
1875
+ * M10B / DOD-END-SURFACE-1 — issue a trust signal ABOUT a counterparty.
1876
+ *
1877
+ * NOTE WHAT IS NOT HERE: a `type` parameter, and the word "endorsement" anywhere in the path. The
1878
+ * submission wire carries no type field — the PORTAL decides what it mints from a submission — so
1879
+ * a second client-sourced type needs no new verb, no new parameter and no client change. That is
1880
+ * INV-ZEROBUMP holding by construction rather than by discipline, and it is what
1881
+ * `DOD-END-PLAYBOOK-1` has to prove with an empty diff.
1882
+ *
1883
+ * The subject is the counterparty's K_local pubkey: the only identifier a contact actually holds.
1884
+ * No account identifier crosses the wire — the portal resolves agent → account at intake, and the
1885
+ * directory is hash-only by design.
1886
+ */
1887
+ handlers.set("cello_trust_signals_issue", async (params, connectionId) => {
1888
+ const sel = resolveSelectedAgent(connectionId);
1889
+ if (!sel.ok)
1890
+ return sel;
1891
+ const subject = typeof params?.subject_pubkey === "string" ? params.subject_pubkey.toLowerCase() : "";
1892
+ if (!/^[0-9a-f]{64}$/.test(subject)) {
1893
+ return { ok: false, reason: "invalid_subject",
1894
+ 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." };
1895
+ }
1896
+ const body = typeof params?.body === "string" ? params.body.trim() : "";
1897
+ if (body.length === 0) {
1898
+ return { ok: false, reason: "empty_body",
1899
+ guidance: "An issued signal needs text — it is the claim you are making about them, in your own words." };
1900
+ }
1901
+ // SELF-ISSUANCE IS REFUSED AT THE SOURCE, and across EVERY agent on this daemon — not just the
1902
+ // selected one. The check used to compare against `sel.pubkey` alone, which let an operator
1903
+ // running two of their own agents issue from one about the other and sail through a guard whose
1904
+ // comment claimed certainty. That configuration is not exotic: solo multi-agent is CELLO's first
1905
+ // wedge, so it is the most likely way to hit this, not the least.
1906
+ //
1907
+ // The portal remains the real enforcer of INV-NO-SELF-STANDING — only it can see account
1908
+ // linkage, and only it can catch two agents under one account on different machines. But the
1909
+ // daemon knows its OWN agents with certainty, and refusing here gives the operator a real answer
1910
+ // now instead of a silent rejection at intake minutes later.
1911
+ const localSelf = loadedAgents.find((a) => a.pubkey.toLowerCase() === subject);
1912
+ if (localSelf) {
1913
+ return { ok: false, reason: "self_subject",
1914
+ guidance: localSelf.name === sel.name
1915
+ ? "An agent cannot issue a trust signal about itself — standing has to come from somebody else."
1916
+ : `'${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.` };
1917
+ }
1918
+ const res = await submitForAgent({
1919
+ connectionId,
1920
+ op: "submit", subjectKind: "agent", subject, body,
1921
+ context: "The signal was NOT submitted:",
1922
+ });
1923
+ if (!res.queued)
1924
+ return { ok: false, reason: res.reason, guidance: res.guidance };
1925
+ return {
1926
+ ok: true, queued: true, stored: res.stored, submission_id: res.submissionId,
1927
+ // Deliberately NOT "issued". Nothing is minted yet: the portal must still drain, authenticate,
1928
+ // scan and mint, and the subject must then ACCEPT it before anyone else can see it. Reporting
1929
+ // this as a completed endorsement would promise three steps that have not happened.
1930
+ guidance: res.storedWarning
1931
+ ? `Submitted for '${sel.name}'. ${res.storedWarning}`
1932
+ : `Submitted for '${sel.name}'. The portal will scan and mint it, then the subject must ACCEPT it before it is visible to anyone — a signal they have not accepted is inert. Nothing here is final until they decide.`,
1933
+ };
1934
+ });
1935
+ /**
1936
+ * M10B / DOD-END-SURFACE-1 — per-counterparty presentation choice.
1937
+ *
1938
+ * `present: null` CLEARS the choice rather than setting it false. Those are different states and
1939
+ * the surface must keep them apart: cleared means "no opinion, use the signal's default", false
1940
+ * means "specifically not this person". An operator who could only toggle true/false would be
1941
+ * unable to undo an omission without first knowing what the default had been.
1942
+ */
1943
+ handlers.set("cello_contact_set_signal", async (params, connectionId) => {
1944
+ const sel = resolveSelectedAgent(connectionId);
1945
+ if (!sel.ok)
1946
+ return sel;
1947
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey.toLowerCase() : "";
1948
+ if (!/^[0-9a-f]{64}$/.test(pubkey)) {
1949
+ return { ok: false, reason: "invalid_pubkey", guidance: "pubkey must be the counterparty's 32-byte public key as 64 hex characters." };
1950
+ }
1951
+ const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : "";
1952
+ if (prefix.length < 8) {
1953
+ return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters — see cello_trust_signals_list." };
1954
+ }
1955
+ const present = params?.present === null ? null : typeof params?.present === "boolean" ? params.present : undefined;
1956
+ if (present === undefined) {
1957
+ 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)." };
1958
+ }
1959
+ // Resolve the prefix against signals this agent actually holds, so a typo cannot silently write
1960
+ // a preference about a hash that does not exist and sit there doing nothing forever.
1961
+ const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
1962
+ const match = store.listAllWalletSignals().filter((r) => r.signalHash.startsWith(prefix));
1963
+ if (match.length === 0) {
1964
+ return { ok: false, reason: "signal_not_found", guidance: `No signal in this wallet starts with '${prefix}'.` };
1965
+ }
1966
+ if (match.length > 1) {
1967
+ return { ok: false, reason: "ambiguous_prefix", guidance: `'${prefix}' matches ${match.length} signals — use more characters.` };
1968
+ }
1969
+ sessionNodeManager.setContactSignalPref(sel.name, pubkey, match[0].signalHash, present);
1970
+ return {
1971
+ ok: true, signal_hash: match[0].signalHash, pubkey, present,
1972
+ guidance: present === null
1973
+ ? "Choice cleared — this signal now follows its own default for this contact."
1974
+ : present
1975
+ ? "This signal will be presented to this contact when a session forms, if you have accepted it."
1976
+ : "This signal will NOT be presented to this contact, whatever its default.",
1977
+ };
1978
+ });
1785
1979
  handlers.set("cello_consent_list", async (_params, connectionId) => {
1786
1980
  const sel = resolveSelectedAgent(connectionId);
1787
1981
  if (!sel.ok)
@@ -1878,15 +2072,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
1878
2072
  const message = typeof params?.message === "string" ? params.message.trim() : "";
1879
2073
  if (message.length === 0)
1880
2074
  return { ...refused, message_queued: false };
1881
- // A CAP, because the CLI joins every trailing positional into this string and a paste is
1882
- // unbounded. Without it a multi-megabyte body is CBOR-encoded, signed, sealed, and pushed at the
1883
- // signaling stream, where it dies as `signaling_lost` — a TRANSPORT label for a payload-size
1884
- // bug, which is exactly the error-substitution failure that sends an operator to the wrong
1885
- // subsystem for a day.
1886
- if (message.length > MAX_REFUSAL_MESSAGE_CHARS) {
1887
- return { ...refused, message_queued: false, message_error: "message_too_long",
1888
- guidance: `The refusal is recorded. Your message was NOT sent: it is ${message.length} characters and the limit is ${MAX_REFUSAL_MESSAGE_CHARS}.` };
1889
- }
1890
2075
  // ACCOUNT-SUBJECT ITEMS DO NOT GET A MESSAGE YET, and this is a refusal, not an oversight.
1891
2076
  //
1892
2077
  // `listPendingConsent` scopes with `(subject_kind <> 'agent' OR lower(subject) = ?)`, so EVERY
@@ -1894,81 +2079,35 @@ async function startDaemonHoldingLock(config, singletonLock) {
1894
2079
  // itself is defensible (it is the account's own decision, and any of its agents speaks for it),
1895
2080
  // but the MESSAGE is signed with THIS agent's K_local, so the issuer would receive a signed
1896
2081
  // statement from an agent that was not the subject of anything. Which agent may speak for an
1897
- // account is an open question this milestone has not answered (see the note in
1898
- // trust-signal-store.ts), and signing is not the place to guess at it.
1899
- //
1900
- // So the decision stands and the courtesy is withheld, with the reason named.
2082
+ // account is an open question this milestone has not answered, and signing is not the place to
2083
+ // guess at it. So the decision stands and the courtesy is withheld, with the reason named.
1901
2084
  if (item.subjectKind !== "agent") {
1902
2085
  return { ...refused, message_queued: false, message_error: "account_subject_message_unsupported",
1903
2086
  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.` };
1904
2087
  }
1905
- // F3: the agent must already be ONLINE. `getAgentSignaling` is not a getter — for an agent with
1906
- // no manager it CONSTRUCTS one, which dials and authenticates to the directory immediately and
1907
- // installs an unbounded reconnect loop. Calling it here would silently bring a stopped agent
1908
- // online: the directory would route sessions to it while no standing receiver exists, producing
1909
- // `standing_receiver_unavailable` — and `cello status` would still report it offline.
1910
- if (!onlineAgents.has(sel.name)) {
1911
- return { ...refused, message_queued: false, message_error: "agent_offline",
1912
- guidance: `The refusal is recorded. Your message was NOT sent: agent '${sel.name}' is not started. Run cello_start_agent and refuse again with the same message — re-sending is safe, the submission id is derived from the content.` };
1913
- }
1914
- // It rides the submission queue as the `refuse` op: operator-authored free text scanned at
1915
- // intake exactly like the endorsement body — the same injection surface pointed the other way.
1916
- // The subject of a refusal is the TARGET SIGNAL HASH, as it is for a withdrawal.
1917
- const kp = keyProviders.get(sel.name);
1918
- if (!kp) {
1919
- logger.warn("signal.refusal.refused", { agentName: sel.name, reason: "key_provider_absent" });
1920
- return { ...refused, message_queued: false, message_error: "key_provider_absent",
1921
- guidance: `The refusal is recorded. Your message was NOT sent: no signing key is loaded for '${sel.name}'.` };
1922
- }
1923
- try {
1924
- const composed = await composeSealedSubmission({
1925
- manifest: verifiedManifest, keyProvider: kp, op: "refuse",
1926
- // The subject of a refusal is the TARGET SIGNAL HASH, and `subject_kind` describes what the
1927
- // REFUSED SIGNAL was about — it is carried through from the row rather than hardcoded. A
1928
- // hardcoded "agent" would be a SIGNED field asserting something false for every
1929
- // account-subject refusal, and `subject_kind` is inside the TBS, so the lie would be signed.
1930
- subjectKind: item.subjectKind, subject: item.signalHash, body: message,
1931
- issuedAt: Math.floor(Date.now() / 1000), logger,
1932
- });
1933
- if (!composed.ok) {
1934
- // ERRORS NAME THEIR CAUSE: composeSealedSubmission's refusals (manifest_unavailable,
1935
- // manifest_expired, intake_key_absent, intake_key_malformed) say WHICH check refused, and
1936
- // that reason survives here rather than collapsing into a generic send failure.
1937
- return { ...refused, message_queued: false, message_error: composed.reason,
1938
- guidance: `The refusal is recorded. Your message was NOT sent: ${composed.guidance}` };
1939
- }
1940
- const sent = await sendSealedSubmission({
1941
- signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
1942
- submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
1943
- ciphertext: composed.ciphertext, logger,
1944
- });
1945
- if (!sent.ok) {
1946
- return { ...refused, message_queued: false, message_error: sent.reason,
1947
- guidance: `The refusal is recorded. Your message was NOT sent: ${sent.guidance ?? sent.reason}. Retrying is safe — the submission id is derived from the content.` };
1948
- }
1949
- logger.info("signal.refusal.queued", {
1950
- agentName: sel.name, submissionId: composed.submissionId, stored: sent.stored,
1951
- });
1952
- // `message_queued`, NOT `issuer_notified`. A directory node acked a sealed blob; the portal has
1953
- // not drained it, scanned it, minted it, or delivered anything to the issuer. Claiming the
1954
- // issuer was notified asserts four steps that have not happened.
1955
- //
1956
- // `stored` is carried through rather than collapsed into plain success: it is the ONE signal
1957
- // that separates a benign duplicate from single-node censorship (an operator pre-inserting
1958
- // garbage under a clear-text submission_id), and folding the two together destroys the only
1959
- // information that could ever tell them apart.
1960
- return {
1961
- ...refused, message_queued: true, stored: sent.stored, submission_id: composed.submissionId,
1962
- ...(sent.stored ? {} : { guidance: "The refusal is recorded and your message was accepted by a directory node, but that node reports it already held this submission id. If the issuer never receives it, re-run the refusal — re-sending is safe." }),
1963
- };
1964
- }
1965
- catch (err) {
1966
- // A throw here must not un-refuse anything, and must not read as a clean refusal either.
1967
- const reason = err instanceof Error ? err.message : String(err);
1968
- logger.warn("signal.refusal.refused", { agentName: sel.name, reason });
1969
- return { ...refused, message_queued: false, message_error: reason,
1970
- guidance: `The refusal is recorded. Your message was NOT sent: ${reason}` };
2088
+ // Rides the submission queue as the `refuse` op. The SUBJECT is the target signal hash (as it is
2089
+ // for a withdrawal — both verbs act on an existing signal), and `subject_kind` is carried from
2090
+ // the row rather than hardcoded: it is inside the TBS, so a hardcoded value would be a SIGNED
2091
+ // field asserting something false.
2092
+ const res = await submitForAgent({
2093
+ connectionId,
2094
+ op: "refuse", subjectKind: item.subjectKind, subject: item.signalHash, body: message,
2095
+ context: "The refusal is recorded. Your message was NOT sent:",
2096
+ });
2097
+ if (!res.queued) {
2098
+ return { ...refused, message_queued: false, message_error: res.reason, guidance: res.guidance };
1971
2099
  }
2100
+ // `message_queued`, NOT `issuer_notified`. A directory node acked a sealed blob; the portal has
2101
+ // not drained it, scanned it, minted it, or delivered anything to the issuer.
2102
+ //
2103
+ // `stored` is carried through rather than collapsed into plain success: it is the ONE signal
2104
+ // separating a benign duplicate from single-node censorship (an operator pre-inserting garbage
2105
+ // under a clear-text submission_id), and folding them together destroys the only information
2106
+ // that could ever tell them apart.
2107
+ return {
2108
+ ...refused, message_queued: true, stored: res.stored, submission_id: res.submissionId,
2109
+ ...(res.storedWarning ? { guidance: `The refusal is recorded. ${res.storedWarning}` } : {}),
2110
+ };
1972
2111
  });
1973
2112
  handlers.set("wallet_revoke_signal", async (params, _connectionId) => {
1974
2113
  const hashPrefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;