@cello-protocol/daemon 0.0.187 → 0.0.189

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 (64) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +5 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/contact-pubkey-case.d.ts +65 -0
  5. package/dist/contact-pubkey-case.d.ts.map +1 -0
  6. package/dist/contact-pubkey-case.js +135 -0
  7. package/dist/contact-pubkey-case.js.map +1 -0
  8. package/dist/content-encryption-status.d.ts +14 -0
  9. package/dist/content-encryption-status.d.ts.map +1 -1
  10. package/dist/content-encryption-status.js +36 -0
  11. package/dist/content-encryption-status.js.map +1 -1
  12. package/dist/content-park-client.d.ts +31 -0
  13. package/dist/content-park-client.d.ts.map +1 -1
  14. package/dist/content-park-client.js +89 -4
  15. package/dist/content-park-client.js.map +1 -1
  16. package/dist/content-park.d.ts +1 -0
  17. package/dist/content-park.d.ts.map +1 -1
  18. package/dist/content-park.js +262 -20
  19. package/dist/content-park.js.map +1 -1
  20. package/dist/daemon.js +19 -1
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/inbound-sessions.d.ts.map +1 -1
  23. package/dist/inbound-sessions.js +19 -5
  24. package/dist/inbound-sessions.js.map +1 -1
  25. package/dist/notification-handlers.d.ts.map +1 -1
  26. package/dist/notification-handlers.js +113 -2
  27. package/dist/notification-handlers.js.map +1 -1
  28. package/dist/orphan-triage.d.ts +130 -0
  29. package/dist/orphan-triage.d.ts.map +1 -0
  30. package/dist/orphan-triage.js +207 -0
  31. package/dist/orphan-triage.js.map +1 -0
  32. package/dist/quarantine-framing.d.ts +92 -0
  33. package/dist/quarantine-framing.d.ts.map +1 -0
  34. package/dist/quarantine-framing.js +111 -0
  35. package/dist/quarantine-framing.js.map +1 -0
  36. package/dist/refusal-reasons.d.ts +53 -0
  37. package/dist/refusal-reasons.d.ts.map +1 -1
  38. package/dist/refusal-reasons.js +109 -0
  39. package/dist/refusal-reasons.js.map +1 -1
  40. package/dist/seal-frontier-verify.d.ts.map +1 -1
  41. package/dist/seal-frontier-verify.js +24 -16
  42. package/dist/seal-frontier-verify.js.map +1 -1
  43. package/dist/sealed-leaf-set.d.ts +4 -4
  44. package/dist/sealed-leaf-set.d.ts.map +1 -1
  45. package/dist/sealed-leaf-set.js +11 -18
  46. package/dist/sealed-leaf-set.js.map +1 -1
  47. package/dist/session-content-handlers.d.ts.map +1 -1
  48. package/dist/session-content-handlers.js +92 -18
  49. package/dist/session-content-handlers.js.map +1 -1
  50. package/dist/session-node-manager.d.ts +284 -39
  51. package/dist/session-node-manager.d.ts.map +1 -1
  52. package/dist/session-node-manager.js +2620 -245
  53. package/dist/session-node-manager.js.map +1 -1
  54. package/dist/session-read-handlers.d.ts.map +1 -1
  55. package/dist/session-read-handlers.js +133 -4
  56. package/dist/session-read-handlers.js.map +1 -1
  57. package/dist/session-relay-client.d.ts +0 -7
  58. package/dist/session-relay-client.d.ts.map +1 -1
  59. package/dist/session-relay-client.js +52 -37
  60. package/dist/session-relay-client.js.map +1 -1
  61. package/dist/vocabulary.d.ts.map +1 -1
  62. package/dist/vocabulary.js +1 -0
  63. package/dist/vocabulary.js.map +1 -1
  64. package/package.json +5 -5
@@ -23,12 +23,14 @@
23
23
  import { contentHashFor, resolveContentHashAlg, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
24
24
  import { CAPACITY_REASONS } from "./refusal-reasons.js";
25
25
  import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABEL_MAX, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
26
- import { CONTENT_ENCRYPTION_REASONS, CONTENT_ENCRYPTION_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1, } from "./content-encryption-status.js";
26
+ import { CONTENT_ENCRYPTION_REASONS, CONTENT_ENCRYPTION_GUIDANCE, CONTENT_ENCRYPTION_INBOUND_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1, } from "./content-encryption-status.js";
27
27
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
28
28
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
29
29
  import { ensureIdentitySchema } from "./db-identity-store.js";
30
30
  import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
31
31
  import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
32
+ import { normalizeContactPubkey, foldContactPubkeyCase } from "./contact-pubkey-case.js";
33
+ import { REFUSAL_KINDS } from "./refusal-reasons.js";
32
34
  import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
33
35
  import { ensureTrustSignalSchema } from "./trust-signal-store.js";
34
36
  import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
@@ -36,7 +38,7 @@ import { publishableEndpoint, relayOnlyState } from "./relay-only.js";
36
38
  import { randomUUID, createHash, randomBytes } from "node:crypto";
37
39
  import * as lp from "it-length-prefixed";
38
40
  import { decode } from "cbor-x";
39
- import { encodeCbor } from "@cello-protocol/protocol-types";
41
+ import { encodeCbor, decodeStructure1, encodeStructure1 } from "@cello-protocol/protocol-types";
40
42
  import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
41
43
  import { SessionConnectionGater } from "./session-connection-gater.js";
42
44
  import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
@@ -48,6 +50,7 @@ import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/
48
50
  // receives the already-classified `ParkAuthFailure`, so importing the code table here would invite a
49
51
  // second, drifting copy of the classification logic.
50
52
  import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, parkRefusalGuidance } from "./park-envelope.js";
53
+ import { triageOrphanedContent } from "./orphan-triage.js";
51
54
  import { isValidMultiaddr } from "@cello-protocol/transport";
52
55
  // `LEAF_KIND_MSG` is no longer imported here: `sendContent`'s `leafKind` stopped defaulting to it
53
56
  // (B2b-1 review F4), so this file no longer names a default — every caller states its own kind.
@@ -58,6 +61,7 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
58
61
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
59
62
  import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
60
63
  import { addColumnIfMissing } from "./column-birth.js";
64
+ import { quarantineRedaction, retentionSentence } from "./quarantine-framing.js";
61
65
  import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
62
66
  /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
63
67
  /**
@@ -77,6 +81,25 @@ const MAX_REFUSED_PARKED_ENTRIES = 512;
77
81
  * losing an old one costs a log line rather than correctness.
78
82
  */
79
83
  const MAX_UNREADABLE_ALG_FRAMES = 64;
84
+ /**
85
+ * How many consumers' read positions a single refusal notice remembers.
86
+ *
87
+ * A consumer id is an IPC connection id, so every reconnect mints a new one and the read state would
88
+ * otherwise grow without bound in a durable table. Sixteen is far above the real number of windows
89
+ * attending one agent; past that the OLDEST reader is evicted, which costs at worst one repeated
90
+ * announcement to a window that has already gone.
91
+ */
92
+ const MAX_REFUSAL_READERS = 16;
93
+ /**
94
+ * How many refusal notices one read returns, newest first.
95
+ *
96
+ * Review F3. The store is never emptied for an agent — a refusal records something that happened —
97
+ * and read state is per IPC connection, so a fresh window after a restart is entitled to every
98
+ * notice ever recorded. Uncapped, the answer to "why did this conversation go quiet?" was at the
99
+ * bottom of an archive. Capped and newest-first, the recent cause leads and the caller is TOLD the
100
+ * list was cut (`refusals_incomplete`), rather than the tail vanishing silently.
101
+ */
102
+ const MAX_REFUSALS_PER_READ = 25;
80
103
  /**
81
104
  * How long the first send waits for an in-flight salt agreement before giving up on it —
82
105
  * `DOD-M15-SEALWIRE-1` B2b-2 constraint 2.
@@ -358,6 +381,127 @@ export const REVIVE_RESERVATION_CANDIDATES = 2;
358
381
  * the healthy direct latency and far below anything a person would notice.
359
382
  */
360
383
  export const LEAF_FETCH_GRACE_MS = 2_000;
384
+ /**
385
+ * DOD-M15-REFUSALTERMINAL-1 — the refusal reasons no retry can ever get past.
386
+ *
387
+ * MEASURED LIVE 2026-09-04: one message aimed at a conversation the counterparty had already
388
+ * closed, refused `session_committed` and re-fetched roughly twice a second for 62 hours across
389
+ * several daemon restarts — 232,056 refusal events on that one session and a 484 MB `daemon.log`.
390
+ *
391
+ * **WHY `session_committed` QUALIFIES.** A committed session carries a signature over its contents.
392
+ * Nothing can be appended to it by anyone — not the counterparty, not us — so there is no future
393
+ * state in which this content is accepted. That is the bar, and it is the whole bar.
394
+ *
395
+ * **⚠️ DO NOT ADD A REASON WITHOUT MEETING IT.** A reason wrongly called terminal silently drops a
396
+ * message that would have arrived on the next try, which is worse than the loop this set exists to
397
+ * end. The tempting ones and why each fails:
398
+ *
399
+ * - `content_hash_mismatch` — the fetch is BY CONTENT HASH, and a later fetch may retrieve a
400
+ * correct copy from a different relay. Retrying can succeed.
401
+ * - `sender_unresolved` — the sender may become resolvable when a profile arrives or a directory
402
+ * syncs. Retrying can succeed.
403
+ * - `session_orphaned` — `024-ORPHANTRIAGE` owns that path and decides its disposition.
404
+ * - `session_size_limit_exceeded` — the cap IS monotonic, but its bound is a setting, and an
405
+ * operator who raises it must be able to un-stick the conversation.
406
+ * - a transient screener block — transient is in its name.
407
+ */
408
+ /**
409
+ * ⚠️ NOT the same concept as `session-terminal-refusal.ts` (`DOD-MP-SESSION-RETIRE-1`), which is the
410
+ * RELAY terminally refusing one of OUR SENDS. This set is about INBOUND content this side will
411
+ * never accept. Two "terminal refusal" ideas live in this daemon and they point in opposite
412
+ * directions — review F9.
413
+ */
414
+ export const TERMINAL_REFUSAL_REASONS = new Set(["session_committed"]);
415
+ /**
416
+ * DOD-M15-REFUSALTERMINAL-1 review F3 — how long a FAILED read of the terminal-refusal rows is
417
+ * backed off for, per session.
418
+ *
419
+ * A minute: long enough that a database throwing on every witnessed leaf produces one ERROR rather
420
+ * than one per message (the exact log growth this unit exists to end), short enough that a disk
421
+ * which recovers is noticed within a message or two rather than at the next restart.
422
+ */
423
+ export const TERMINAL_REFUSAL_READ_RETRY_MS = 60_000;
424
+ /**
425
+ * DOD-M15-REFUSALTERMINAL-1 review F7 — how many terminally-refused content hashes are remembered
426
+ * per session.
427
+ *
428
+ * The counterparty chooses how many rows this table gets: one per distinct message aimed at a
429
+ * closed conversation, written even after the byte cap has stopped retaining evidence. 512 matches
430
+ * `MAX_REFUSED_PARKED_ENTRIES`, is far above any honest volume for a conversation that has ENDED,
431
+ * and bounds the table at (sessions × 512) small rows.
432
+ */
433
+ export const MAX_TERMINAL_REFUSALS_PER_SESSION = 512;
434
+ /**
435
+ * The `unusable` reason for a proof that is real and describes some OTHER message.
436
+ *
437
+ * ⚠️ NOT `"content_hash_mismatch"` — review §6. That string is already the refusal reason for the
438
+ * RECEIVER's own recompute failing (`ingestReceivedContent`), which is a tamper signal about the
439
+ * BODY. This one says the sender's signed claim is about different content. Two different failures
440
+ * sharing one name is a collision an operator grepping the log walks straight into.
441
+ */
442
+ const AUTHORSHIP_CONTENT_HASH_MISMATCH = "authorship_hash_mismatch";
443
+ /**
444
+ * The `unusable` reason for a proof that is real, is by the right signer, describes this content —
445
+ * and was signed for a DIFFERENT conversation. A replay, not a forgery.
446
+ *
447
+ * Every one of those properties has been ESTABLISHED by the time this is returned; see the ordering
448
+ * note in `#verifyAuthorshipClaim`. An earlier version of this sentence was true of the intent and
449
+ * not of the code, because the check ran before the signature was verified.
450
+ */
451
+ const AUTHORSHIP_SESSION_MISMATCH = "session_mismatch";
452
+ /**
453
+ * ⚠️ **THE REFUSALS THAT SAY THIS ARE THE ONES WHERE THE REFUSAL DOES NOT HOLD — NOT ALL OF THEM.**
454
+ *
455
+ * It said "EVERY INBOUND REFUSAL SAYS THIS", and review F5 measured that: fifteen call sites file a
456
+ * refusal notice in this file and four carry this sentence — the three encryption causes and the
457
+ * authorship one. The rest MUST NOT. A screened-out message is deliberately never delivered by any
458
+ * route, and a transcript write failure lost content that was already accepted; promising either
459
+ * operator a second chance would be a lie in the opposite direction. Rewritten rather than deleted,
460
+ * because "EVERY" read as a rule and the next person to add a refusal would have applied it blindly.
461
+ *
462
+ * Where it DOES apply: refusing an inbound frame sends back no delivery acknowledgement, so a CELLO
463
+ * sender's TTF backstop parks a copy in the relay mailbox — sealed to this agent's LONG-TERM
464
+ * IDENTITY key, not the session key — and recovery opens that one whatever went wrong with the
465
+ * direct copy.
466
+ *
467
+ * ⚠️ AND ONLY WHEN THIS MACHINE CAN OPEN ONE. See `REFUSAL_NO_OTHER_ROUTE`; the choice is made by
468
+ * `#mailboxRouteAvailable`, never by a caller writing the sentence into a literal.
469
+ */
470
+ const REFUSAL_MAY_STILL_ARRIVE = "IT MAY STILL REACH YOU BY THE OTHER ROUTE: a refusal sends back no acknowledgement, so a CELLO " +
471
+ "counterparty's agent parks a copy in the relay mailbox and this side opens that one with your " +
472
+ "long-term key instead of this session's. If it arrives, it arrives without whatever this check " +
473
+ "was unable to confirm — and if they are not running CELLO, there is no such copy and it will " +
474
+ "not arrive.";
475
+ /**
476
+ * ⚠️ **THE OTHER ROUTE DOES NOT EXIST ON THIS MACHINE, AND SAYING SO IS THE POINT** — review F2.
477
+ *
478
+ * Opening a mailbox copy needs `KeyProvider.openContentSeal`, which is OPTIONAL: a threshold or
479
+ * signing-only provider does not implement it, and an agent loaded without a provider has none at
480
+ * all. `content-park.ts` refuses both — `signing_key_unavailable`, `cannot_unseal`.
481
+ *
482
+ * That is the SAME condition `CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY` reports. So on the one
483
+ * refusal that names a missing local identity, the reassurance above was false: both routes are shut
484
+ * by one cause, permanently, for every message on every session of that agent — and the operator was
485
+ * told to wait for a delivery that cannot happen. That is the H1 defect exactly: a refusal
486
+ * announcing a better outcome than it delivers.
487
+ */
488
+ const REFUSAL_NO_OTHER_ROUTE = "AND IT WILL NOT REACH YOU BY THE OTHER ROUTE EITHER: the relay mailbox copy is opened with this " +
489
+ "agent's long-term identity key, which is the very thing this machine is missing. One cause shuts " +
490
+ "both routes, and it will keep shutting them until the agent is loaded with its identity key. Do " +
491
+ "not wait for this message to turn up.";
492
+ /**
493
+ * Constant-shape byte equality for the two binding checks. Lifted rather than hand-rolled a second
494
+ * time — `seal-frontier-verify` has the same helper for the same comparison, and two copies of
495
+ * "are these the same bytes" is two things to keep true.
496
+ */
497
+ function bytesEqual(a, b) {
498
+ if (a.length !== b.length)
499
+ return false;
500
+ for (let i = 0; i < a.length; i++)
501
+ if (a[i] !== b[i])
502
+ return false;
503
+ return true;
504
+ }
361
505
  /**
362
506
  * The relay's peer id out of a circuit listen address, or `null` if the address does not name one.
363
507
  *
@@ -374,24 +518,18 @@ function relayPeerIdOf(circuitAddr) {
374
518
  * `null` when any leaf is unreadable — the caller must then answer "I cannot judge", never "we
375
519
  * disagree". A decode failure is this daemon's limitation, not evidence against anyone.
376
520
  *
377
- * Canonical Structure 1 is
378
- * `[protocol_version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]`, and the
379
- * content hash is used AS the leaf hash (RFC 6962 §2.1 "hash" leaves are taken as-is), which is the
380
- * domain the certified root lives in.
521
+ * Canonical Structure 1 is `[version, content_hash, sender_pubkey, session_id, last_seen_seq,
522
+ * timestamp]`, plus `last_seen_hash` at index 6 on a v2 claim (020-ACKHASH). The content hash is at
523
+ * index 1 in both and is used AS the leaf hash (RFC 6962 §2.1 "hash" leaves are taken as-is), which
524
+ * is the domain the certified root lives in.
381
525
  */
382
526
  function carryContentHashInputs(carry) {
383
527
  const inputs = [];
384
528
  for (const leaf of carry) {
385
- let contentHash;
386
- try {
387
- contentHash = decode(leaf.structure1Cbor)[1];
388
- }
389
- catch {
390
- return null;
391
- }
392
- if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32)
529
+ const s1 = decodeStructure1(leaf.structure1Cbor);
530
+ if (!s1.ok)
393
531
  return null;
394
- inputs.push({ kind: "hash", data: contentHash });
532
+ inputs.push({ kind: "hash", data: s1.fields.contentHash });
395
533
  }
396
534
  return inputs;
397
535
  }
@@ -894,6 +1032,21 @@ export class SessionNodeManager {
894
1032
  /** In-flight grace timers, keyed session+hash, so a redelivered leaf does not schedule a second
895
1033
  * fetch for the same content — a slow relay must not be turned into a storm against itself. */
896
1034
  #leafFetchTimers = new Map();
1035
+ /**
1036
+ * DOD-M15-REFUSALTERMINAL-1: content hashes refused for a reason no retry can get past — a READ
1037
+ * CACHE over `terminal_content_refusals`, never the record itself.
1038
+ *
1039
+ * ⚠️ **NOT the same fact as `#resolvedContent`, and collapsing them is the trap.** "Resolved"
1040
+ * means we HAVE the content. Terminally refused means we have it and are never accepting it.
1041
+ * Filing one under the other tells the next reader that refused content was delivered.
1042
+ */
1043
+ #terminallyRefused = new Map();
1044
+ /** Sessions whose terminal-refusal rows have been read from the database into the map above.
1045
+ * Nothing ever un-marks content, so a loaded set only grows and can never go stale. */
1046
+ #terminalRefusalsLoaded = new Set();
1047
+ /** DOD-M15-REFUSALTERMINAL-1 review F3: when the load above last FAILED, per session. Bounds the
1048
+ * retry and the ERROR to once a minute instead of once per witnessed leaf. */
1049
+ #terminalRefusalsReadFailedAt = new Map();
897
1050
  /** Test seam: collapse the grace window so a test does not have to wait two real seconds. The
898
1051
  * window itself is covered by its own case. */
899
1052
  #leafFetchGraceMs = LEAF_FETCH_GRACE_MS;
@@ -986,6 +1139,15 @@ export class SessionNodeManager {
986
1139
  // DOD-M12B-ACK-1: WHY a session is impaired and what became of the content. Separate from the
987
1140
  // state above because the state is what surfaces print and this is what they must explain.
988
1141
  #impairmentCause = new Map();
1142
+ /**
1143
+ * DOD-M15-NO-SILENT-REFUSAL-1 review F6 — refusal notices that could NOT be persisted.
1144
+ *
1145
+ * Empty in every healthy daemon. It exists so that a database failure costs the restart property
1146
+ * and nothing else: without it, the operator-facing surface for a refusal disappears entirely the
1147
+ * moment the write fails, which is strictly worse than the in-memory Map this store replaced.
1148
+ * `session.refusal.persist.failed` fires at ERROR on every entry that lands here.
1149
+ */
1150
+ #refusalFallback = new Map();
989
1151
  // DOD-M12B-STRAND-1: sessions whose durable holds have been read back. One read per session per
990
1152
  // process; the Map is the working copy from then on.
991
1153
  #heldRestored = new Set();
@@ -1298,6 +1460,26 @@ export class SessionNodeManager {
1298
1460
  * entry is removed the moment it is reconciled.
1299
1461
  */
1300
1462
  #unreadableAlgSeen = new Map();
1463
+ /**
1464
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` review H1, widened by `029c` review F4 — the content hashes this
1465
+ * side refused ON THE DIRECT PATH, for any reason, so the park path can say so when the same
1466
+ * message arrives the other way.
1467
+ *
1468
+ * One map rather than one per refusal: what the park path needs to know is "did we turn this
1469
+ * content away and tell somebody so", and the reason is already on the notice.
1470
+ *
1471
+ * **The silence this closes.** A direct-path refusal sends no delivery ACK, so the sender's TTF
1472
+ * backstop parks the message and it arrives through the relay mailbox seconds later — where the
1473
+ * ENVELOPE's signature is what authenticates it, and recovery correctly accepts it. So the
1474
+ * message is delivered, with no per-message proof, moments after the operator was told it was
1475
+ * refused. Nothing tied the two events together, which is the same shape the algorithm refusal
1476
+ * above already had and the same remedy.
1477
+ *
1478
+ * Same bounded shape and the same reason: it is fed entirely by a remote party, so losing an
1479
+ * entry costs one reconciliation line and an unbounded map would be a leak with a peer's hand on
1480
+ * the tap.
1481
+ */
1482
+ #refusedOnDirectPath = new Map();
1301
1483
  // DOD-MSG-4 (strict in-order): the RELAY is the ordering authority (Structure 2). For each
1302
1484
  // message the relay witnesses, it delivers B a (content_hash -> canonical sequence) binding via
1303
1485
  // the leaf_deliver stream. B records it here — keyed #k(agent,session) -> (contentHashHex -> seq)
@@ -2085,9 +2267,17 @@ export class SessionNodeManager {
2085
2267
  -- else — which is the whole point of a notarized record.
2086
2268
  --
2087
2269
  -- sender_sig holds one of TWO things, and which one is told by direction:
2088
- -- RECEIVED row -> the Structure-2 signature, stored ONLY after the receiver verified it
2089
- -- against the pubkey inside the sender's own signed bytes
2090
- -- (#recordFrameOrdering). Verified, never claimed.
2270
+ -- RECEIVED row -> the sender's signature over their own Structure-1 bytes, carried on the
2271
+ -- content frame BESIDE those bytes, stored ONLY after the receiver
2272
+ -- verified it against the pubkey inside them (#verifyAuthorshipClaim).
2273
+ -- Verified, never claimed.
2274
+ -- ⚠️ THIS USED TO READ "the Structure-2 signature ... (#recordFrameOrdering)"
2275
+ -- and it named a real place: until DOD-M15-AUTHORSHIP-ABSENT-1 the only
2276
+ -- copy of that signature this side ever saw was the one the RELAY had
2277
+ -- committed at Structure-2 index 3, so a message with no relay record had
2278
+ -- no checkable author at all. Rewritten rather than deleted: an auditor
2279
+ -- reading the old sentence goes looking for Structure-2 bytes that, on a
2280
+ -- relay-degraded message, do not exist.
2091
2281
  -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
2092
2282
  -- from the submit result. Produced, not verified — there was no
2093
2283
  -- counterparty in the act, so it must NEVER be labelled verified_signature.
@@ -2095,11 +2285,16 @@ export class SessionNodeManager {
2095
2285
  -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
2096
2286
  -- Named here because it is the same shape this column exists to prevent, one level up: a
2097
2287
  -- provable sent row and an unprovable one share a label, so a reader keying on attribution
2098
- -- alone cannot tell them apart. An unprovable sent row is legitimate — an UNWITNESSED send
2099
- -- never put a Structure 1 on the wire, so there is nothing signed to store — but the reader
2100
- -- has to be told where the distinction lives, or it will be rediscovered as a bug.
2288
+ -- alone cannot tell them apart.
2101
2289
  -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
2102
- -- self_authored + sender_sig NULL -> we wrote it; the relay never witnessed it
2290
+ -- self_authored + sender_sig NULL -> we wrote it; no proof was stored for this row
2291
+ --
2292
+ -- ⚠️ THE NULL CASE USED TO READ "the relay never witnessed it", and DOD-M15-AUTHORSHIP-ABSENT-1
2293
+ -- made that false. Every content frame now carries this side's signature over its own
2294
+ -- Structure 1 whether or not a relay witnessed the leaf, so an unwitnessed send is provable
2295
+ -- too. Rewritten rather than deleted: the old sentence is why a NULL here was read as
2296
+ -- ordinary. It is not ordinary now — it means this machine could not sign at all, or the row
2297
+ -- came by a path that carries no proof, and both are worth a second look.
2103
2298
  --
2104
2299
  -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
2105
2300
  -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
@@ -2109,11 +2304,42 @@ export class SessionNodeManager {
2109
2304
  -- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
2110
2305
  -- impossible rather than merely discouraged.
2111
2306
  sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
2112
- sender_sig BLOB, -- the VERIFIED Structure-2 signature; NULL unless verified
2307
+ sender_sig BLOB, -- the VERIFIED sender signature over structure1_cbor (see above); NULL unless verified
2113
2308
  attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
2114
2309
  PRIMARY KEY (agent_id, session_id, sequence, direction)
2115
2310
  )
2116
2311
  `);
2312
+ /**
2313
+ * DOD-M15-REFUSEDEVIDENCE-1 — the refusal reason on a QUARANTINED row.
2314
+ *
2315
+ * `direction` takes a third value, `'quarantined'`: a message that was received and REFUSED. It
2316
+ * is stored the same way a delivered one is — plaintext blob, sender key, sender signature,
2317
+ * attribution — because a hash with no original proves nothing, and the messages worth proving
2318
+ * (an injection, a probe, a tampered frame) are exactly the refused ones.
2319
+ *
2320
+ * ⚠️ THE DIRECTION VALUE IS THE FLAG, AND THAT IS WHY IT IS NOT A BOOLEAN COLUMN. `direction` is
2321
+ * in the primary key and every delivery and unread reader already filters it with an equality
2322
+ * literal (`findNextReceivedAfter`, `#UNREAD_RECEIVED_WHERE`, `countReceivedMessages`). A row
2323
+ * written `'quarantined'` therefore cannot be returned by `WHERE direction = 'received'` — it is
2324
+ * excluded BY CONSTRUCTION, with no query edited and none left to remember. A boolean column
2325
+ * alone would have been exclusion by EDIT, which rebuilds `DOD-UNREAD-1 D4a`'s phantom-session
2326
+ * residue the first time a new query forgets the predicate.
2327
+ *
2328
+ * `attribution` needs no new value: the expression in `recordTranscriptMessage` is
2329
+ * `direction === "sent" ? … : authorship ? "verified_signature" : "local_session_state"`, and
2330
+ * `'quarantined'` is not `'sent'` — so a verified frame lands `verified_signature` and an
2331
+ * unverified one `local_session_state`, which is the distinction the column exists for.
2332
+ */
2333
+ // Through `addColumnIfMissing`, not a hand-rolled try/catch — review F7. A bare `ADD COLUMN`
2334
+ // wrapped in a duplicate-name test had already been written twice in this codebase, which is
2335
+ // why the helper exists; a third copy rethrows correctly but emits no `db.column_birth.failed`,
2336
+ // so a failure on a fresh operator's database would name neither the table nor the column. That
2337
+ // is exactly the case — the FIRST run on a new machine — the helper was extracted for.
2338
+ addColumnIfMissing(this.#db, this.#logger, {
2339
+ table: "transcript",
2340
+ column: "quarantine_reason",
2341
+ sql: "ALTER TABLE transcript ADD COLUMN quarantine_reason TEXT",
2342
+ });
2117
2343
  // M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
2118
2344
  // RECEIVED transcript sequence the operator has been shown via cello_receive (delivery marks
2119
2345
  // read — no ack verb). Unread = received transcript rows with sequence > last_delivered_seq.
@@ -2251,6 +2477,181 @@ export class SessionNodeManager {
2251
2477
  noticed_at INTEGER NOT NULL,
2252
2478
  PRIMARY KEY (agent_id, pubkey)
2253
2479
  )
2480
+ `);
2481
+ /**
2482
+ * A PUBLIC KEY IS BYTES; ITS HEX CASE IS NOT PART OF ITS IDENTITY.
2483
+ *
2484
+ * ⚠️ **PLACED HERE FOR TWO ORDERING REASONS, and getting either wrong is a crash at boot.** It
2485
+ * touches all three contact-keyed tables, so it runs after the LAST of them exists
2486
+ * (`contact_rename_notices`, directly above); and the merge it performs on a collision keeps the
2487
+ * more restrictive TIER, which it cannot read until `migrateContactsAddTierMetadata` has added
2488
+ * that column.
2489
+ *
2490
+ * Normalizing the accessors alone would be worse than the bug for anyone who already has a
2491
+ * mixed-case row: the row becomes UNREACHABLE rather than merely wrong, taking its block, its
2492
+ * away message and its pet name with it. Idempotent and silent on a clean database.
2493
+ */
2494
+ foldContactPubkeyCase(this.#db, this.#logger);
2495
+ // DOD-M15-NO-SILENT-REFUSAL-1: refusal notices — one per (agent, session, reason). Written every
2496
+ // time an inbound message is refused; read by cello_receive and by the cello_inbox pull. Modelled
2497
+ // on contact_rename_notices above and keyed the same way, on agent_id (the stable key) — the map
2498
+ // this replaced was keyed on agent_name, a mutable display label, which was its second bug.
2499
+ //
2500
+ // DURABLE because the case this exists for is NOBODY ATTENDING. A notice held only in memory is
2501
+ // lost to a restart and is only ever surfaced to whoever happens to call cello_receive on that
2502
+ // exact session, which is a log line with extra steps.
2503
+ //
2504
+ // `content_refusal_reads` is the part rename notices do not need: they clear on operator action,
2505
+ // these are read non-destructively PER CONSUMER. Two MCP windows attending one agent is ordinary,
2506
+ // and under a single surfaced flag the first reader consumed the notice and the second was told
2507
+ // nothing, permanently.
2508
+ this.#db.exec(`
2509
+ CREATE TABLE IF NOT EXISTS content_refusal_notices (
2510
+ agent_id TEXT NOT NULL,
2511
+ session_id TEXT NOT NULL,
2512
+ reason TEXT NOT NULL,
2513
+ kind TEXT NOT NULL,
2514
+ impact TEXT NOT NULL,
2515
+ guidance TEXT NOT NULL,
2516
+ count INTEGER NOT NULL,
2517
+ first_at INTEGER NOT NULL,
2518
+ last_at INTEGER NOT NULL,
2519
+ PRIMARY KEY (agent_id, session_id, reason)
2520
+ )
2521
+ `);
2522
+ /**
2523
+ * DOD-M15-REFUSALTERMINAL-1 — the lifetime refusal count, which `content_refusal_notices` is
2524
+ * NOT and never was.
2525
+ *
2526
+ * `cello_dismiss` DELETEs the notice row (`dismissContentRefusals`), so `notices.count` restarts
2527
+ * at 1 after every dismissal. That is correct for the notice — the operator said "I know" and
2528
+ * the next announcement should describe what happened since — and it is exactly why the number
2529
+ * shown beside it cannot be described as a lifetime figure. Live on 2026-09-04 an inbox reported
2530
+ * `times: 58` for a refusal that had fired tens of thousands of times.
2531
+ *
2532
+ * A separate table rather than a column, because the two have different lifetimes: this one is
2533
+ * never deleted by anything an operator does. Same key, so the read is one LEFT JOIN.
2534
+ */
2535
+ this.#db.exec(`
2536
+ CREATE TABLE IF NOT EXISTS content_refusal_totals (
2537
+ agent_id TEXT NOT NULL,
2538
+ session_id TEXT NOT NULL,
2539
+ reason TEXT NOT NULL,
2540
+ total INTEGER NOT NULL,
2541
+ first_at INTEGER NOT NULL,
2542
+ last_at INTEGER NOT NULL,
2543
+ -- 1 when this row was SEEDED from an existing notice at upgrade rather than counted from
2544
+ -- the first refusal. Its total is then a LOWER BOUND, not a figure, and the drain reports
2545
+ -- it under a different field name so a reader cannot mistake one for the other.
2546
+ seeded INTEGER NOT NULL DEFAULT 0,
2547
+ PRIMARY KEY (agent_id, session_id, reason)
2548
+ )
2549
+ `);
2550
+ /**
2551
+ * ⚠️ **THE BACKFILL, and without it this unit ships the original lie with the new name on it.**
2552
+ *
2553
+ * Review finding 1. A new table is created EMPTY. Every daemon that already has refusal notices
2554
+ * — including the one that produced this incident, whose notice sat at 58 — would report
2555
+ * `times_since_dismissed: 59` beside a `times_total` of **1**, on the very field the guidance
2556
+ * tells an operator to judge severity by. Smaller than the number it exists to dwarf.
2557
+ *
2558
+ * `count` is the best figure available at upgrade and it is a LOWER BOUND: dismissals before
2559
+ * this build deleted history nothing can recover. So the row is marked `seeded` and reported as
2560
+ * "at least", never as a total. A lower bound is a true statement; `total = 1` is not.
2561
+ *
2562
+ * `INSERT OR IGNORE` makes it idempotent and self-healing — it fills only rows that do not
2563
+ * exist, so a real counted total is never overwritten by a seeded one, and running it at every
2564
+ * boot costs one indexed scan of a table bounded by (sessions × reasons).
2565
+ */
2566
+ /**
2567
+ * ⚠️ **AND `CREATE TABLE IF NOT EXISTS` IS A NO-OP AGAINST A TABLE THAT ALREADY EXISTS** —
2568
+ * review F1b, and it is the same hazard `DOD-M12B-INDEX-1` records for `held_content.origin`
2569
+ * three hundred lines above.
2570
+ *
2571
+ * The table shipped one commit earlier WITHOUT `seeded`, and that build ran on a real daemon to
2572
+ * take this unit's live measurement. On that machine the `CREATE` does nothing, the backfill
2573
+ * below names a column that is not there, and the throw comes out of schema init — **the daemon
2574
+ * does not open at all.** The one machine that most needs the backfill is the one it would have
2575
+ * bricked.
2576
+ */
2577
+ try {
2578
+ this.#db.exec("ALTER TABLE content_refusal_totals ADD COLUMN seeded INTEGER NOT NULL DEFAULT 0");
2579
+ }
2580
+ catch (err) {
2581
+ const msg = err instanceof Error ? err.message : String(err);
2582
+ if (!/duplicate column name/i.test(msg))
2583
+ throw err;
2584
+ }
2585
+ this.#db.exec(`
2586
+ INSERT OR IGNORE INTO content_refusal_totals
2587
+ (agent_id, session_id, reason, total, first_at, last_at, seeded)
2588
+ SELECT agent_id, session_id, reason, count, first_at, last_at, 1
2589
+ FROM content_refusal_notices
2590
+ `);
2591
+ /**
2592
+ * ⚠️ **THE INVARIANT: a lifetime total can never be SMALLER than a since-dismissal count.**
2593
+ * Caught on the live daemon, not by review — the inbox read
2594
+ * `times_since_dismissed: 78, times_total: 12`.
2595
+ *
2596
+ * `INSERT OR IGNORE` above only fills rows that are ABSENT. A row that already exists but began
2597
+ * counting AFTER the notice did — the totals table shipped one commit before `seeded`, so its
2598
+ * rows default to 0 and claim to be exact — is left alone, and then presents a partial tally as
2599
+ * a lifetime figure. Smaller than the number beside it, which is the tell.
2600
+ *
2601
+ * `count` resets on dismissal and `total` does not, so in healthy operation `total >= count`
2602
+ * always. `count > total` therefore means one thing only: this row's total did not start at the
2603
+ * beginning. Repaired to the best floor available and marked `seeded`, because that is what it
2604
+ * is. Runs at every boot — it is also the repair for a totals write that failed while the
2605
+ * notice's succeeded.
2606
+ */
2607
+ this.#db.exec(`
2608
+ UPDATE content_refusal_totals
2609
+ SET total = (SELECT n.count FROM content_refusal_notices n
2610
+ WHERE n.agent_id = content_refusal_totals.agent_id
2611
+ AND n.session_id = content_refusal_totals.session_id
2612
+ AND n.reason = content_refusal_totals.reason),
2613
+ seeded = 1
2614
+ WHERE EXISTS (SELECT 1 FROM content_refusal_notices n
2615
+ WHERE n.agent_id = content_refusal_totals.agent_id
2616
+ AND n.session_id = content_refusal_totals.session_id
2617
+ AND n.reason = content_refusal_totals.reason
2618
+ AND n.count > content_refusal_totals.total)
2619
+ `);
2620
+ /**
2621
+ * DOD-M15-REFUSALTERMINAL-1 — content this agent will never accept, so the daemon stops going
2622
+ * to fetch it.
2623
+ *
2624
+ * **DURABLE BECAUSE THE DEFECT CROSSED RESTARTS.** The 62-hour loop spanned several `cello
2625
+ * login` cycles; a marker held in a `Set` on the manager would have passed every test and
2626
+ * shipped nothing.
2627
+ *
2628
+ * NOT the `'quarantined'` transcript row, which is the natural candidate and does not work: it
2629
+ * is keyed on the BYTES, and the fetch scheduler is keyed on the content hash the sender
2630
+ * committed to. On the two refusals where those provably differ (a tamper, an algorithm we
2631
+ * cannot read) the row cannot answer the question this table is asked.
2632
+ *
2633
+ * Keyed on `agent_id` — the stable key. `agent_name` is a display label.
2634
+ */
2635
+ this.#db.exec(`
2636
+ CREATE TABLE IF NOT EXISTS terminal_content_refusals (
2637
+ agent_id TEXT NOT NULL,
2638
+ session_id TEXT NOT NULL,
2639
+ content_hash TEXT NOT NULL,
2640
+ reason TEXT NOT NULL,
2641
+ marked_at INTEGER NOT NULL,
2642
+ PRIMARY KEY (agent_id, session_id, content_hash)
2643
+ )
2644
+ `);
2645
+ this.#db.exec(`
2646
+ CREATE TABLE IF NOT EXISTS content_refusal_reads (
2647
+ agent_id TEXT NOT NULL,
2648
+ session_id TEXT NOT NULL,
2649
+ reason TEXT NOT NULL,
2650
+ consumer_id TEXT NOT NULL,
2651
+ seen_count INTEGER NOT NULL,
2652
+ seen_at INTEGER NOT NULL,
2653
+ PRIMARY KEY (agent_id, session_id, reason, consumer_id)
2654
+ )
2254
2655
  `);
2255
2656
  // DOD-SETTINGS-1: a daemon-side per-agent settings store for REACHABILITY POLICY (the tier bounds
2256
2657
  // overrides and the per-tier/agent away messages). A generic key-value table on the stable
@@ -2548,17 +2949,18 @@ export class SessionNodeManager {
2548
2949
  return null;
2549
2950
  const reach = new Map();
2550
2951
  for (const leaf of carry) {
2551
- let signedLastSeen = 0;
2552
- try {
2553
- // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
2554
- const raw = decode(leaf.structure1Cbor)[4];
2555
- const n = typeof raw === "bigint" ? Number(raw) : raw;
2556
- if (typeof n === "number" && Number.isFinite(n))
2557
- signedLastSeen = n;
2558
- }
2559
- catch {
2560
- return null; // unreadable: publish no boundary rather than a half-derived one
2561
- }
2952
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp],
2953
+ // plus last_seen_hash at 6 on a v2 claim. `last_seen_seq` is index 4 in both — 020-ACKHASH
2954
+ // APPENDS, so this read did not move. The hash is not consulted here: this derives a
2955
+ // POSITIONAL boundary, which is the job last_seen_seq keeps doing alongside the new field.
2956
+ const s1 = decodeStructure1(leaf.structure1Cbor);
2957
+ // Unreadable, or a layout this build cannot name: publish no boundary rather than a
2958
+ // half-derived one somebody else could have shaped.
2959
+ if (!s1.ok)
2960
+ return null;
2961
+ const signedLastSeen = Number.isFinite(Number(s1.fields.lastSeenSeq))
2962
+ ? Number(s1.fields.lastSeenSeq)
2963
+ : 0;
2562
2964
  const prior = reach.get(leaf.senderPubkeyHex) ?? 0;
2563
2965
  reach.set(leaf.senderPubkeyHex, Math.max(prior, leaf.sequenceNumber, signedLastSeen));
2564
2966
  }
@@ -2583,7 +2985,14 @@ export class SessionNodeManager {
2583
2985
  * lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
2584
2986
  * row is TOTAL content loss and the caller has to know.
2585
2987
  */
2586
- recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId,
2988
+ recordTranscriptMessage(agentName, sessionId, sequence,
2989
+ /**
2990
+ * DOD-M15-REFUSEDEVIDENCE-1 adds `'quarantined'` — received and REFUSED, kept as evidence and
2991
+ * never delivered. It goes through THIS writer rather than a second one so that the attribution
2992
+ * rule, the blob handling and the write-failure logging cannot drift between a delivered message
2993
+ * and a refused one. One store, one writer.
2994
+ */
2995
+ direction, plaintext, correlationId,
2587
2996
  /**
2588
2997
  * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof, when there is one.
2589
2998
  *
@@ -2592,7 +3001,16 @@ export class SessionNodeManager {
2592
3001
  * is written into the row as `attribution = 'local_session_state'`, so a reader can tell a row
2593
3002
  * whose author was proven from one whose author was assumed. That distinction is the bullet.
2594
3003
  */
2595
- authorship) {
3004
+ authorship,
3005
+ /** Required on a `'quarantined'` row and meaningless on any other: WHY it was refused. */
3006
+ quarantineReason,
3007
+ /**
3008
+ * DOD-M15-REFUSEDEVIDENCE-1: the sender's key when there is one but no verified signature to go
3009
+ * with it. A refused frame often has an identified sender and an unusable proof — a tampered
3010
+ * message is still FROM someone — and dropping the key because the signature failed would throw
3011
+ * away the half of the attribution that survived.
3012
+ */
3013
+ senderPubkeyHexOverride) {
2596
3014
  if (!this.#db)
2597
3015
  return false;
2598
3016
  try {
@@ -2600,9 +3018,11 @@ export class SessionNodeManager {
2600
3018
  const blob = Buffer.from(plaintext);
2601
3019
  this.#db
2602
3020
  .prepare(`INSERT OR IGNORE INTO transcript
2603
- (agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution)
2604
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2605
- .run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship ? Buffer.from(authorship.senderPubkey).toString("hex") : null, authorship ? Buffer.from(authorship.senderSig) : null,
3021
+ (agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution, quarantine_reason)
3022
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
3023
+ .run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship
3024
+ ? Buffer.from(authorship.senderPubkey).toString("hex")
3025
+ : senderPubkeyHexOverride ?? null, authorship ? Buffer.from(authorship.senderSig) : null,
2606
3026
  /**
2607
3027
  * THREE values, not two — caught by CELLO_Coder_1 reviewing the first version, and it was
2608
3028
  * the same defect this column exists to prevent, surviving one layer up in the enum.
@@ -2630,7 +3050,7 @@ export class SessionNodeManager {
2630
3050
  * `verified_signature` — someone else wrote it and we checked their key against it.
2631
3051
  * `local_session_state`— someone else wrote it and nobody checked anything.
2632
3052
  */
2633
- direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state");
3053
+ direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state", quarantineReason ?? null);
2634
3054
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
2635
3055
  return true;
2636
3056
  }
@@ -2646,12 +3066,17 @@ export class SessionNodeManager {
2646
3066
  // sentence is corrected rather than kept, because as written it reassured a reader about a
2647
3067
  // safety net that no longer exists. Sent-row failures stay a warning (they only affect the
2648
3068
  // durable readable transcript, not delivery).
2649
- const level = direction === "received" ? "error" : "warn";
3069
+ // A QUARANTINED row that fails to write is an ERROR for the same reason a received one is,
3070
+ // and a different one: nothing else holds these bytes. The message was refused, so it was
3071
+ // never delivered and never acked in a way that brings it back — a failed write here is the
3072
+ // evidence gap this unit exists to close, reopened by a disk fault.
3073
+ const level = direction === "sent" ? "warn" : "error";
2650
3074
  this.#logger[level]("transcript.message.record.failed", {
2651
3075
  sessionId, agentName, sequence, direction,
2652
3076
  reason: err instanceof Error ? err.message : String(err),
2653
3077
  correlationId,
2654
3078
  ...(direction === "received" ? { impact: "content_undeliverable_message_lost" } : {}),
3079
+ ...(direction === "quarantined" ? { impact: "refused_message_not_retained_no_other_copy_exists" } : {}),
2655
3080
  });
2656
3081
  return false;
2657
3082
  }
@@ -2665,7 +3090,7 @@ export class SessionNodeManager {
2665
3090
  if (!this.#db)
2666
3091
  return { messages: [], undecryptable: 0 };
2667
3092
  const rows = this.#db
2668
- .prepare(`SELECT sequence, direction, blob, created_at FROM transcript
3093
+ .prepare(`SELECT sequence, direction, blob, created_at, quarantine_reason FROM transcript
2669
3094
  WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
2670
3095
  .all(this.#requireAgentId(agentName), sessionId);
2671
3096
  const messages = [];
@@ -2674,12 +3099,38 @@ export class SessionNodeManager {
2674
3099
  // already read the field.
2675
3100
  for (const r of rows) {
2676
3101
  const blob = r.blob instanceof Uint8Array ? r.blob : new Uint8Array(r.blob);
2677
- messages.push({
2678
- sequence: r.sequence,
2679
- direction: r.direction === "sent" ? "sent" : "received",
2680
- text: new TextDecoder().decode(blob),
2681
- createdAt: r.created_at,
2682
- });
3102
+ /**
3103
+ * DOD-M15-REFUSEDEVIDENCE-1 — THE READ IS REDACTED, THE STORAGE IS NOT.
3104
+ *
3105
+ * The entry stays at its position, because a hole where a message was is the evidence gap
3106
+ * this unit exists to close, one level up: the operator must be able to see that something
3107
+ * arrived here and was refused. What is withheld is the TEXT, and `text` carries the
3108
+ * withholding statement rather than being omitted — every existing renderer of this array
3109
+ * prints `text`, so a missing field would print nothing and an unfiltered one would print the
3110
+ * payload. The statement is the fail-safe value for both.
3111
+ *
3112
+ * ⚠️ THREE-WAY, not `!== "sent" ? "received"`. The old expression labelled anything that was
3113
+ * not `sent` as `received`, which would have handed a refused message to every reader as a
3114
+ * delivered one — with its text.
3115
+ */
3116
+ const direction = r.direction === "sent" ? "sent" : r.direction === "quarantined" ? "quarantined" : "received";
3117
+ if (direction === "quarantined") {
3118
+ // No `?? "refused"` default — review F11. See `readQuarantined` for why a generic label for
3119
+ // an impossible state is worse than an empty one.
3120
+ const reason = r.quarantine_reason;
3121
+ const redaction = quarantineRedaction(reason, sessionId, r.sequence);
3122
+ messages.push({
3123
+ sequence: r.sequence, direction, createdAt: r.created_at,
3124
+ text: redaction.text,
3125
+ // The key ENDS in `guidance` so `vocabulary.ts` rewrites the verb for a CLI reader — see
3126
+ // the note on `quarantineRedaction`.
3127
+ withheld_guidance: redaction.guidance,
3128
+ refusalReason: reason,
3129
+ withheld: true,
3130
+ });
3131
+ continue;
3132
+ }
3133
+ messages.push({ sequence: r.sequence, direction, text: new TextDecoder().decode(blob), createdAt: r.created_at });
2683
3134
  }
2684
3135
  return { messages, undecryptable: 0 };
2685
3136
  }
@@ -2864,7 +3315,7 @@ export class SessionNodeManager {
2864
3315
  isContact(agentName, pubkey) {
2865
3316
  if (!this.#db)
2866
3317
  return false;
2867
- const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
3318
+ const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), normalizeContactPubkey(pubkey));
2868
3319
  return row !== undefined;
2869
3320
  }
2870
3321
  /** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
@@ -2881,7 +3332,7 @@ export class SessionNodeManager {
2881
3332
  throw new Error(`getTier('${agentName}'): database not initialized`);
2882
3333
  const row = this.#db
2883
3334
  .prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
2884
- .get(this.#requireAgentId(agentName), pubkey);
3335
+ .get(this.#requireAgentId(agentName), normalizeContactPubkey(pubkey));
2885
3336
  if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
2886
3337
  // A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
2887
3338
  // tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
@@ -2942,6 +3393,9 @@ export class SessionNodeManager {
2942
3393
  * at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
2943
3394
  * the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
2944
3395
  addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
3396
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3397
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3398
+ pubkey = normalizeContactPubkey(pubkey);
2945
3399
  if (!pubkey)
2946
3400
  return;
2947
3401
  // Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
@@ -2972,6 +3426,9 @@ export class SessionNodeManager {
2972
3426
  * when no such contact — fail-loud at the caller, never a silent no-op success. Same
2973
3427
  * validate-throw backstop as addContact. */
2974
3428
  setContactMoniker(agentName, pubkey, moniker) {
3429
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3430
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3431
+ pubkey = normalizeContactPubkey(pubkey);
2975
3432
  // Review F2: false means exactly "no such contact" — a null DB handle throws instead, so the
2976
3433
  // operator is never sent chasing a nonexistent missing-contact problem.
2977
3434
  if (!this.#db)
@@ -3001,6 +3458,9 @@ export class SessionNodeManager {
3001
3458
  * contact in order to withhold something from them.
3002
3459
  */
3003
3460
  setContactSignalPref(agentName, pubkey, signalHash, present) {
3461
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3462
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3463
+ pubkey = normalizeContactPubkey(pubkey);
3004
3464
  if (!this.#db)
3005
3465
  throw new Error(`setContactSignalPref('${agentName}'): database not initialized`);
3006
3466
  const agentId = this.#requireAgentId(agentName);
@@ -3031,6 +3491,9 @@ export class SessionNodeManager {
3031
3491
  * the operator's standing default, never to disclosing something consent has not cleared.
3032
3492
  */
3033
3493
  getContactSignalPrefs(agentName, pubkey) {
3494
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3495
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3496
+ pubkey = normalizeContactPubkey(pubkey);
3034
3497
  if (!this.#db)
3035
3498
  return new Map();
3036
3499
  const rows = this.#db
@@ -3041,6 +3504,9 @@ export class SessionNodeManager {
3041
3504
  /** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
3042
3505
  * when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
3043
3506
  setContactAwayMessage(agentName, pubkey, message) {
3507
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3508
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3509
+ pubkey = normalizeContactPubkey(pubkey);
3044
3510
  if (!this.#db)
3045
3511
  throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
3046
3512
  const res = this.#db
@@ -3054,6 +3520,9 @@ export class SessionNodeManager {
3054
3520
  * four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
3055
3521
  * the caller like any content (SI — it does not bypass the gateway). */
3056
3522
  resolveAwayMessage(agentName, pubkey) {
3523
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3524
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3525
+ pubkey = normalizeContactPubkey(pubkey);
3057
3526
  if (!this.#db)
3058
3527
  return null;
3059
3528
  const agentId = this.#requireAgentId(agentName);
@@ -3082,6 +3551,9 @@ export class SessionNodeManager {
3082
3551
  * setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
3083
3552
  * stores whatever it is handed (the handler is the validation boundary). */
3084
3553
  setContactTier(agentName, pubkey, tier) {
3554
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3555
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3556
+ pubkey = normalizeContactPubkey(pubkey);
3085
3557
  if (!this.#db)
3086
3558
  throw new Error(`setContactTier('${agentName}'): database not initialized`);
3087
3559
  const res = this.#db
@@ -3099,6 +3571,9 @@ export class SessionNodeManager {
3099
3571
  * (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
3100
3572
  * detection works only for peers who INITIATE to you — a property, not a bug. */
3101
3573
  recordOfferedMoniker(agentName, pubkey, offered) {
3574
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3575
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3576
+ pubkey = normalizeContactPubkey(pubkey);
3102
3577
  // Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
3103
3578
  // (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
3104
3579
  if (!this.#db)
@@ -3140,6 +3615,9 @@ export class SessionNodeManager {
3140
3615
  /** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
3141
3616
  * contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
3142
3617
  clearRenameNotice(agentName, pubkey) {
3618
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3619
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3620
+ pubkey = normalizeContactPubkey(pubkey);
3143
3621
  if (!this.#db)
3144
3622
  throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
3145
3623
  this.#db
@@ -3148,6 +3626,9 @@ export class SessionNodeManager {
3148
3626
  }
3149
3627
  /** M8C-CONTACT-1: known stays known until explicitly removed. */
3150
3628
  removeContact(agentName, pubkey) {
3629
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3630
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3631
+ pubkey = normalizeContactPubkey(pubkey);
3151
3632
  if (!this.#db)
3152
3633
  return false;
3153
3634
  const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
@@ -3206,6 +3687,9 @@ export class SessionNodeManager {
3206
3687
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
3207
3688
  * and tolerant of a not-yet-open DB (a missing label degrades the doorbell, never blocks it). */
3208
3689
  getContactMoniker(agentName, pubkey) {
3690
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
3691
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
3692
+ pubkey = normalizeContactPubkey(pubkey);
3209
3693
  if (!this.#db) {
3210
3694
  // Review F2: the last fully-silent branch in the resolution chain — the label degrades to
3211
3695
  // fingerprint, which is correct, but say so rather than returning null wordlessly.
@@ -3236,15 +3720,242 @@ export class SessionNodeManager {
3236
3720
  ORDER BY c.added_at ASC`)
3237
3721
  .all(this.#requireAgentId(agentName));
3238
3722
  }
3239
- /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
3723
+ /**
3724
+ * M8C-ABUSE-1: cumulative inbound byte total for a session (anti-drip-feed accounting).
3725
+ *
3726
+ * ⚠️ DOD-M15-REFUSEDEVIDENCE-1 — QUARANTINED BYTES COUNT, and the bound depends on it.
3727
+ *
3728
+ * Retaining refused messages puts real bytes on the operator's disk. Left out of this sum,
3729
+ * retention would be an UNBOUNDED SIDE CHANNEL: a counterparty who can get messages refused —
3730
+ * anyone who can trip the screener, which is anyone — stores against a budget that cannot see
3731
+ * what they spent. Counting them makes total retention per session bounded by the same tier cap
3732
+ * that bounds delivery, which is the bound the unit's no-truncation rule rests on.
3733
+ *
3734
+ * No new capability reaches an attacker from this: the counterparty already spends the session's
3735
+ * byte budget by sending ordinary messages. Spending it with refused ones costs them the same.
3736
+ */
3240
3737
  #getReceivedBytesTotal(agentName, sessionId) {
3241
3738
  if (!this.#db)
3242
3739
  return 0;
3243
3740
  const row = this.#db
3244
- .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
3741
+ .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction IN ('received','quarantined')")
3245
3742
  .get(this.#requireAgentId(agentName), sessionId);
3246
3743
  return row.total;
3247
3744
  }
3745
+ /**
3746
+ * DOD-M15-REFUSEDEVIDENCE-1 — RETAIN a message that was refused. Retention is universal; DELIVERY
3747
+ * is what is withheld.
3748
+ *
3749
+ * Every refusal path that can store calls this. It writes the plaintext, the sender's key, the
3750
+ * sender's signature and the refusal reason into the transcript, flagged `'quarantined'` so it is
3751
+ * excluded by construction from delivery and from unread counts.
3752
+ *
3753
+ * ⚠️ STORING HOSTILE CONTENT IS SAFE; INTERPOLATING IT IS NOT. The blob is a bound parameter and
3754
+ * the database never parses it — SQL injection is not the risk here and must not be defended
3755
+ * against. The risk is on the way OUT, so nothing below puts `content` into a log line, an error
3756
+ * message or a path. The log carries the length and the hash.
3757
+ *
3758
+ * Returns the sequence it was stored at, or `null` when it was not stored — which happens only for
3759
+ * a reason the caller is expected to log.
3760
+ */
3761
+ #quarantineRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts) {
3762
+ const sequence = this.#retainRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts);
3763
+ /**
3764
+ * DOD-M15-REFUSALTERMINAL-1 — **THE FUNNEL, and the reason it lives here.**
3765
+ *
3766
+ * Every refusal that retains evidence passes through this method carrying its reason and its
3767
+ * content hash, so this is the one place where "which reasons stop the work" can be a LIST
3768
+ * rather than a decision copied into seven branches. `TERMINAL_REFUSAL_REASONS` decides; the
3769
+ * six other reasons that reach here — a hash mismatch, an unreadable algorithm, a missing salt,
3770
+ * an unresolved sender, an orphaned session, a terminal screen block — all keep retrying, and
3771
+ * each of them can succeed on a later attempt.
3772
+ *
3773
+ * AFTER the retention, deliberately: the evidence has to exist before anything stops going to
3774
+ * look for the message.
3775
+ */
3776
+ this.#considerTerminalRefusal(agentName, sessionId, contentHashHex, reason);
3777
+ return sequence;
3778
+ }
3779
+ /** DOD-M15-REFUSEDEVIDENCE-1: the retention itself. Reached only through the funnel above. */
3780
+ #retainRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts) {
3781
+ if (!this.#db)
3782
+ return null;
3783
+ try {
3784
+ const agentId = this.#requireAgentId(agentName);
3785
+ /**
3786
+ * ⚠️ **DEDUP FIRST — review F1, and without it retention KILLS THE CONVERSATION IT PROTECTS.**
3787
+ *
3788
+ * Six of the seven retaining exits refuse WITHOUT ACKNOWLEDGING, which is exactly what makes
3789
+ * the sender's daemon redeliver. Each redelivery re-enters here, above the leaf dedup, and
3790
+ * would take a fresh negative sequence — another full copy of the same bytes. The park drain's
3791
+ * own comment measures that loop at *"~120 repeats per message, forever"*.
3792
+ *
3793
+ * **And retained bytes spend the delivery budget** (`#getReceivedBytesTotal` counts them, which
3794
+ * is what makes the bound honest). So a counterparty on a newer build sends ONE message, the
3795
+ * version skew refuses it un-acked, and twenty-five drains later the conversation's 25 MB is
3796
+ * gone — permanently, because the cap does not reset. Honest traffic then hits
3797
+ * `session_size_limit_exceeded` and the daemon tells the operator to start a new conversation.
3798
+ *
3799
+ * The counterbalance, stated properly this time: **evidence and delivery share one monotonic
3800
+ * budget, and when evidence wins the conversation stops working.** Entry 69 claimed there was
3801
+ * nothing to trade off, and this is what that claim was hiding.
3802
+ *
3803
+ * Keyed on (session, reason, bytes) rather than a hash column: SQLite compares BLOBs directly
3804
+ * and short-circuits on length, the candidate set is one session's refusals, and it needs no
3805
+ * schema change. Same bytes refused the same way is ONE piece of evidence — how many times it
3806
+ * arrived is already counted by the refusal notice. The same bytes refused for a DIFFERENT
3807
+ * reason is a different fact and keeps its own row.
3808
+ */
3809
+ const already = this.#db
3810
+ .prepare(`SELECT sequence FROM transcript
3811
+ WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'
3812
+ AND quarantine_reason = ? AND blob = ?`)
3813
+ .get(agentId, sessionId, reason, Buffer.from(content));
3814
+ if (already) {
3815
+ this.#logger.debug("session.content.quarantine.duplicate", {
3816
+ agentName, sessionId, reason, sequence: already.sequence,
3817
+ contentHash: contentHashHex, correlationId: opts.correlationId,
3818
+ });
3819
+ return already.sequence;
3820
+ }
3821
+ /**
3822
+ * THE BOUND. A session at its byte cap retains no more.
3823
+ *
3824
+ * `senderPubkeyHex` is absent exactly when there is no session row or no counterparty
3825
+ * (`session_orphaned`, `sender_unresolved`), so there is no contact to look a tier up on. Those
3826
+ * take the UNKNOWN tier — the tightest bound, and the right one for a sender we cannot name.
3827
+ */
3828
+ const tier = opts.senderPubkeyHex ? this.getTier(agentName, opts.senderPubkeyHex) : TIER.UNKNOWN;
3829
+ const cap = this.resolveTierBound(agentName, tier, "max_bytes");
3830
+ const prior = this.#getReceivedBytesTotal(agentName, sessionId);
3831
+ if (prior + content.length > cap) {
3832
+ this.#logger.warn("session.content.quarantine.skipped", {
3833
+ agentName, sessionId, reason, contentHash: contentHashHex,
3834
+ bytes: content.length, prior, cap, tier,
3835
+ correlationId: opts.correlationId,
3836
+ skipped: "byte_budget_exhausted",
3837
+ impact: "this refused message was NOT retained: the conversation has already spent its storage budget, so there is no evidence of it beyond this line and the refusal notice.",
3838
+ });
3839
+ return null;
3840
+ }
3841
+ /**
3842
+ * WHERE IT SITS.
3843
+ *
3844
+ * A screener block already leafed at its canonical position, and the quarantine row takes that
3845
+ * same sequence so the leaf and the evidence describe one event — DoD 7's leaf index is
3846
+ * untouched by this unit.
3847
+ *
3848
+ * A refusal with NO leaf takes the next NEGATIVE sequence for the session. A leaf position is
3849
+ * never negative, so the two spaces cannot collide, and descending from −1 means two refusals
3850
+ * cannot overwrite each other. This is what lets `session_orphaned` — a session id with no
3851
+ * `sessions` row at all — live in the same table as everything else, which is the whole point
3852
+ * of one store rather than two.
3853
+ */
3854
+ let sequence = opts.canonicalSeq;
3855
+ if (sequence === undefined || sequence < 0) {
3856
+ const low = this.#db
3857
+ .prepare("SELECT MIN(sequence) AS lo FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'")
3858
+ .get(agentId, sessionId);
3859
+ sequence = Math.min(low.lo ?? 0, 0) - 1;
3860
+ }
3861
+ const stored = this.recordTranscriptMessage(agentName, sessionId, sequence, "quarantined", content, opts.correlationId, opts.authorship, reason, opts.senderPubkeyHex ?? null);
3862
+ if (!stored)
3863
+ return null;
3864
+ this.#logger.info("session.content.quarantined", {
3865
+ agentName, sessionId, reason, sequence,
3866
+ contentHash: contentHashHex, bytes: content.length,
3867
+ signature: opts.authorship ? "verified" : "none",
3868
+ correlationId: opts.correlationId,
3869
+ });
3870
+ return sequence;
3871
+ }
3872
+ catch (err) {
3873
+ this.#logger.error("session.content.quarantine.failed", {
3874
+ agentName, sessionId, reason, contentHash: contentHashHex,
3875
+ error: extractErrorMessage(err),
3876
+ impact: "a refused message could not be retained, so nothing holds a copy of it — it cannot be shown to anyone or reported.",
3877
+ });
3878
+ return null;
3879
+ }
3880
+ }
3881
+ /**
3882
+ * DOD-M15-REFUSEDEVIDENCE-1 — retain a message refused OUTSIDE `ingestReceivedContent`.
3883
+ *
3884
+ * Review F6. The park drain terminally-blocks a message that arrived for an already-committed
3885
+ * session, then confirm-deletes the relay copy — the one other route in the tree that discarded
3886
+ * refused content, and the highest-suspicion combination in the product: hostile bytes aimed at a
3887
+ * conversation somebody has already sealed. Shipped guidance now tells every operator that
3888
+ * refused messages are kept, so this is made true rather than the promise narrowed.
3889
+ *
3890
+ * A thin delegate, not a second implementation: the bound, the dedup, the sequence allocation and
3891
+ * the logging are the ones every other refusal uses.
3892
+ */
3893
+ quarantineRefusedInbound(agentName, sessionId, reason, content, contentHashHex, senderPubkeyHex, correlationId) {
3894
+ return this.#quarantineRefusedContent(agentName, sessionId, reason, content, contentHashHex, {
3895
+ senderPubkeyHex, correlationId,
3896
+ });
3897
+ }
3898
+ /**
3899
+ * DOD-M15-REFUSEDEVIDENCE-1: read retained refused messages back — all of a session's, or the one
3900
+ * at `sequence`.
3901
+ *
3902
+ * Returns the RAW payload. Every caller that hands it to a reader must frame it first
3903
+ * (`frameQuarantinedPayload`); nothing else in the tree may read this without doing so.
3904
+ */
3905
+ readQuarantined(agentName, sessionId, sequence) {
3906
+ if (!this.#db)
3907
+ return [];
3908
+ const rows = this.#db
3909
+ .prepare(`SELECT sequence, blob, created_at, sender_pubkey, sender_sig, attribution, quarantine_reason
3910
+ FROM transcript
3911
+ WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'
3912
+ ${sequence === undefined ? "" : "AND sequence = ?"}
3913
+ ORDER BY sequence ASC`)
3914
+ .all(...(sequence === undefined
3915
+ ? [this.#requireAgentId(agentName), sessionId]
3916
+ : [this.#requireAgentId(agentName), sessionId, sequence]));
3917
+ return rows.map((r) => ({
3918
+ sequence: r.sequence,
3919
+ /**
3920
+ * ⚠️ NOT `?? "refused"` — review F11. A generic default here is a label for a state that
3921
+ * cannot occur: the column exists before any `'quarantined'` row can be written and
3922
+ * `#quarantineRefusedContent` always supplies a reason. A default reads to the next maintainer
3923
+ * as a supported case and would quietly stand in for a real bug. If a NULL ever appears, the
3924
+ * empty reason travels to the frame and the caller, which is loud enough to chase.
3925
+ */
3926
+ reason: r.quarantine_reason,
3927
+ content: r.blob instanceof Uint8Array ? r.blob : new Uint8Array(r.blob),
3928
+ senderPubkeyHex: r.sender_pubkey,
3929
+ senderSig: r.sender_sig === null ? null : (r.sender_sig instanceof Uint8Array ? r.sender_sig : new Uint8Array(r.sender_sig)),
3930
+ attribution: r.attribution,
3931
+ createdAt: r.created_at,
3932
+ }));
3933
+ }
3934
+ /** The metadata half of a framed quarantine read — everything known ABOUT the message, none of it
3935
+ * taken from the message. Split out so the framing module never touches the database. */
3936
+ quarantineFrameMeta(agentName, sessionId, rec) {
3937
+ return {
3938
+ reason: rec.reason,
3939
+ senderPubkeyHex: rec.senderPubkeyHex,
3940
+ senderLabel: rec.senderPubkeyHex === null ? null : this.getContactMoniker(agentName, rec.senderPubkeyHex),
3941
+ // `attribution` is the column that exists to answer exactly this, so it is read rather than
3942
+ // inferred from `sender_sig` being non-null — a stored signature that was never checked
3943
+ // against the sender's key would otherwise be reported as VERIFIED.
3944
+ signature: rec.attribution === "verified_signature" ? "VERIFIED" : "NOT SIGNED",
3945
+ sessionId,
3946
+ position: rec.sequence,
3947
+ arrivedAtMs: rec.createdAt,
3948
+ /**
3949
+ * The hash OF THE RETAINED BYTES, recomputed here — not the hash the sender committed to.
3950
+ *
3951
+ * On the highest-value case in the whole unit those two differ ON PURPOSE:
3952
+ * `content_hash_mismatch` means the sender's committed hash does not describe these bytes.
3953
+ * Printing their claim over our bytes would label the payload with a hash it does not have,
3954
+ * which is the one thing a reader would use this line to check.
3955
+ */
3956
+ contentHashHex: Buffer.from(contentHashFor(rec.content, { alg: "sha256", salt: null })).toString("hex"),
3957
+ };
3958
+ }
3248
3959
  /** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
3249
3960
  * buffer for this session — NOT yet committed leaves, but real bytes in memory that would
3250
3961
  * otherwise let multiple held chunks each individually pass the size gate while cumulatively
@@ -3679,6 +4390,21 @@ export class SessionNodeManager {
3679
4390
  #k(agentName, sessionId) {
3680
4391
  return `${agentName}\x1f${sessionId}`;
3681
4392
  }
4393
+ /**
4394
+ * The inverse of `#k`, for the ONE reader that has a key and needs the session id back: the
4395
+ * unpersisted-refusal fallback, which is keyed like every other per-session map but is drained
4396
+ * per AGENT rather than per session.
4397
+ *
4398
+ * Returns null when the key belongs to a different agent. Split on the FIRST separator only —
4399
+ * `agentName` cannot contain 0x1f, so anything after the first one is the session id, and a
4400
+ * greedy split would silently mis-attribute a key rather than reject it.
4401
+ */
4402
+ #unk(key, agentName) {
4403
+ const sep = key.indexOf("\x1f");
4404
+ if (sep < 0)
4405
+ return null;
4406
+ return key.slice(0, sep) === agentName ? key.slice(sep + 1) : null;
4407
+ }
3682
4408
  /**
3683
4409
  * DOD-AGENT-ID-JOINKEY-1: resolve an agent's NAME to its STABLE agent_id. This is the ONE place a
3684
4410
  * name becomes a key, and it is the boundary between the two worlds:
@@ -3944,22 +4670,26 @@ export class SessionNodeManager {
3944
4670
  });
3945
4671
  // DOD-MSG-4 (strict in-order): record the relay-witnessed canonical sequence for the
3946
4672
  // counterparty's MSG leaves. The relay is the ordering authority; structure1_cbor =
3947
- // [1, content_hash(32), sender_pubkey, session_id, last_seen_seq, ts]. The relay sequence
4673
+ // [version, content_hash(32), sender_pubkey, session_id, last_seen_seq, ts] (+ last_seen_hash
4674
+ // at index 6 on a v2 claim — 020-ACKHASH; content_hash stays at 1). The relay sequence
3948
4675
  // is 1-based and global per session; the daemon tree is 0-based — normalize with -1. Only
3949
4676
  // COUNTERPARTY leaves (the ones B will ingest); our own echoed leaf already lands via the
3950
4677
  // send path. The gate (ingestReceivedContent) reads this map to hold out-of-order arrivals.
3951
4678
  if (!frame.authored_by_us && frame.leaf_kind !== LEAF_KIND_CTRL) {
3952
- try {
3953
- const s1 = decode(frame.structure1_cbor);
3954
- const contentHash = s1?.[1];
3955
- if (contentHash instanceof Uint8Array && frame.sequence_number > 0) {
3956
- this.recordWitnessedSequence(agentName, sessionId, Buffer.from(contentHash).toString("hex"), frame.sequence_number - 1);
4679
+ const s1 = decodeStructure1(frame.structure1_cbor);
4680
+ if (s1.ok) {
4681
+ if (frame.sequence_number > 0) {
4682
+ this.recordWitnessedSequence(agentName, sessionId, Buffer.from(s1.fields.contentHash).toString("hex"), frame.sequence_number - 1);
3957
4683
  }
3958
4684
  }
3959
- catch (err) {
4685
+ else {
4686
+ // `structure1Reason`, not `error` — review F6. This is a named refusal code, and putting
4687
+ // it in a field called `error` reads as an exception message to anyone scanning logs.
4688
+ // The old `try` here also wrapped `recordWitnessedSequence`, so a throw from THAT was
4689
+ // reported as a decode failure; the decode no longer throws, and the split is deliberate.
3960
4690
  this.#logger.warn("session.relay.leaf.witness.decode.failed", {
3961
4691
  sessionId,
3962
- error: err instanceof Error ? err.message : String(err),
4692
+ structure1Reason: s1.reason,
3963
4693
  correlationId,
3964
4694
  });
3965
4695
  }
@@ -4476,6 +5206,38 @@ export class SessionNodeManager {
4476
5206
  if (!current)
4477
5207
  return;
4478
5208
  this.#impairmentCause.set(key, { cause: current.cause, retained });
5209
+ /**
5210
+ * ─── DOD-M15-NO-SILENT-REFUSAL-1: the notice fires HERE, and ONLY on `lost` ─────────────────
5211
+ *
5212
+ * ⚠️ **THE FIRST VERSION WROTE IT ON THE IMPAIRMENT TRANSITION, WHICH IS A SUCCESS PATH.**
5213
+ *
5214
+ * A direct send failing is the ORDINARY case when a counterparty is offline: the message is
5215
+ * then parked with the relay and handed over when they come back, which is the leave-a-message
5216
+ * feature working exactly as designed. Writing a notice at that moment told the operator "a
5217
+ * message this side sent did not reach the counterparty" about a message that was in flight and
5218
+ * would arrive — while `cello_send` was simultaneously telling them it was parked. Two surfaces,
5219
+ * opposite stories, same message.
5220
+ *
5221
+ * By this line the outcome is known, and only one of the three is the operator's problem:
5222
+ * - `parked` — with the relay, delivered when they come back. Nothing to tell.
5223
+ * - `durable` — queued here, re-sent automatically. Nothing to tell.
5224
+ * - `lost` — it could not be queued anywhere. It is gone, and only a resend recovers it.
5225
+ *
5226
+ * A FAILED ACK takes none of these branches (it never reaches this method), and that is correct:
5227
+ * an acknowledgement this side owed them going missing costs the counterparty a redelivery, not
5228
+ * the operator a message.
5229
+ *
5230
+ * NOT retracted when the connection recovers, unlike the impairment state it rides on: a
5231
+ * recovered connection does not un-lose a message. That is also why the count is meaningful —
5232
+ * it is the number of messages lost in this conversation, and it only ever grows.
5233
+ */
5234
+ if (retained !== "lost")
5235
+ return;
5236
+ this.noteContentRefusal(agentName, sessionId, "outbound_message_lost", {
5237
+ kind: REFUSAL_KINDS.OUTBOUND,
5238
+ impact: "a message you sent could not be delivered and could not be saved to send later, so it is gone. Nothing was added to the conversation and the other person never saw it. Everything you sent before it is unaffected.",
5239
+ guidance: "Send it again. This is the one case where resending is right — there is no copy of it anywhere, so nothing will deliver it for you. If it keeps happening, the connection to this person is not working: check cello_status for this conversation before sending anything long.",
5240
+ });
4479
5241
  }
4480
5242
  /** DOD-M12B-ACK-1: why this session is impaired, for the surface that has to explain it. Null
4481
5243
  * when it is not impaired — a caller must not narrate a failure that is not current. */
@@ -4859,11 +5621,36 @@ export class SessionNodeManager {
4859
5621
  this.#sessionLiveness.delete(key);
4860
5622
  // M7-UPGRADE-002: drop the auto-acknowledge bookkeeping for a torn-down session.
4861
5623
  this.#contentDesynced.delete(key);
4862
- // DOD-M15-REFUSED-INBOUND-SILENT-1: and the unshown refusals. Bounded (a fixed set of reasons
4863
- // per session) so leaving them was a slow leak rather than a bug — but this list IS the
4864
- // documented teardown set, and a map that is not in it drifts out of everyone's mental model.
4865
- this.#contentRefusals.delete(key);
5624
+ /**
5625
+ * DOD-M15-NO-SILENT-REFUSAL-1 review N2: the UNPERSISTED half IS torn down, and only that half.
5626
+ *
5627
+ * `#refusalFallback` is in memory and restores exactly what the deleted Map did, so it belongs
5628
+ * in the teardown set exactly as that Map did. Leaving it out meant a daemon that could not
5629
+ * write to disk — already in trouble — grew without bound in memory as well. The durable rows
5630
+ * stay, for the reason below.
5631
+ */
5632
+ this.#refusalFallback.delete(key);
5633
+ // DOD-M15-NO-SILENT-REFUSAL-1: the DURABLE notices are NOT torn down here, and the omission is
5634
+ // deliberate — this list is the documented teardown set, so anything absent from it needs a
5635
+ // reason. They live in `content_refusal_notices`, keyed on agent_id + session_id, and the
5636
+ // question they answer ("why did that person stop replying?") is one an operator asks AFTER a
5637
+ // session ends, most sharply for `session_committed` — a refusal that exists only because the
5638
+ // session was already sealed. Dropping them at seal would delete exactly the ones a sealed
5639
+ // session produces. Growth is one row per (session, reason), i.e. proportional to `sessions`.
5640
+ /**
5641
+ * DOD-M15-REFUSALTERMINAL-1 review F7 — named because this list is the documented teardown set.
5642
+ *
5643
+ * `#terminallyRefused` and `#terminalRefusalsLoaded` are a READ CACHE over
5644
+ * `terminal_content_refusals` and are dropped here with everything else in-memory; the durable
5645
+ * rows stay, for the same reason the notices do — the question they answer outlives the
5646
+ * session, and a fresh check reloads them on demand. `#terminalRefusalsReadFailedAt` goes too,
5647
+ * so a torn-down session's back-off does not delay the first read after it is revived.
5648
+ */
5649
+ this.#terminallyRefused.delete(key);
5650
+ this.#terminalRefusalsLoaded.delete(key);
5651
+ this.#terminalRefusalsReadFailedAt.delete(key);
4866
5652
  this.#unreadableAlgSeen.delete(key);
5653
+ this.#refusedOnDirectPath.delete(key);
4867
5654
  this.#responderSealSubmitted.delete(key);
4868
5655
  // DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
4869
5656
  // so a torn-down session retains no stale ordering state or buffered plaintext.
@@ -6644,6 +7431,12 @@ export class SessionNodeManager {
6644
7431
  // the leaf_deliver witness stream / arrival order.
6645
7432
  let orderingS1;
6646
7433
  let orderingS2;
7434
+ /**
7435
+ * `DOD-M15-AUTHORSHIP-ABSENT-1`: the sender's signature over `orderingS1`, when the relay
7436
+ * witnessed this leaf. Undefined when it did not — and the frame builder below then signs a
7437
+ * Structure 1 of its own rather than shipping a frame with nothing to check.
7438
+ */
7439
+ let orderingSig;
6647
7440
  /**
6648
7441
  * DOD-M15-SEALWIRE-1 bullet 5, SENT half. Our own Ed25519 signature over `orderingS1`.
6649
7442
  *
@@ -6672,6 +7465,11 @@ export class SessionNodeManager {
6672
7465
  if (witnessed.ok) {
6673
7466
  orderingS1 = witnessed.structure1_cbor;
6674
7467
  orderingS2 = witnessed.structure2_cbor;
7468
+ // `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature that goes ON THE FRAME beside `orderingS1`.
7469
+ // Captured here, next to the bytes it signs, because a signature assigned anywhere else
7470
+ // could end up beside a different Structure 1 — a proof next to the wrong signed bytes is
7471
+ // worse than no proof, since it looks checkable and fails.
7472
+ orderingSig = witnessed.sender_signature;
6675
7473
  /**
6676
7474
  * PAIRED WITH THE BYTES IT SIGNS, in one place, so the two can never be assigned apart.
6677
7475
  *
@@ -6691,8 +7489,10 @@ export class SessionNodeManager {
6691
7489
  * tell "the relay never witnessed this" from "we witnessed it, held the proof, and
6692
7490
  * dropped it decoding our own bytes."
6693
7491
  *
6694
- * And the asymmetry with the received half is the argument. `#recordFrameOrdering` is
6695
- * soft because the COUNTERPARTY supplied those bytes an absence we cannot resolve.
7492
+ * And the asymmetry with the received half is the argument. The received half is soft
7493
+ * about a missing ORDERING record (`#recordFrameOrdering`) because the COUNTERPARTY
7494
+ * supplied those bytes — an absence we cannot resolve. It is not soft about a missing
7495
+ * authorship proof any more; `DOD-M15-AUTHORSHIP-ABSENT-1` refuses that outright.
6696
7496
  * Here **we produced them**, in `session-relay-client.ts`, moments earlier. A failure
6697
7497
  * means our own encoder and decoder disagree: an internal invariant break that would
6698
7498
  * strip authorship from every sent row for the life of the process. Soft is still right
@@ -6718,10 +7518,14 @@ export class SessionNodeManager {
6718
7518
  });
6719
7519
  };
6720
7520
  try {
6721
- // Structure 1 = [1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
6722
- // the same decode `#recordFrameOrdering` does for the received half, index 2.
6723
- const s1 = decode(witnessed.structure1_cbor);
6724
- const pk = s1[2];
7521
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq,
7522
+ // timestamp] (+ last_seen_hash at 6 on a v2 claim 020-ACKHASH). The sender pubkey is
7523
+ // index 2 in both; the same read `#recordFrameOrdering` does for the received half.
7524
+ const s1Decoded = decodeStructure1(witnessed.structure1_cbor);
7525
+ // NAMED AT ITS CAUSE. A failed decode falling through to the shape check below would
7526
+ // report `pubkey_shape` for bytes that never yielded a pubkey at all, sending the next
7527
+ // reader to audit a key when the layout is what disagreed.
7528
+ const pk = s1Decoded.ok ? s1Decoded.fields.senderPubkey : undefined;
6725
7529
  // The SIGNATURE is length-checked too (review F2): the guard checked the pubkey's 32
6726
7530
  // bytes and only truthiness on the signature, so a zero-length one would have stored an
6727
7531
  // uncheckable BLOB. Not reachable today — `sign()` returns 64 — and the asymmetry is
@@ -6737,7 +7541,7 @@ export class SessionNodeManager {
6737
7541
  * two different submits would all have been persisted as a row that **looks checkable
6738
7542
  * to an auditor and fails** — strictly worse than the honest unproven row it replaced.
6739
7543
  *
6740
- * The received half has always done this (`#recordFrameOrdering` verifies before
7544
+ * The received half has always done this (`#verifyAuthorshipClaim` verifies before
6741
7545
  * storing and treats a failure as fatal). The sent half did not, and every ingredient
6742
7546
  * was already in scope on this line.
6743
7547
  *
@@ -6750,7 +7554,10 @@ export class SessionNodeManager {
6750
7554
  * identity problem. Here it means our own encoder and decoder disagree — bad, but it
6751
7555
  * must not cost the operator a delivered message.
6752
7556
  */
6753
- if (!(pk instanceof Uint8Array) || pk.length !== 32) {
7557
+ if (!s1Decoded.ok) {
7558
+ dropAuthorship("structure1_decode_failed", undefined, { structure1Reason: s1Decoded.reason });
7559
+ }
7560
+ else if (!(pk instanceof Uint8Array) || pk.length !== 32) {
6754
7561
  dropAuthorship("pubkey_shape", undefined, { pubkeyLen: pk instanceof Uint8Array ? pk.length : -1 });
6755
7562
  }
6756
7563
  else if (witnessed.sender_signature.length !== 64) {
@@ -6764,7 +7571,11 @@ export class SessionNodeManager {
6764
7571
  }
6765
7572
  }
6766
7573
  catch (err) {
6767
- dropAuthorship("structure1_decode_failed", err);
7574
+ // NOT a decode failure — review F3. `decodeStructure1` never throws and its failure is
7575
+ // handled as the first branch above, with its own reason. What can still throw in here
7576
+ // is `verify()` and the Buffer work, so this names that instead of sending the reader
7577
+ // to audit a CBOR layout that decoded fine.
7578
+ dropAuthorship("authorship_verify_threw", err);
6768
7579
  }
6769
7580
  }
6770
7581
  // 1-BASED → 0-BASED. The relay numbers the first leaf of a session 1
@@ -6898,6 +7709,72 @@ export class SessionNodeManager {
6898
7709
  // connection). See the note on #handleContentStream's finally.
6899
7710
  let sendStream;
6900
7711
  try {
7712
+ /**
7713
+ * ─── EVERY FRAME CARRIES ITS OWN PROOF — `DOD-M15-AUTHORSHIP-ABSENT-1` ────────────────────
7714
+ *
7715
+ * Structure 1 used to be built and signed INSIDE the relay submit, so a send the relay never
7716
+ * witnessed put a frame on the wire with nothing on it to check — and the receiver, having no
7717
+ * proof to compare, ingested it and attributed it anyway. There was always something to sign;
7718
+ * nobody signed it.
7719
+ *
7720
+ * ⚠️ `structure2_cbor` IS DROPPED WHEN WE BUILD OUR OWN, and that pairing is load-bearing. The
7721
+ * relay's record commits its copy of the sender's signature to the EXACT Structure 1 that was
7722
+ * submitted; put it beside a Structure 1 built here (different timestamp, different
7723
+ * last_seen_seq) and the receiver's cross-check fails against bytes that were never altered —
7724
+ * a freeze on an honest message. The two travel together or the relay's half does not travel.
7725
+ *
7726
+ * ⚠️ SIGNED HERE, BEFORE THE SESSION KEY IS READ, and the order is load-bearing rather than
7727
+ * tidy. `sessionKey` below is read once and used to seal the body several `await`s later; a
7728
+ * key agreed with the counterparty inside that window leaves this side sealing under the key
7729
+ * it captured while the far side has already moved on, and every message is refused as
7730
+ * `decrypt_failed`. Signing costs two awaits, and putting them between the read and the seal
7731
+ * widened that window enough to break the live two-node round trip. Measured, not reasoned
7732
+ * about: seam-3 went red and both daemons logged `session.key.agreed` before the refusal.
7733
+ */
7734
+ let frameS1 = orderingS1;
7735
+ let frameSig = orderingSig;
7736
+ let frameS2 = orderingS2;
7737
+ if (frameS1 === undefined || frameSig === undefined) {
7738
+ const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
7739
+ frameS1 = own.structure1;
7740
+ frameSig = own.signature;
7741
+ frameS2 = undefined;
7742
+ /**
7743
+ * ⚠️ **AND OUR OWN TRANSCRIPT ROW GETS THE PROOF TOO** — review M3.
7744
+ *
7745
+ * `sentAuthorship` is set above only when the relay witnessed the leaf, because that was
7746
+ * the only path that ever produced a signature. This path produces one — and dropping it
7747
+ * here would leave the counterparty's transcript able to prove we wrote the message while
7748
+ * ours recorded `self_authored` with a NULL signature. That is exactly the half-provable
7749
+ * transcript `DOD-M15-SEALWIRE-1` bullet 5 exists to close, reappearing on the one path
7750
+ * that had no proof to lose before this unit and has one now.
7751
+ *
7752
+ * VERIFIED BEFORE IT IS STORED, the same discipline as the witnessed path: the pubkey comes
7753
+ * from INSIDE the bytes we signed, never from an agent lookup, and a pair that does not
7754
+ * verify is dropped loudly rather than persisted as a row that looks checkable and fails.
7755
+ * A failure here means this daemon's own encoder and decoder disagree.
7756
+ */
7757
+ const s1Decoded = decodeStructure1(own.structure1);
7758
+ const pk = s1Decoded.ok ? s1Decoded.fields.senderPubkey : undefined;
7759
+ if (!s1Decoded.ok) {
7760
+ this.#logger.warn("session.sent.authorship.unavailable", {
7761
+ agentName, sessionId, correlationId, reason: "own_structure1_decode_failed",
7762
+ structure1Reason: s1Decoded.reason,
7763
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature, so the row asserts its author rather than proving one — even though this side signed the claim it put on the wire.",
7764
+ guidance: "We produced these bytes ourselves moments ago, so a decode failure here means this daemon's own encoder and decoder disagree. Treat it as an internal invariant break, not a peer problem.",
7765
+ });
7766
+ }
7767
+ else if (!verify(pk, own.structure1, own.signature)) {
7768
+ this.#logger.warn("session.sent.authorship.unavailable", {
7769
+ agentName, sessionId, correlationId, reason: "own_pair_does_not_verify",
7770
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature. The signature this side just produced does not verify against the key inside the bytes it signed.",
7771
+ guidance: "An internal invariant break: the signer and the encoder disagree. The message still went out; only the local proof was dropped.",
7772
+ });
7773
+ }
7774
+ else {
7775
+ sentAuthorship = { senderPubkey: pk, senderSig: own.signature };
7776
+ }
7777
+ }
6901
7778
  /**
6902
7779
  * 🚨 NO KEY, NO DIRECT SEND — `DOD-M15-EPHEMERAL-AUTH-1`, and there is no plaintext fallback.
6903
7780
  *
@@ -6909,11 +7786,12 @@ export class SessionNodeManager {
6909
7786
  * frame and a system that "carries on, degraded" gives up the body while the operator reads a
6910
7787
  * warning they have learned to scroll past. That is why this is a throw and not a warning.
6911
7788
  */
6912
- const encState = this.#contentEncryptionState(agentName, sessionId);
6913
- if (encState.key === null) {
6914
- throw new Error(`content_not_encryptable: ${encState.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[encState.reason]}`);
7789
+ // FAIL FAST, before a stream is opened for a message that cannot go out. The key this reads is
7790
+ // NOT the one that seals the body — see the read beside `sealSessionContent` below.
7791
+ const preflight = this.#contentEncryptionState(agentName, sessionId);
7792
+ if (preflight.key === null) {
7793
+ throw new Error(`content_not_encryptable: ${preflight.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[preflight.reason]}`);
6915
7794
  }
6916
- const sessionKey = encState.key;
6917
7795
  const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
6918
7796
  sendStream = stream;
6919
7797
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
@@ -6940,8 +7818,36 @@ export class SessionNodeManager {
6940
7818
  * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
6941
7819
  * transcript, the seal and the salted hash all depend on it meaning what it means today, and
6942
7820
  * the receiver decrypts before it verifies.
7821
+ *
7822
+ * ⚠️ **THE KEY IS READ HERE, ADJACENT TO THE SEAL, AND IT USED TO BE READ FAR ABOVE.**
7823
+ *
7824
+ * The read sat before `#openContentStream`, so the captured key crossed an `await` — several,
7825
+ * once this unit added signing — before it sealed anything. A session key agreed with the
7826
+ * counterparty inside that window left this side sealing under the key it captured while the
7827
+ * far side had already moved on, and **every message was then refused as `decrypt_failed`**:
7828
+ * a false tamper report on honest content, on both sides, for the life of the session.
7829
+ *
7830
+ * Not theoretical. It is what reddened four live-libp2p fixtures when identity keys were first
7831
+ * wired into them — both daemons logged `session.key.agreed`, and the refusal followed.
7832
+ *
7833
+ * A window cannot be closed by reasoning about who wins it, only by removing it: nothing may
7834
+ * run between the read and the seal. The preflight above stays because failing before a stream
7835
+ * is opened is worth one extra read.
7836
+ *
7837
+ * ⚠️ THIS CLOSES THE LOCAL WINDOW AND NOT THE CLASS (review F8). The sender still seals at T
7838
+ * and the receiver still decrypts at T+flight, so a re-key landing in THAT interval produces
7839
+ * the same false tamper report. What is removed is the part this side controls; the rest is a
7840
+ * property of there being two machines.
6943
7841
  */
6944
- const wireBody = sealSessionContent(sessionKey, content);
7842
+ const sealState = this.#contentEncryptionState(agentName, sessionId);
7843
+ if (sealState.key === null) {
7844
+ // Reachable only if the key vanished between the preflight and here — a re-key or a
7845
+ // teardown mid-send. Named as its own cause rather than reusing the preflight's, so a log
7846
+ // reader can tell "never had one" from "had one and lost it while sending".
7847
+ throw new Error(`content_not_encryptable: ${sealState.reason} — the key was present when this send began ` +
7848
+ `and gone by the time it sealed. ${CONTENT_ENCRYPTION_GUIDANCE[sealState.reason]}`);
7849
+ }
7850
+ const wireBody = sealSessionContent(sealState.key, content);
6945
7851
  const frame = encodeCbor({
6946
7852
  type: "content_frame",
6947
7853
  session_id: sessionId,
@@ -6954,9 +7860,12 @@ export class SessionNodeManager {
6954
7860
  // DOD-MSG-4 (self-ordering): the relay's signed ordering record, so the receiver verifies +
6955
7861
  // orders from the frame ALONE (no dependence on the separate leaf_deliver witness timing).
6956
7862
  // structure1_cbor = sender-signed bytes (verify); structure2_cbor = relay's committed seq +
6957
- // prev_root (order). Omitted if the relay was unreachable — receiver falls back to the witness.
6958
- structure1_cbor: orderingS1,
6959
- structure2_cbor: orderingS2,
7863
+ // prev_root (order). Structure 2 is omitted if the relay was unreachable — the receiver
7864
+ // falls back to the witness stream for POSITION. Structure 1 and its signature are never
7865
+ // omitted: `DOD-M15-AUTHORSHIP-ABSENT-1`, and a frame without them is refused on arrival.
7866
+ structure1_cbor: frameS1,
7867
+ sender_signature: frameSig,
7868
+ structure2_cbor: frameS2,
6960
7869
  // DOD-M15-SEALWIRE-1 part B2b: HOW `content_hash` was produced. An older peer ignores an
6961
7870
  // unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
6962
7871
  // it and verifies under the named algorithm instead of assuming.
@@ -6988,7 +7897,17 @@ export class SessionNodeManager {
6988
7897
  return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
6989
7898
  }
6990
7899
  catch (err) {
6991
- this.#markSessionImpaired(agentName, sessionId, { cause: "direct_send", error: err instanceof Error ? err.message : String(err), correlationId });
7900
+ /**
7901
+ * Review F7: a content-key fault is NOT a transport fault, and labelling it `direct_send`
7902
+ * points the operator at the connection when the connection is fine. `content_not_encryptable`
7903
+ * is thrown twice above — once before the stream is opened, once at the seal — and both are
7904
+ * about this machine's key state.
7905
+ */
7906
+ const failure = err instanceof Error ? err.message : String(err);
7907
+ this.#markSessionImpaired(agentName, sessionId, {
7908
+ cause: failure.startsWith("content_not_encryptable") ? "content_key" : "direct_send",
7909
+ error: failure, correlationId,
7910
+ });
6992
7911
  if (sendStream !== undefined) {
6993
7912
  try {
6994
7913
  sendStream.abort(err instanceof Error ? err : new Error(String(err)));
@@ -7568,15 +8487,39 @@ export class SessionNodeManager {
7568
8487
  byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
7569
8488
  }
7570
8489
  /**
7571
- * ─── DOD-M15-REFUSED-INBOUND-SILENT-1: refusals the RECEIVING operator can actually see ───────
8490
+ * Remember that THIS frame was refused for carrying no usable proof of who wrote it, so the park
8491
+ * path can say so when the same content arrives through the relay mailbox — review H1.
8492
+ *
8493
+ * Bounded for the same reason and by the same cap as `#noteUnreadableAlgFrame`: a peer sending
8494
+ * unprovable frames feeds this map, so it drops the oldest rather than growing.
8495
+ */
8496
+ #noteRefusedOnDirectPath(agentName, sessionId, contentHash) {
8497
+ const key = this.#k(agentName, sessionId);
8498
+ let hashes = this.#refusedOnDirectPath.get(key);
8499
+ if (!hashes) {
8500
+ hashes = new Set();
8501
+ this.#refusedOnDirectPath.set(key, hashes);
8502
+ }
8503
+ if (hashes.size >= MAX_UNREADABLE_ALG_FRAMES) {
8504
+ const oldest = hashes.values().next();
8505
+ if (!oldest.done)
8506
+ hashes.delete(oldest.value);
8507
+ }
8508
+ hashes.add(Buffer.from(contentHash).toString("hex"));
8509
+ }
8510
+ /**
8511
+ * ─── DOD-M15-NO-SILENT-REFUSAL-1: refusals the RECEIVING operator can actually see ────────────
7572
8512
  *
7573
8513
  * Every inbound refusal already logs a `reason`, an `impact` and a `guidance` — and they are
7574
8514
  * good. They had no reader. From the receiving operator's chair a refused message simply never
7575
8515
  * arrives: the conversation goes quiet with a full explanation sitting in a file they have no
7576
8516
  * reason to open, and they conclude the other person stopped replying.
7577
8517
  *
7578
- * `content_hash_alg_unknown` is why this matters more than it sounds. It is a VERSION SKEW, so it
7579
- * affects every message from that counterparty, permanently not a rare one-off.
8518
+ * **DURABLE, and that is the half that makes this useful.** The predecessor kept notices in a
8519
+ * `Map` on this instance and drained them on the receive path for one session. So a restart lost
8520
+ * them, and an agent NOBODY IS ATTENDING lost them too — the connection is live, the daemon is
8521
+ * up, and the notice only ever reaches whoever happens to call `cello_receive` on that exact
8522
+ * session. `cello_check_notifications` now reads them as its own inbox category.
7580
8523
  *
7581
8524
  * **DEDUPLICATED PER SESSION PER REASON, and that is the design, not an optimisation.** A skewed
7582
8525
  * peer turns one problem into a flood: the first refusal of a kind is the signal, the ninetieth is
@@ -7586,62 +8529,186 @@ export class SessionNodeManager {
7586
8529
  * **NEVER carries the content.** It failed verification; surfacing it is the injection path the
7587
8530
  * cross-check exists to close. The operator learns that a message was refused and why — never
7588
8531
  * what it said.
8532
+ */
8533
+ /**
8534
+ * Record an inbound refusal for the operator. First of its kind per session is the signal.
7589
8535
  *
7590
- * In memory, deliberately: a restart re-signalling a still-broken peer is correct behaviour, not
7591
- * duplication, and durability here would buy nothing the next refusal does not.
8536
+ * ⚠️ **DOES NOT THROW, and that is a decision with a cost stated so it is not mistaken for an
8537
+ * oversight.** Every call site here has already decided to refuse and is about to return a reason
8538
+ * to its caller; a throw would replace that clean refusal with an exception on the ingest path,
8539
+ * changing what the SENDER observes because this daemon could not file a note. So a persistence
8540
+ * failure is logged at ERROR under `session.refusal.persist.failed`, carrying the reason, the
8541
+ * impact and the guidance verbatim — the forensic record survives even when the operator-facing
8542
+ * one does not. It is not silent; it is one surface short, and the log says which notice was lost.
7592
8543
  */
8544
+ noteContentRefusal(agentName, sessionId, reason,
7593
8545
  /**
7594
- * (agentName, sessionId) reason the notice. `firstAt` was dropped: it was written and read by
7595
- * nothing, and a field nobody consumes is a claim the code does not keep.
8546
+ * ALL THREE REQUIRED, and that is the enforcement rather than the convention.
8547
+ *
8548
+ * The DoD clause is "every reason calls this with an impact and a guidance", and an optional
8549
+ * field makes that a thing a reviewer checks by reading thirteen call sites. `kind` is required
8550
+ * for the same reason one level up: the header over a list of refusals is composed from it, and
8551
+ * a notice that could omit it would silently inherit whichever header happened to be first.
7596
8552
  */
7597
- #contentRefusals = new Map();
7598
- /** Record an inbound refusal for the operator. First of its kind per session is the signal. */
7599
- noteContentRefusal(agentName, sessionId, reason, detail) {
7600
- const key = this.#k(agentName, sessionId);
7601
- let perSession = this.#contentRefusals.get(key);
7602
- if (!perSession) {
7603
- perSession = new Map();
7604
- this.#contentRefusals.set(key, perSession);
8553
+ detail) {
8554
+ try {
8555
+ if (!this.#db)
8556
+ throw new Error("database is not open");
8557
+ const agentId = this.#requireAgentId(agentName);
8558
+ const now = Date.now();
8559
+ /**
8560
+ * ⚠️ **BOTH WRITES OR NEITHER — review F6, and the comment this replaces was wrong.**
8561
+ *
8562
+ * It argued that putting the totals insert in the same `try` made the two "fail together".
8563
+ * It does not: each `.run()` autocommits, so the notice could persist and the total throw —
8564
+ * and the `catch` then ALSO writes an in-memory fallback entry for a notice that is already
8565
+ * in the table. The drain unions the two halves without deduplicating on (session, reason),
8566
+ * so the operator would see the same refusal TWICE, once with a lifetime figure and once
8567
+ * without, one of them blaming a disk fault. Before this unit a single statement made that
8568
+ * state impossible; the second statement is what created it.
8569
+ *
8570
+ * `ROLLBACK` is best-effort because SQLite may have aborted the transaction already — the
8571
+ * same shape `agent-id-migration.ts` uses — and it must never mask the original error.
8572
+ */
8573
+ this.#db.exec("BEGIN");
8574
+ try {
8575
+ // `count` grows on conflict; impact and guidance are refreshed, because a later refusal of the
8576
+ // same reason may know more than the first (the salt branch has four causes and names them).
8577
+ this.#db
8578
+ .prepare(`INSERT INTO content_refusal_notices
8579
+ (agent_id, session_id, reason, kind, impact, guidance, count, first_at, last_at)
8580
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
8581
+ ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
8582
+ count = count + 1, kind = excluded.kind, impact = excluded.impact,
8583
+ guidance = excluded.guidance, last_at = excluded.last_at`)
8584
+ .run(agentId, sessionId, reason, detail.kind, detail.impact, detail.guidance, now, now);
8585
+ /**
8586
+ * DOD-M15-REFUSALTERMINAL-1 — the lifetime tally, in the SAME `try` on purpose.
8587
+ *
8588
+ * If the notice write failed there is no notice to hang a total off, and the fallback below
8589
+ * has no durable counterpart to read — so the two must fail together. A total that survived a
8590
+ * failed notice would be a number nobody could see, and one that was written twice for a
8591
+ * retried notice would be worse than absent.
8592
+ */
8593
+ this.#db
8594
+ .prepare(
8595
+ // `seeded` stays whatever the row already has. A row seeded at upgrade remains a LOWER
8596
+ // BOUND for the life of that (session, reason) — counting forward from an incomplete
8597
+ // figure does not recover the refusals dismissal already erased, and clearing the flag
8598
+ // would turn "at least 58" into a claimed total of 59.
8599
+ `INSERT INTO content_refusal_totals
8600
+ (agent_id, session_id, reason, total, first_at, last_at, seeded)
8601
+ VALUES (?, ?, ?, 1, ?, ?, 0)
8602
+ ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
8603
+ total = total + 1, last_at = excluded.last_at`)
8604
+ .run(agentId, sessionId, reason, now, now);
8605
+ this.#db.exec("COMMIT");
8606
+ }
8607
+ catch (inner) {
8608
+ try {
8609
+ this.#db.exec("ROLLBACK");
8610
+ }
8611
+ catch { /* already aborted by SQLite */ }
8612
+ throw inner;
8613
+ }
7605
8614
  }
7606
- const existing = perSession.get(reason);
7607
- if (existing) {
7608
- existing.count += 1;
7609
- return;
8615
+ catch (err) {
8616
+ this.#logger.error("session.refusal.persist.failed", {
8617
+ agentName, sessionId, reason,
8618
+ impact: detail.impact,
8619
+ guidance: detail.guidance,
8620
+ error: extractErrorMessage(err),
8621
+ consequence: "this refusal could not be written to the notice store, so it will not survive a restart. It is held in memory for this process instead, so the operator is still told while this daemon runs. The reason, impact and guidance above are the whole notice.",
8622
+ });
8623
+ /**
8624
+ * DOD-M15-NO-SILENT-REFUSAL-1 review F6 — the fallback, and why it is not the silent kind.
8625
+ *
8626
+ * The store this replaced was an in-memory Map, which could not fail: recording a refusal was
8627
+ * a `set`, so the receive door always had the notice for the life of the process. Making the
8628
+ * store durable made it, in the failure case, LESS available than before — a DB write failure
8629
+ * left no operator-facing surface at all, only a log line.
8630
+ *
8631
+ * So a failed write falls back to exactly what the Map did. This is not a fallback that hides
8632
+ * a fault: the ERROR above fires every time, naming the notice and the cause, and what is lost
8633
+ * is only the restart property — which is the property the database was unavailable for
8634
+ * anyway. A silent fallback is one that makes a broken system look healthy; this one is
8635
+ * announced, and it preserves the surface rather than substituting for it.
8636
+ */
8637
+ const key = this.#k(agentName, sessionId);
8638
+ let perSession = this.#refusalFallback.get(key);
8639
+ if (!perSession) {
8640
+ perSession = new Map();
8641
+ this.#refusalFallback.set(key, perSession);
8642
+ }
8643
+ const existing = perSession.get(reason);
8644
+ if (existing) {
8645
+ existing.count += 1;
8646
+ existing.kind = detail.kind;
8647
+ existing.impact = detail.impact;
8648
+ existing.guidance = detail.guidance;
8649
+ return;
8650
+ }
8651
+ perSession.set(reason, { ...detail, count: 1, surfacedTo: new Map() });
7610
8652
  }
7611
- perSession.set(reason, {
7612
- reason,
7613
- impact: detail?.impact,
7614
- guidance: detail?.guidance,
7615
- count: 1,
7616
- // Per CONSUMER, not one global flag. See `takeContentRefusals`.
7617
- surfacedTo: new Map(),
7618
- });
8653
+ }
8654
+ /**
8655
+ * DOD-M15-NO-SILENT-REFUSAL-1: the operator has seen these and does not want to see them again.
8656
+ *
8657
+ * ⚠️ **WITHOUT THIS THE NOTICES ARE PERMANENT, and that is what makes people stop reading the
8658
+ * inbox.** "Already shown you" is tracked per WINDOW — a new MCP connection has been told nothing,
8659
+ * so it is told everything. Someone on an older build messages you, you sort it out with them,
8660
+ * they upgrade, and every new session you ever open still opens with that refusal.
8661
+ *
8662
+ * Dismissing does NOT turn anything off. If the cause fires again the notice comes back, because
8663
+ * a fresh refusal writes a fresh row. The operator is saying "I know", not "stop telling me".
8664
+ *
8665
+ * Returns how many were cleared, so the caller can say so rather than claiming a silent success.
8666
+ */
8667
+ dismissContentRefusals(agentName, sessionId) {
8668
+ if (!this.#db)
8669
+ return 0;
8670
+ let agentId;
8671
+ try {
8672
+ agentId = this.#requireAgentId(agentName);
8673
+ }
8674
+ catch {
8675
+ return 0;
8676
+ }
8677
+ this.#refusalFallback.delete(this.#k(agentName, sessionId));
8678
+ const res = this.#db
8679
+ .prepare("DELETE FROM content_refusal_notices WHERE agent_id = ? AND session_id = ?")
8680
+ .run(agentId, sessionId);
8681
+ this.#db
8682
+ .prepare("DELETE FROM content_refusal_reads WHERE agent_id = ? AND session_id = ?")
8683
+ .run(agentId, sessionId);
8684
+ return Number(res.changes);
7619
8685
  }
7620
8686
  /**
7621
8687
  * Drain the refusals a GIVEN CONSUMER has not been shown yet, and remember what it was shown.
7622
8688
  *
7623
- * ─── Why this is keyed by connection, and not by a single flag ─────────────────────────────────
8689
+ * ─── Why this is keyed by consumer, and not by a single flag ──────────────────────────────────
7624
8690
  *
7625
8691
  * It used to set one `surfaced: boolean` on the notice. Two MCP windows attending the same agent
7626
8692
  * is the ordinary case, and under that flag whoever read FIRST consumed the notice — the second
7627
8693
  * window was told nothing, permanently. **That is the same defect `takeReceivedContent` had**, and
7628
8694
  * the comment above the delivery loop in `session-content-handlers.ts` spells out why it was
7629
8695
  * removed: *"reading is non-destructive by construction. Nothing one consumer does mutates state
7630
- * another consumer reads."* The whole `taken_by_sibling` apparatus exists because this was paid
7631
- * for once already; re-introducing it on a different surface makes it no less true.
8696
+ * another consumer reads."*
7632
8697
  *
7633
- * ─── Why the count now has a reader ────────────────────────────────────────────────────────────
8698
+ * ─── Why the count has a reader ───────────────────────────────────────────────────────────────
7634
8699
  *
7635
- * The old docstring claimed *"count still grows underneath, so a later reader can ask how many
7636
- * without being told again."* **There was no later reader.** After the first surfacing the count
7637
- * incremented under a flag the drain skipped unconditionally, so 3 refusals became 903 and nothing
7638
- * anywhere could say sowhile the comment asserted the opposite.
8700
+ * A reason RE-ANNOUNCES to a consumer when its count has grown by an order of magnitude since that
8701
+ * consumer last saw it (1 10 100 …), marked `repeat: true`. That keeps the first refusal the
8702
+ * signal and the ninetieth silent, which is the dedup's point, while still making a skew that has
8703
+ * swallowed hundreds of messages visible at a handful of announcements per session, not one per
8704
+ * message.
7639
8705
  *
7640
- * So a reason RE-ANNOUNCES to a consumer when its count has grown by an order of magnitude since
7641
- * that consumer last saw it (1 → 10 → 100 → …), marked `repeat: true`. That keeps the first
7642
- * refusal the signal and the ninetieth silent, which is the dedup's point, while still making a
7643
- * skew that has swallowed hundreds of messages visible at a handful of announcements per
7644
- * session, not one per message.
8706
+ * ─── What a restart does, deliberately ────────────────────────────────────────────────────────
8707
+ *
8708
+ * The notices survive; the read state is keyed by IPC connection id, which does not. So after a
8709
+ * restart every notice is unseen again and the next reader is told. That is the correct direction:
8710
+ * a fresh window has not been told anything, and re-announcing costs one line where staying silent
8711
+ * costs the whole point of storing it.
7645
8712
  */
7646
8713
  takeContentRefusals(agentName, sessionId,
7647
8714
  /**
@@ -7654,26 +8721,263 @@ export class SessionNodeManager {
7654
8721
  * forget it is.
7655
8722
  */
7656
8723
  consumerId) {
7657
- const perSession = this.#contentRefusals.get(this.#k(agentName, sessionId));
7658
- if (!perSession)
7659
- return [];
8724
+ // `sessionId` is dropped from each entry: the caller passed it in and every entry carries the
8725
+ // same one, so repeating it back would be a field that can never say anything. The agent-wide
8726
+ // door below keeps it, because there it is the only thing that says WHICH conversation.
8727
+ //
8728
+ // `truncated` is dropped too, and only here: one session's notices cannot reach the cap (the
8729
+ // reasons are a bounded set), so a flag that can never be true is a field readers learn to skip.
8730
+ return this.#drainRefusals(agentName, consumerId, sessionId).notices.map(({ sessionId: _drop, ...rest }) => rest);
8731
+ }
8732
+ /**
8733
+ * DOD-M15-NO-SILENT-REFUSAL-1: every unshown refusal for an agent, ACROSS its sessions.
8734
+ *
8735
+ * The inbox's door. `takeContentRefusals` answers for one session because its caller already holds
8736
+ * one; `cello_check_notifications` holds an agent and nothing else, and the case this whole line
8737
+ * exists for is that nobody is attending any of that agent's sessions — so a per-session read
8738
+ * cannot reach it. Same store, same per-consumer rule, same re-announce.
8739
+ */
8740
+ takeAgentContentRefusals(agentName, consumerId) {
8741
+ return this.#drainRefusals(agentName, consumerId);
8742
+ }
8743
+ /**
8744
+ * The one read path behind both doors. `sessionId` narrows it; omitted, it spans the agent.
8745
+ *
8746
+ * A single implementation on purpose: the per-consumer rule and the order-of-magnitude rule are
8747
+ * the two properties this unit must not lose, and two copies of them is two things to keep true.
8748
+ */
8749
+ #drainRefusals(agentName, consumerId, sessionId) {
7660
8750
  const out = [];
7661
- for (const notice of perSession.values()) {
7662
- const shownAt = notice.surfacedTo.get(consumerId);
7663
- const firstTime = shownAt === undefined;
7664
- // `shownAt` is at least 1 whenever it is set, so this cannot loop on zero.
7665
- if (!firstTime && notice.count < shownAt * 10)
8751
+ let truncated = false;
8752
+ const now = Date.now();
8753
+ let agentId = null;
8754
+ if (this.#db) {
8755
+ try {
8756
+ agentId = this.#requireAgentId(agentName);
8757
+ }
8758
+ catch {
8759
+ // A name that resolves to no active agent has no notices by construction. Already logged by
8760
+ // #requireAgentId; re-throwing would fail a read that has nothing to report.
8761
+ agentId = null;
8762
+ }
8763
+ }
8764
+ if (agentId !== null && this.#db) {
8765
+ /**
8766
+ * ⚠️ NEWEST FIRST, AND CAPPED — review F3, and the ordering is the load-bearing half.
8767
+ *
8768
+ * Read state is keyed on IPC connection id, so every new window and every restart is a
8769
+ * consumer that has been told nothing, and the drain returns every notice ever recorded for
8770
+ * that agent. Oldest-first and uncapped, the first thing an operator saw after a restart was a
8771
+ * chronological archive whose top entry was the oldest refusal on record and whose newest —
8772
+ * the one explaining the conversation that just went quiet — was at the bottom. That is a
8773
+ * section people learn to scroll past, which is the failure this whole line exists to end.
8774
+ *
8775
+ * `LIMIT` is `+ 1` so the cap can be DETECTED rather than assumed; the extra row is dropped
8776
+ * and `truncated` is reported to the caller, which says so on the list itself.
8777
+ *
8778
+ * ⚠️ `rowid DESC` is the TIEBREAK and it is load-bearing, not tidiness. `last_at` is
8779
+ * `Date.now()`, so notices recorded in the same millisecond have no defined order and "newest
8780
+ * first" was true only on average — measured by a test that recorded 30 notices in one tick
8781
+ * and got them back in an order SQLite was free to choose. rowid is monotonic per insert, so
8782
+ * the tiebreak is insertion order, which for a same-millisecond batch is exactly recency.
8783
+ */
8784
+ const rows = (sessionId === undefined
8785
+ ? this.#db
8786
+ .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count,
8787
+ t.total AS lifetime_total, t.seeded AS lifetime_seeded
8788
+ FROM content_refusal_notices n
8789
+ LEFT JOIN content_refusal_reads r
8790
+ ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8791
+ AND r.reason = n.reason AND r.consumer_id = ?
8792
+ LEFT JOIN content_refusal_totals t
8793
+ ON t.agent_id = n.agent_id AND t.session_id = n.session_id
8794
+ AND t.reason = n.reason
8795
+ WHERE n.agent_id = ?
8796
+ AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8797
+ ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
8798
+ .all(consumerId, agentId, MAX_REFUSALS_PER_READ + 1)
8799
+ : this.#db
8800
+ .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count,
8801
+ t.total AS lifetime_total, t.seeded AS lifetime_seeded
8802
+ FROM content_refusal_notices n
8803
+ LEFT JOIN content_refusal_reads r
8804
+ ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8805
+ AND r.reason = n.reason AND r.consumer_id = ?
8806
+ LEFT JOIN content_refusal_totals t
8807
+ ON t.agent_id = n.agent_id AND t.session_id = n.session_id
8808
+ AND t.reason = n.reason
8809
+ WHERE n.agent_id = ? AND n.session_id = ?
8810
+ AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8811
+ ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
8812
+ .all(consumerId, agentId, sessionId, MAX_REFUSALS_PER_READ + 1));
8813
+ if (rows.length > MAX_REFUSALS_PER_READ) {
8814
+ truncated = true;
8815
+ rows.length = MAX_REFUSALS_PER_READ;
8816
+ }
8817
+ for (const row of rows) {
8818
+ const firstTime = row.seen_count === null;
8819
+ /**
8820
+ * ⚠️ The unseen test is IN THE QUERY (review N4), and this line is a belt, not the gate.
8821
+ *
8822
+ * Applied only here, the `LIMIT` cut the newest 25 notices and THEN discarded the ones this
8823
+ * consumer had already seen — so a consumer holding read rows for the newest 25 got an empty
8824
+ * answer forever and a genuinely unseen notice at position 26 could never be reached. The
8825
+ * cap has to cut UNSHOWN notices, which means the filter has to run before it.
8826
+ *
8827
+ * `seen_count` is at least 1 whenever it is set, so this cannot loop on zero.
8828
+ */
8829
+ if (!firstTime && row.count < row.seen_count * 10)
8830
+ continue;
8831
+ this.#db
8832
+ .prepare(`INSERT INTO content_refusal_reads
8833
+ (agent_id, session_id, reason, consumer_id, seen_count, seen_at)
8834
+ VALUES (?, ?, ?, ?, ?, ?)
8835
+ ON CONFLICT(agent_id, session_id, reason, consumer_id) DO UPDATE SET
8836
+ seen_count = excluded.seen_count, seen_at = excluded.seen_at`)
8837
+ .run(agentId, row.session_id, row.reason, consumerId, row.count, now);
8838
+ this.#evictOldestReads(agentId, row.session_id, row.reason);
8839
+ out.push({
8840
+ sessionId: row.session_id,
8841
+ reason: row.reason,
8842
+ kind: row.kind,
8843
+ impact: row.impact,
8844
+ guidance: row.guidance,
8845
+ timesSinceDismissed: row.count,
8846
+ /**
8847
+ * DOD-M15-REFUSALTERMINAL-1 — three states, and they are three different claims.
8848
+ *
8849
+ * A counted total is a FIGURE. A seeded row is a FLOOR, and says so by using a different
8850
+ * field name (review F1c). `null` means no totals row at all, which after the upgrade
8851
+ * backfill can only happen when the notice write itself failed — reported as ABSENT
8852
+ * rather than as the smaller number, because substituting it is the defect this unit
8853
+ * exists to remove.
8854
+ */
8855
+ ...(row.lifetime_total === null
8856
+ ? {}
8857
+ : row.lifetime_seeded === 1
8858
+ ? { timesTotalAtLeast: row.lifetime_total }
8859
+ : { timesTotal: row.lifetime_total }),
8860
+ ...(firstTime ? {} : { repeat: true }),
8861
+ });
8862
+ }
8863
+ }
8864
+ /**
8865
+ * The unpersisted notices (review F6), under the SAME per-consumer and order-of-magnitude rules.
8866
+ * Reading them differently would make a database failure change WHAT the operator is told rather
8867
+ * than only how long it survives — and that difference is the thing hardest to notice.
8868
+ *
8869
+ * Collected separately and REVERSED before joining, not appended in place — review N2. A Map
8870
+ * yields insertion order, which is oldest-first, so appending them straight after the DB half
8871
+ * (which is newest-first) put the newest notices at the bottom on exactly the daemon where the
8872
+ * fallback is the only half there is.
8873
+ */
8874
+ const fromFallback = [];
8875
+ for (const [key, perSession] of this.#refusalFallback) {
8876
+ const sid = this.#unk(key, agentName);
8877
+ if (sid === null)
7666
8878
  continue;
7667
- notice.surfacedTo.set(consumerId, notice.count);
7668
- out.push({
7669
- reason: notice.reason,
7670
- impact: notice.impact,
7671
- guidance: notice.guidance,
7672
- count: notice.count,
7673
- ...(firstTime ? {} : { repeat: true }),
7674
- });
8879
+ if (sessionId !== undefined && sid !== sessionId)
8880
+ continue;
8881
+ for (const [reason, notice] of perSession) {
8882
+ const shownAt = notice.surfacedTo.get(consumerId);
8883
+ const firstTime = shownAt === undefined;
8884
+ if (!firstTime && notice.count < shownAt * 10)
8885
+ continue;
8886
+ notice.surfacedTo.set(consumerId, notice.count);
8887
+ // Bounded like the table's read rows are (review N2): a consumer id is an IPC connection id,
8888
+ // so without this a long-running daemon grows one entry per reconnect, in memory, on the
8889
+ // very path that exists because the disk is already failing.
8890
+ if (notice.surfacedTo.size > MAX_REFUSAL_READERS) {
8891
+ const oldest = notice.surfacedTo.keys().next();
8892
+ if (!oldest.done)
8893
+ notice.surfacedTo.delete(oldest.value);
8894
+ }
8895
+ // `timesTotal` is deliberately absent: this notice exists because the durable write failed,
8896
+ // so there is no lifetime record to report and inventing one from `notice.count` would
8897
+ // restore the exact misreading this unit removes.
8898
+ fromFallback.push({
8899
+ sessionId: sid, reason, kind: notice.kind, impact: notice.impact,
8900
+ guidance: notice.guidance, timesSinceDismissed: notice.count,
8901
+ ...(firstTime ? {} : { repeat: true }),
8902
+ });
8903
+ }
8904
+ }
8905
+ out.push(...fromFallback.reverse());
8906
+ /**
8907
+ * ONE cap over BOTH halves — review N2.
8908
+ *
8909
+ * `LIMIT` governs the table only. A persistent database fault (a full disk, which is also the
8910
+ * likeliest cause of `transcript_write_failed`) routes EVERY refusal to the fallback, so the cap
8911
+ * this unit added was undone for exactly the daemon already in trouble.
8912
+ *
8913
+ * The truncation keeps the DB half preferentially, and that is the right bias: those rows are
8914
+ * the ones that survive a restart, and they are already ordered newest-first.
8915
+ */
8916
+ if (out.length > MAX_REFUSALS_PER_READ) {
8917
+ truncated = true;
8918
+ out.length = MAX_REFUSALS_PER_READ;
7675
8919
  }
7676
- return out;
8920
+ return { notices: out, truncated };
8921
+ }
8922
+ #evictOldestReads(agentId, sessionId, reason) {
8923
+ if (!this.#db)
8924
+ return;
8925
+ this.#db
8926
+ .prepare(`DELETE FROM content_refusal_reads
8927
+ WHERE agent_id = ? AND session_id = ? AND reason = ? AND consumer_id NOT IN (
8928
+ SELECT consumer_id FROM content_refusal_reads
8929
+ WHERE agent_id = ? AND session_id = ? AND reason = ?
8930
+ ORDER BY seen_at DESC LIMIT ${MAX_REFUSAL_READERS}
8931
+ )`)
8932
+ .run(agentId, sessionId, reason, agentId, sessionId, reason);
8933
+ }
8934
+ /**
8935
+ * DOD-M15-NO-SILENT-REFUSAL-1 — the per-session byte cap, from the operator's chair.
8936
+ *
8937
+ * This is the harshest refusal on the inbound path and the one that reads least like a fault:
8938
+ * once the cap is crossed, EVERY later message from that sender on that session is refused, for
8939
+ * the life of the session. The counterparty is told nothing either, so from both chairs the other
8940
+ * person simply stopped replying.
8941
+ *
8942
+ * One method rather than two copies because the cap is checked twice — once before the screening
8943
+ * await and once after, against freshly-read totals — and a notice that differs between the two
8944
+ * would describe a different refusal depending on timing.
8945
+ */
8946
+ /**
8947
+ * DOD-M15-REFUSEDEVIDENCE-1 — **THE BYTE CAP RETAINS NOTHING, and that is a ruling, not an
8948
+ * oversight.**
8949
+ *
8950
+ * Retention is universal everywhere else in this method. Here it is not, because retaining would
8951
+ * defeat the very bound it enforces: a session already over its storage budget cannot be given
8952
+ * more storage as a reward for exceeding it, and `#getReceivedBytesTotal` counts quarantined bytes
8953
+ * precisely so that budget is honest.
8954
+ *
8955
+ * Andre, 2026-09-03: *"The message limit is the message limit, already handled by the cap. If
8956
+ * you're unknown and you have 25 MB and you just tried to send me one gig, well that's it."*
8957
+ *
8958
+ * The ABUSE is still evidenced — this notice records the reason, the cap and the tier, and every
8959
+ * message the session did retain is still there. What is not kept is the oversized payload.
8960
+ */
8961
+ #noteSizeCapRefusal(agentName, sessionId, cap, tier) {
8962
+ /**
8963
+ * ⚠️ **IN MEGABYTES, WITH THE BYTES BESIDE THEM.** "26214400 bytes" is not a number anyone reads
8964
+ * as 25 MB, and the operator being told a conversation just ended deserves to understand the
8965
+ * limit that ended it at a glance. The raw figure stays because it is the exact bound.
8966
+ */
8967
+ const mb = Math.round((cap / 1_048_576) * 10) / 10;
8968
+ /**
8969
+ * The access level as a QUOTED LOWERCASE LABEL, never a bare word.
8970
+ *
8971
+ * `their tier is UNKNOWN` reads as "we could not determine their tier" — the opposite of what it
8972
+ * says. UNKNOWN is the NAME of the level a sender has before the operator adds them as a
8973
+ * contact. Quoting it and lowercasing it makes it a label rather than a failure.
8974
+ */
8975
+ const level = (Object.entries(TIER).find(([, v]) => v === tier)?.[0] ?? String(tier)).toLowerCase();
8976
+ this.noteContentRefusal(agentName, sessionId, "session_size_limit_exceeded", {
8977
+ kind: REFUSAL_KINDS.REFUSED,
8978
+ impact: `This conversation has hit its size limit for this sender: ${mb} MB (${cap} bytes), which is the limit at their access level ("${level}"). The message was not delivered, and neither will anything else they send in this conversation. They were not told — from their side it sent normally.`,
8979
+ guidance: `The limit is per conversation and does not reset, so waiting will not clear it. Start a NEW conversation with them to keep talking. If you trust them, raising their access level with cello_contact_set_tier gives them a larger limit next time — it does not revive this one. Tell them what happened: they have no way to know.`,
8980
+ });
7677
8981
  }
7678
8982
  #markContentUnverifiable(agentName, sessionId, why) {
7679
8983
  const key = this.#k(agentName, sessionId);
@@ -7681,6 +8985,116 @@ export class SessionNodeManager {
7681
8985
  return;
7682
8986
  this.#contentDesynced.set(key, why);
7683
8987
  }
8988
+ /**
8989
+ * 024-ORPHANTRIAGE — the three signals, read from evidence this side owns.
8990
+ *
8991
+ * ⚠️ **THE SEQUENCE NUMBER ON THE FRAME IS NOT ONE OF THEM.** "Was there an ongoing conversation
8992
+ * up to this point" is tempting to answer from the position the sender wrote, and that answer is
8993
+ * worthless: the sender picks the number, so anyone wanting the reach-out branch writes a large
8994
+ * one. It is answered from OUR transcript rows instead — a partial local record of that
8995
+ * conversation is something an attacker cannot put there from the wire.
8996
+ *
8997
+ * ⚠️ **"KNOWN" IS A TIER, NOT A ROW — review F1/F2, and reading it as a row inverted the unit.**
8998
+ * `contacts` rows are written from the WIRE with no operator action: `inbound-sessions.ts` calls
8999
+ * `addContact(..., "signal_presentation")` at `TIER.UNKNOWN` for any inbound offer inside the
9000
+ * acceptance bound, because the trust-signal foreign key needs a row to point at. And BLOCKING a
9001
+ * contact is an UPDATE to `TIER.BLOCKED`, so the row survives that too. A `SELECT … WHERE pubkey`
9002
+ * therefore answers "yes, known" for a stranger who merely dialled, AND for a key the operator
9003
+ * deliberately blocked — handing both the reach-out branch, which is the exact population this
9004
+ * unit exists to refuse. `DOD-TIER-4` had already settled this and retired `isContact` for it:
9005
+ * *"An UNKNOWN-tier contact (a mere row) is NOT known."* The tier is read from the row already
9006
+ * being fetched, so the case-insensitivity below survives (`getTier` compares case-sensitively).
9007
+ *
9008
+ * ⚠️ **HEX CASE IS NOT A DIFFERENCE IN IDENTITY.** This unit originally worked around that with its
9009
+ * own `lower(pubkey)` lookup, because `contacts.pubkey` was stored verbatim from the IPC parameter
9010
+ * and an exact match would report a contact the operator can SEE in `cello_contacts` as an unknown
9011
+ * stranger. The workaround is gone: `contact-pubkey-case.ts` now normalizes every contacts
9012
+ * accessor and folds the rows already on disk, so there is one spelling and one rule.
9013
+ */
9014
+ #orphanEvidence(agentName, sessionId, verifiedSignerUnmatched) {
9015
+ const signerPubkeyHex = verifiedSignerUnmatched === undefined
9016
+ ? null
9017
+ : Buffer.from(verifiedSignerUnmatched).toString("hex");
9018
+ /**
9019
+ * ⚠️ `"not_checked"` IS NOT A COSMETIC THIRD STATE — review F6.
9020
+ *
9021
+ * These two were `false` on every path that did not look, and the log event then reported them
9022
+ * as readings. An investigator filtering `session.content.orphaned` days later would read
9023
+ * `ongoingConversation: false` and conclude there was no local trace, when nothing had been
9024
+ * asked. Clause 1 says the branch RECORDS these; a default wearing the shape of a measurement
9025
+ * is not a record, and it is the cheapest possible way to mislead the one person who comes
9026
+ * looking.
9027
+ */
9028
+ const notChecked = {
9029
+ signerPubkeyHex, knownContact: "not_checked", contactMoniker: null, ongoingConversation: "not_checked",
9030
+ };
9031
+ // With no verifiable signature the other two signals mean nothing — a claimed key is a string
9032
+ // anyone can type — so they are not looked up at all rather than looked up and ignored.
9033
+ if (signerPubkeyHex === null)
9034
+ return notChecked;
9035
+ try {
9036
+ /**
9037
+ * ⚠️ NO `!this.#db` SHORT-CIRCUIT — review F7, and it was the silent half of this guard.
9038
+ *
9039
+ * The catch below logs ERROR for exactly this outcome; a bare `if (!this.#db) return` did not,
9040
+ * and the state is reachable while the operator still gets a notice — `noteContentRefusal`
9041
+ * keeps its own in-memory fallback when the write fails, so a daemon with an unusable store
9042
+ * still surfaces a refusal saying "that key is not in your address book" about a key that may
9043
+ * well be in it, with nothing anywhere recording that the address book was never opened.
9044
+ * Throwing into the catch is also what this file's other contact reads do (`getTier`,
9045
+ * `addContact`), and for the same reason: a read that decides how to treat a sender must not
9046
+ * degrade to "unclassified" in silence.
9047
+ */
9048
+ if (!this.#db)
9049
+ throw new Error("database is not open");
9050
+ const agentId = this.#requireAgentId(agentName);
9051
+ const contact = this.#db
9052
+ .prepare("SELECT moniker, tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
9053
+ .get(agentId, normalizeContactPubkey(signerPubkeyHex));
9054
+ /**
9055
+ * ⚠️ **QUARANTINED ROWS ARE NOT A LOCAL TRACE, AND WITHOUT THIS CLAUSE THE PROBE WRITES ITS
9056
+ * OWN EVIDENCE.** Found where `023-REFUSEDEVIDENCE` met `024-ORPHANTRIAGE`.
9057
+ *
9058
+ * This signal answers *"does this machine hold any part of a conversation under the id the
9059
+ * message names?"*, and a `true` is one of the two conditions that flips the triage from
9060
+ * REPORT-ONLY to offering the operator a reach-out. Since 023, a refused message is RETAINED
9061
+ * as a transcript row — so an unfiltered `SELECT 1 FROM transcript` sees the row this very
9062
+ * refusal just wrote.
9063
+ *
9064
+ * From the operator's chair, unfiltered: a stranger with a vouched key probes an id nobody
9065
+ * opened; the first probe is refused and retained; the second probe finds the first one's row,
9066
+ * reads as an ongoing conversation, and the operator is invited to reach out. **The attacker
9067
+ * manufactures the signal by sending twice** — which is precisely the outcome 024 exists to
9068
+ * prevent, reintroduced by the unit that made evidence durable.
9069
+ *
9070
+ * `direction != 'quarantined'` is the same exclusion every delivery reader uses, and it is the
9071
+ * right one: what is asked here is whether anything was ever DELIVERED under this id.
9072
+ */
9073
+ const trace = this.#db
9074
+ .prepare("SELECT 1 AS present FROM transcript WHERE agent_id = ? AND session_id = ? AND direction != 'quarantined' LIMIT 1")
9075
+ .get(agentId, sessionId);
9076
+ return {
9077
+ signerPubkeyHex,
9078
+ knownContact: contact !== undefined && normalizeTier(contact.tier) >= TIER.KNOWN,
9079
+ contactMoniker: contact?.moniker ?? null,
9080
+ ongoingConversation: trace !== undefined,
9081
+ };
9082
+ }
9083
+ catch (err) {
9084
+ /**
9085
+ * NOT A SILENT FALLBACK. Both signals come back `"not_checked"`, which the triage treats
9086
+ * exactly as it treats a stranger — REPORT, the action that is safe when nothing is known — and
9087
+ * which the log distinguishes from a measured `false`. A read failure here must never invent
9088
+ * the reach-out branch, and it must never be invisible.
9089
+ */
9090
+ this.#logger.error("session.content.orphaned.evidence.failed", {
9091
+ agentName, sessionId,
9092
+ error: extractErrorMessage(err),
9093
+ impact: "the address book and transcript could not be read, so a message whose signature DID verify is being reported to the operator as coming from a key nothing is known about. The advice is the safe one; it may be more cautious than the evidence warrants.",
9094
+ });
9095
+ return notChecked;
9096
+ }
9097
+ }
7684
9098
  async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
7685
9099
  /**
7686
9100
  * DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
@@ -7701,14 +9115,33 @@ export class SessionNodeManager {
7701
9115
  contentHashAlgIn,
7702
9116
  /**
7703
9117
  * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
7704
- * has one. The caller is the only place that has it — `#recordFrameOrdering` verifies the
7705
- * signature against the key inside the sender's own signed bytes and matches the signer to this
7706
- * session's counterparty, and that result reaches here or nowhere.
9118
+ * has one. The caller is the only place that has it — `#verifyAuthorshipClaim` verifies the
9119
+ * signature the frame carries beside the sender's own signed bytes, against the key inside those
9120
+ * bytes, and matches the signer to this session's counterparty. That result reaches here or
9121
+ * nowhere.
7707
9122
  *
7708
- * Optional, because the soft decode-failure path ingests without it. The row records which it
7709
- * was, so absence is never silent.
9123
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, and that was accurate until
9124
+ * `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature arrived only inside the RELAY's Structure 2, so
9125
+ * checking authorship needed a relay record. It does not now, and the old name sends a reader to
9126
+ * a method that answers a different question. Rewritten, not deleted — that dependence is the
9127
+ * defect the unit removed.
9128
+ *
9129
+ * Optional, because the PARK route ingests without it: recovered mail proves its sender by the
9130
+ * mailbox envelope instead. The row records which it was, so absence is never silent.
7710
9131
  */
7711
- verifiedAuthorship) {
9132
+ verifiedAuthorship,
9133
+ /**
9134
+ * 024-ORPHANTRIAGE — the key whose signature VERIFIED on a frame we could not tie to a session.
9135
+ *
9136
+ * Read by the orphan branch below and NOWHERE ELSE. It exists because the daemon establishes,
9137
+ * cryptographically, that the sender holds a private key — and then discarded that the instant
9138
+ * the session lookup came back empty, leaving the operator advised to go and make contact with
9139
+ * whoever sent a message for a conversation that does not exist.
9140
+ *
9141
+ * Absent on the park-recovery caller, which cannot reach the orphan branch at all:
9142
+ * `authenticateParkedEntry` refuses `counterparty_unknown` from the same missing record first.
9143
+ */
9144
+ verifiedSignerUnmatched) {
7712
9145
  // The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
7713
9146
  // 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
7714
9147
  // notarization attests that exact root; a late leaf would diverge from it.
@@ -7727,8 +9160,67 @@ export class SessionNodeManager {
7727
9160
  // papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
7728
9161
  // un-acked, so a live sender redelivers once the session actually exists. After D3
7729
9162
  // (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
9163
+ /**
9164
+ * DOD-M15-REFUSEDEVIDENCE-1 — HOISTED from below the hash cross-check, so that every refusal
9165
+ * above that point can retain the bytes under it. Same expression, earlier.
9166
+ *
9167
+ * It is the SENDER'S CLAIM at this point — nothing has checked it yet, and on a
9168
+ * `content_hash_mismatch` it provably does not describe these bytes. The quarantine read
9169
+ * recomputes its own hash over what was retained rather than reprinting this one.
9170
+ */
9171
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
7730
9172
  if (!record) {
7731
- this.#logger.warn("session.content.orphaned", { agentName, sessionId, correlationId });
9173
+ /**
9174
+ * RETAINED FIRST, because the triage below now tells the operator whether there is an artifact
9175
+ * to report — and that claim has to be made after the write, never before it (023 review F3).
9176
+ *
9177
+ * This is the case retention matters most for. A message for a session this daemon has no
9178
+ * record of is the least explicable thing that can arrive, so it is the thing an operator has
9179
+ * the least other way to show anyone. There is no `sessions` row and no counterparty, so no
9180
+ * tier — `#quarantineRefusedContent` bounds it at UNKNOWN and files it at a negative position,
9181
+ * outside the chain it never joined.
9182
+ */
9183
+ const keptOrphan = this.#quarantineRefusedContent(agentName, sessionId, "session_orphaned", content, contentHashHex, { correlationId });
9184
+ /**
9185
+ * 024-ORPHANTRIAGE — TWO ACTIONS EXIST AND THE EVIDENCE DECIDES WHICH.
9186
+ *
9187
+ * The advice here used to be *"ask the counterparty to start a NEW session."* When the message
9188
+ * is a stranger probing a peer id, obeying that advice is the probe succeeding: it confirms
9189
+ * somebody is home and that this agent answers, from a message that was refused.
9190
+ *
9191
+ * All three signals are read from things the sender does not control — their signature is
9192
+ * checked against the key inside their own signed bytes, "known" comes from OUR address book,
9193
+ * and "ongoing" comes from OUR transcript rows rather than the sequence number they chose.
9194
+ */
9195
+ const evidence = this.#orphanEvidence(agentName, sessionId, verifiedSignerUnmatched);
9196
+ const triage = triageOrphanedContent(evidence, retentionSentence(sessionId, keptOrphan));
9197
+ /**
9198
+ * BOTH SURFACES, per Invariant 2. The log is the durable forensic record and carries the
9199
+ * signals structurally — this is where an investigation days later reads what was known and
9200
+ * when. The notice below is the control: it is what the agent actually reads and acts on.
9201
+ */
9202
+ this.#logger.warn("session.content.orphaned", {
9203
+ agentName, sessionId, correlationId,
9204
+ signerPubkey: evidence.signerPubkeyHex ?? "(no verifiable signature)",
9205
+ signatureVerified: evidence.signerPubkeyHex !== null,
9206
+ // Review F6: `"not_checked"` where nothing was measured, never a `false` that reads as a
9207
+ // reading. An investigator filtering this event is the only person who will ever ask.
9208
+ knownContact: evidence.knownContact,
9209
+ ongoingConversation: evidence.ongoingConversation,
9210
+ action: triage.action,
9211
+ // 023: whether the evidence the triage points at actually exists.
9212
+ retained: keptOrphan !== null,
9213
+ impact: triage.impact,
9214
+ });
9215
+ // DOD-M15-NO-SILENT-REFUSAL-1. The notice is written even though there is no session row —
9216
+ // the store is keyed (agent_id, session_id) and holds no foreign key to `sessions` precisely
9217
+ // so this case can be recorded. A refusal for a session that does not exist here is the one
9218
+ // the operator has the least other way to learn about.
9219
+ this.noteContentRefusal(agentName, sessionId, "session_orphaned", {
9220
+ kind: REFUSAL_KINDS.REFUSED,
9221
+ impact: triage.impact,
9222
+ guidance: triage.guidance,
9223
+ });
7732
9224
  return { ok: false, reason: "session_orphaned" };
7733
9225
  }
7734
9226
  // DOD-TERMINAL-WAKE-1 (review F1): `abandoned` belongs here too. It is terminal and, unlike
@@ -7750,6 +9242,30 @@ export class SessionNodeManager {
7750
9242
  currentStatus: record.status,
7751
9243
  correlationId,
7752
9244
  });
9245
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. A post-seal straggler on the DIRECT path kept nothing
9246
+ // before this: `sealed_session_annex` covers the park-drain and held-drift routes, not this
9247
+ // exit. Something arriving into a signed, closed conversation is exactly the kind of thing an
9248
+ // operator later wants to produce.
9249
+ this.#quarantineRefusedContent(agentName, sessionId, "session_committed", content, contentHashHex, {
9250
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
9251
+ });
9252
+ /**
9253
+ * DOD-M15-REFUSALTERMINAL-1 — the retention call above is also what STOPS THE WORK: it runs
9254
+ * the terminal funnel, and `session_committed` is the one reason in it.
9255
+ *
9256
+ * Without that, the relay's next redelivery of the witness leaf armed another park fetch,
9257
+ * which drained, verified, arrived here, and was refused again — measured at ~2 per second
9258
+ * for 62 hours on one message. `#markContentResolved` could not be reused: this content did
9259
+ * not land, and saying that it did is a lie a future reader would act on.
9260
+ */
9261
+ // DOD-M15-NO-SILENT-REFUSAL-1. `currentStatus` on the log line carries the REAL status —
9262
+ // sealed, seal_interrupted_pending or abandoned — and the notice must not flatten those into
9263
+ // one claim, so it names the record as frozen rather than asserting which way it ended.
9264
+ this.noteContentRefusal(agentName, sessionId, "session_committed", {
9265
+ kind: REFUSAL_KINDS.REFUSED,
9266
+ impact: `This conversation is closed (it ended as "${record.status}"), so the message could not be delivered and neither can anything else they send to it. A closed conversation is signed and cannot be added to — that is what closing it means. Nothing is wrong on your side.`,
9267
+ guidance: "There is nothing to repair here. If they still have something to say, ask them to start a NEW conversation — a closed one cannot be reopened, and it is worth telling them, because they may not realise it ended. Read what was said before it closed with cello_transcript.",
9268
+ });
7753
9269
  return { ok: false, reason: "session_committed" };
7754
9270
  }
7755
9271
  /**
@@ -7791,10 +9307,17 @@ export class SessionNodeManager {
7791
9307
  impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
7792
9308
  guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
7793
9309
  });
9310
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. The algorithm name is an unsigned claim by whoever
9311
+ // sent the frame, so this branch is reachable by crafting as well as by version skew, and the
9312
+ // crafted case is one to be able to show someone.
9313
+ this.#quarantineRefusedContent(agentName, sessionId, "content_hash_alg_unknown", content, contentHashHex, {
9314
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
9315
+ });
7794
9316
  // DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
7795
9317
  // This reason is a version skew, so it affects every message from that counterparty — without
7796
9318
  // this the conversation goes permanently quiet and they conclude the peer stopped replying.
7797
9319
  this.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
9320
+ kind: REFUSAL_KINDS.REFUSED,
7798
9321
  impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
7799
9322
  guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
7800
9323
  });
@@ -7837,6 +9360,12 @@ export class SessionNodeManager {
7837
9360
  // purpose.
7838
9361
  guidance: "Look for session.salt.discarded first: if it is there, this side dropped its salt because the counterparty said it could never hold one, the agreement did complete and was deliberately undone, and a new session is the repair. Otherwise look for session.salt.adoption.refused: if it is there, this side declined the salt because the session had already hashed messages, that is permanent for this session, and reconnecting will NOT fix it — close the session and start a new one. Otherwise look for session.salt.read.failed or session.salt.persist.failed. If either is present the agreement re-runs on the next reconnect and this repairs itself — wait for that before doing anything. If none of the four is present, the agreement never completed with this counterparty: close the session and start a new one. In every case the transcript up to here is intact.",
7839
9362
  });
9363
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. We could not check it, which is precisely why the
9364
+ // bytes have to survive: the question of what they actually were stays open, and a hash we
9365
+ // could not verify answers none of it.
9366
+ this.#quarantineRefusedContent(agentName, sessionId, "content_hash_salt_unavailable", content, contentHashHex, {
9367
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
9368
+ });
7840
9369
  // DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
7841
9370
  //
7842
9371
  // It was refused, logged with a full impact and guidance, not ingested, not shown — and the
@@ -7852,12 +9381,12 @@ export class SessionNodeManager {
7852
9381
  // The guidance is passed by reference to the log's own text rather than duplicated: a second
7853
9382
  // copy is a second thing to keep true, and the log's version is the one that gets maintained.
7854
9383
  this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
9384
+ kind: REFUSAL_KINDS.REFUSED,
7855
9385
  impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
7856
9386
  guidance: "If session.salt.discarded is present, this side dropped its salt on purpose because the counterparty said it could never hold one — a new session is the repair. If this side refused the salt because the session had already hashed messages, that is PERMANENT for this session and reconnecting will not fix it — close the session and start a new one. Otherwise the salt agreement re-runs on the next reconnect and this repairs itself. Check session.salt.discarded and session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
7857
9387
  });
7858
9388
  return { ok: false, reason: "content_hash_salt_unavailable" };
7859
9389
  }
7860
- const contentHashHex = Buffer.from(contentHash).toString("hex");
7861
9390
  if (Buffer.from(computed).toString("hex") !== contentHashHex) {
7862
9391
  this.#logger.warn("session.content.cross_check.failed", {
7863
9392
  sessionId,
@@ -7871,9 +9400,26 @@ export class SessionNodeManager {
7871
9400
  // auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
7872
9401
  // but the responder seal now requires the agent's explicit decision, not an auto-ack.
7873
9402
  this.#markContentUnverifiable(agentName, sessionId, "tampered");
9403
+ /**
9404
+ * DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, and this is the highest-value row in the table.
9405
+ *
9406
+ * A tampered frame is the one case where the message and the sender's commitment PROVABLY
9407
+ * disagree, and the proof only exists while both halves do. Before this, the bytes went on the
9408
+ * floor and all that survived was a hash of something nobody still had.
9409
+ *
9410
+ * `verifiedAuthorship` is stored when the caller verified a signature over the sender's own
9411
+ * bytes. That is what makes the row evidence rather than a note: the signature is checked
9412
+ * against the key inside the sender's signed bytes, not against anything this side chose.
9413
+ */
9414
+ this.#quarantineRefusedContent(agentName, sessionId, "content_hash_mismatch", content, contentHashHex, {
9415
+ senderPubkeyHex: this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartyPubkey ?? record.counterparty_pubkey ?? null,
9416
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
9417
+ correlationId,
9418
+ });
7874
9419
  // DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
7875
9420
  // it failed verification, and showing it is the injection path this cross-check closes.
7876
9421
  this.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
9422
+ kind: REFUSAL_KINDS.REFUSED,
7877
9423
  impact: "a message arrived whose bytes do not match the hash the sender committed to, so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
7878
9424
  guidance: "Either the message was altered in transit or the sender's record is wrong. Ask the counterparty to resend. Do not close this session by auto-acknowledgement — seal it only by an explicit decision.",
7879
9425
  });
@@ -7886,6 +9432,49 @@ export class SessionNodeManager {
7886
9432
  // counterparty_pubkey NOT NULL, so this is unreachable unless a row was hand-crafted empty.
7887
9433
  // Either way, "unknown" is never written to a transcript row — refuse instead.
7888
9434
  this.#logger.warn("session.content.sender_unresolved", { sessionId, agentName, correlationId });
9435
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, with NO sender key, because there is none and that
9436
+ // absence is the evidence. The guidance below says to report this; this is the artifact there
9437
+ // is to report. Bounded at the UNKNOWN tier — there is no contact to look a tier up on, which
9438
+ // is the same fact that made it unattributable.
9439
+ const keptUnresolved = this.#quarantineRefusedContent(agentName, sessionId, "sender_unresolved", content, contentHashHex, { correlationId });
9440
+ this.noteContentRefusal(agentName, sessionId, "sender_unresolved", {
9441
+ kind: REFUSAL_KINDS.REFUSED,
9442
+ impact: "A message arrived that this daemon could not attribute to anyone, so it was not delivered. This conversation's record does not say who the other party is, which a conversation opened normally always does. TREAT THIS AS HOSTILE: a message that cannot be tied to a sender is far more likely to be a probe or an attack than a fault.",
9443
+ /**
9444
+ * ⚠️ NO "WHEN IN DOUBT" HERE — Andre, 2026-09-03: *"This message has no sender, the chances
9445
+ * that it is hostile are very high. When in doubt? No. Just report it."*
9446
+ *
9447
+ * That hedge belongs on the ambiguous branch in `024-ORPHANTRIAGE`, where a verified
9448
+ * signature from a known contact leaves a real judgement to make. There is no judgement
9449
+ * here. Softening it would teach the operator to weigh a case that does not need weighing.
9450
+ *
9451
+ * ⚠️ IT NAMES NO REPORTING DESTINATION, and that is still true — but HALF of the reason has
9452
+ * gone, so the sentence is rewritten rather than left to read as though nothing changed.
9453
+ *
9454
+ * It used to rest on two facts: `CELLO_Reporting` does not exist (`DOD-M15-ORPHANTRIAGE-1`,
9455
+ * still open) and **the message itself is not retained**. The second is no longer true —
9456
+ * `DOD-M15-REFUSEDEVIDENCE-1` retains it, and the guidance below now says so and names where
9457
+ * it is. Telling an operator to report something while keeping nothing to report was the
9458
+ * gap; naming a destination nobody can reach would be Invariant 4's failure. So: the
9459
+ * artifact is named now, the destination when 024 lands.
9460
+ *
9461
+ * ⚠️ THE ROTATION ADVICE IS MEASURED, NOT ASSUMED. `#startReceiverNode` mints the standing
9462
+ * receiver's transport key with `randomBytes(32)` and never persists it, so a logout/login
9463
+ * genuinely yields a NEW peer id and fresh directory connections. **And the bound is stated
9464
+ * in the same breath:** session nodes DO persist their seed (`DOD-M12B-SESSION-SEED-1`, so a
9465
+ * revived conversation keeps its address), so this rotates the front door and not the doors
9466
+ * already open. Telling an operator to rotate without that bound would have them believe
9467
+ * they had closed something they had not.
9468
+ */
9469
+ guidance:
9470
+ // "That is the artifact to show someone" is NOT appended: it would be false on the branch
9471
+ // where nothing was retained, which is the branch this sentence exists to be honest about.
9472
+ "Report this. " + retentionSentence(sessionId, keptUnresolved) +
9473
+ "Do not try to reply — there is no one to reply to, and answering an unattributable message is what a probe is looking for. " +
9474
+ "Then rotate your address: run cello logout followed by cello login. Your standing receiver's network identity is generated fresh each time it starts and is never stored, so this gives you a new one and rebuilds your connections to the directory — anyone holding the old address is left talking to something that no longer answers. " +
9475
+ "It does NOT change the addresses of conversations you already have open: those identities are kept on purpose so an interrupted conversation can resume. " +
9476
+ "This conversation cannot be repaired: close it with cello_close_session, and open a new one yourself if you were expecting someone. See session.content.sender_unresolved in the daemon log.",
9477
+ });
7889
9478
  return { ok: false, reason: "sender_unresolved" };
7890
9479
  }
7891
9480
  // DOD-MSG-5: a content_hash satisfies AT MOST ONE Merkle leaf, exactly once. If this hash is
@@ -8019,6 +9608,7 @@ export class SessionNodeManager {
8019
9608
  tier: senderTier,
8020
9609
  correlationId,
8021
9610
  });
9611
+ this.#noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
8022
9612
  return { ok: false, reason: "session_size_limit_exceeded" };
8023
9613
  }
8024
9614
  }
@@ -8100,8 +9690,29 @@ export class SessionNodeManager {
8100
9690
  correlationId,
8101
9691
  });
8102
9692
  }
9693
+ /**
9694
+ * DOD-M15-REFUSEDEVIDENCE-1 — **A TRANSIENT BLOCK RETAINS NOTHING, and nothing is lost by
9695
+ * that.** Nothing was recorded and, decisively, nothing was ACKNOWLEDGED: the message is still
9696
+ * with the sender, whose daemon redelivers it. When the gateway recovers the same bytes are
9697
+ * screened, and if they are blocked they are retained then, under the detector's own reason.
9698
+ *
9699
+ * Retaining here would file a copy of a message that is coming back — a duplicate, not
9700
+ * evidence — and it would do so for content nothing has yet judged, once per redelivery
9701
+ * attempt, for as long as the gateway stays down.
9702
+ */
9703
+ // DOD-M15-NO-SILENT-REFUSAL-1 — a TRANSIENT block, and saying which it is, is the whole
9704
+ // value of the notice. Nothing was recorded and nothing was acked, so the sender's daemon
9705
+ // redelivers on its own. An operator who reads the silence as delivery, or who asks the
9706
+ // counterparty to resend, is acting on the opposite of what happened.
9707
+ this.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
9708
+ kind: REFUSAL_KINDS.DEFERRED,
9709
+ impact: "the screener could not reach a verdict on an inbound message, so it was NOT ingested and NOT shown. Nothing was recorded and nothing was acknowledged — the message is still with the sender and their daemon will redeliver it once screening works again. Do not read this silence as delivery.",
9710
+ guidance: "TRANSIENT — do not ask the counterparty to resend, and do not close the session. Get the local screening gateway healthy and the backlog comes through on its own: look for security.gateway.timeout, security.gateway.unavailable and security.gateway.inbound.blocked in the daemon log — the third is what an internal screen_error logs, and naming only the first two sends you looking for lines that will not be there. While it stays down, every message from every counterparty takes this path.",
9711
+ });
8103
9712
  return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
8104
9713
  }
9714
+ // Assigned only on the terminal-block branch and invoked beside each retention attempt below.
9715
+ let noteTerminalBlock;
8105
9716
  if (terminalBlock) {
8106
9717
  this.#logger.warn("security.gateway.inbound.terminal_block", {
8107
9718
  sessionId,
@@ -8109,6 +9720,78 @@ export class SessionNodeManager {
8109
9720
  reason: inboundVerdict.reason,
8110
9721
  correlationId,
8111
9722
  });
9723
+ /**
9724
+ * DOD-M15-REFUSEDEVIDENCE-1 — retention for a terminal block happens where its LEAF happens,
9725
+ * not here. Two sites below (the hold branch and the in-order append), each writing the
9726
+ * quarantine row at the same index as the leaf it accompanies.
9727
+ *
9728
+ * Not here, deliberately: this point is upstream of the post-screen dedup re-check and the
9729
+ * size-cap re-check, either of which can still refuse. Retaining above them would file
9730
+ * evidence for a message this call then reports as capped — and the cap path is the one that
9731
+ * is ruled NOT to retain.
9732
+ */
9733
+ /**
9734
+ * DOD-M15-NO-SILENT-REFUSAL-1 — **the moment the product catches the attack it exists to
9735
+ * catch, and until now the operator was told nothing about it.**
9736
+ *
9737
+ * This path is not an error path, which is exactly why it had no notice: the block leafs the
9738
+ * original content hash at its canonical position and acknowledges the sender, so nothing
9739
+ * fails and nothing loops. The message is simply never handed to the agent. From the
9740
+ * operator's chair a message they were expecting never arrives and the record shows a leaf
9741
+ * with nothing in it.
9742
+ *
9743
+ * The notice NEVER carries the blocked content — a screener that can be talked into surfacing
9744
+ * what it blocked is not a screener.
9745
+ *
9746
+ * ⚠️ **THE GUIDANCE USED TO SAY "DO NOT ASK FOR THE ORIGINAL TEXT", AND THAT IS NOW WRONG.**
9747
+ * Rewritten rather than deleted, per the claim-comment rule, because the reasoning is what
9748
+ * changed and not just the sentence. It rested on the content being unavailable; under
9749
+ * `DOD-M15-REFUSEDEVIDENCE-1` it is retained and there is a route that returns it FRAMED. And
9750
+ * the friction was never protection: Andre, 2026-09-03 — *"eventually the LLM is going to go
9751
+ * searching for it, because human beings are going to direct their LLMs to find it, and it's
9752
+ * going to come back and say 'Hey, I found it here, the message says…' — which is far
9753
+ * worse."* Withholding the route removes the WARNING from the read, not the read.
9754
+ *
9755
+ * What survives unchanged: do not turn screening off. That is still the one action that makes
9756
+ * things worse, and it is the one the guidance still refuses.
9757
+ */
9758
+ /**
9759
+ * ⚠️ THE DETECTOR'S OWN REASON SURVIVES — `inbound_screen_blocked` is only the fallback.
9760
+ *
9761
+ * Invariant 3: a downstream handler must not replace an upstream descriptive error with a
9762
+ * generic one. The verdict already says WHICH detector fired — `inbound_language_blocked` and
9763
+ * an injection block are different problems with different remedies, and one of them has an
9764
+ * operator command that fixes it. Flattening both to `inbound_screen_blocked` would also
9765
+ * deduplicate them together, so the second kind would be silent for the life of the session.
9766
+ *
9767
+ * The gateway's own `guidance` is appended when it has one, for the same reason: it is the
9768
+ * half that names the actual command.
9769
+ */
9770
+ // `?? "inbound_screen_blocked"` is a floor, not a live branch: every verdict producer in the
9771
+ // tree sets `reason`, so today it never fires. It stays because `reason` is optional on the
9772
+ // type, and a notice keyed on `undefined` would collapse every future detector into one row.
9773
+ /**
9774
+ * ⚠️ **DEFERRED UNTIL THE RETENTION HAS ACTUALLY RUN — review F3.** The notice used to be
9775
+ * written here, above both append sites, and claimed the message was kept before anything had
9776
+ * tried to keep it. It is now a closure invoked beside each `#quarantineRefusedContent` call,
9777
+ * carrying that call's own answer.
9778
+ *
9779
+ * Two paths between here and there deliberately write NO notice now, and both are the better
9780
+ * answer: a post-screen dedup means this exact message was already noticed the first time, and
9781
+ * a size-cap refusal writes `#noteSizeCapRefusal` instead — which is what actually happened,
9782
+ * where before the operator got both stories at once.
9783
+ */
9784
+ noteTerminalBlock = (stored) => {
9785
+ this.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
9786
+ kind: REFUSAL_KINDS.BLOCKED,
9787
+ impact: "the screener blocked an inbound message: its content matched a detector this agent runs on everything that arrives. It was NOT shown to the agent. It IS recorded in the hash chain at its position and the sender was acknowledged, so they will not resend it and they were not told it was blocked.",
9788
+ guidance: "This is the protection doing its job, and nothing is required of you. If you were expecting something from this counterparty around now, tell them it was blocked and ask them to say it differently. " +
9789
+ retentionSentence(sessionId, stored) +
9790
+ (stored === null ? "" : "There is no reason to read it unless you need to show someone, or judge whether this was an attack. ") +
9791
+ "Do NOT turn screening off to read it: that is the one action here that makes things worse. security.gateway.inbound.terminal_block in the daemon log names which detector fired." +
9792
+ (inboundVerdict.guidance !== undefined ? ` The detector says: ${inboundVerdict.guidance}` : ""),
9793
+ });
9794
+ };
8112
9795
  }
8113
9796
  // M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
8114
9797
  // while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
@@ -8178,6 +9861,7 @@ export class SessionNodeManager {
8178
9861
  correlationId,
8179
9862
  recheck: true,
8180
9863
  });
9864
+ this.#noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
8181
9865
  return { ok: false, reason: "session_size_limit_exceeded" };
8182
9866
  }
8183
9867
  }
@@ -8217,6 +9901,18 @@ export class SessionNodeManager {
8217
9901
  // DOD-M12B-STRAND-1: and to disk, before we answer. The in-memory Map is the working copy;
8218
9902
  // this row is the one that survives the teardown that used to destroy it.
8219
9903
  this.#persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, content, contentHashHex, terminalBlock === true, correlationId);
9904
+ // DOD-M15-REFUSEDEVIDENCE-1 (site 1 of 2 for a terminal block): a block held behind an
9905
+ // ordering gap. `#releaseHeld` appends its leaf later WITHOUT re-entering this method, so
9906
+ // retaining at release is not available — it is retained here, at the position the leaf will
9907
+ // take. `held_content` is not a substitute: that row is deleted the moment the gap fills.
9908
+ if (terminalBlock) {
9909
+ const keptHeld = this.#quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
9910
+ senderPubkeyHex: senderPubkey, canonicalSeq,
9911
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
9912
+ correlationId,
9913
+ });
9914
+ noteTerminalBlock?.(keptHeld);
9915
+ }
8220
9916
  this.#logger.info("session.content.held", {
8221
9917
  sessionId,
8222
9918
  canonicalSeq,
@@ -8249,6 +9945,50 @@ export class SessionNodeManager {
8249
9945
  const leafIndex = terminalBlock
8250
9946
  ? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
8251
9947
  : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
9948
+ /**
9949
+ * DOD-M15-REFUSEDEVIDENCE-1 (site 2 of 2) — **the moment the product catches the attack it
9950
+ * exists to catch, and until now it kept only the hash.**
9951
+ *
9952
+ * The terminal-block branch above takes `appendSessionLeaf`, not `#appendVerifiedContent`, so
9953
+ * the row carrying the plaintext, the sender's key and the sender's signature was never written.
9954
+ * A hash proves a message you still hold has not changed; it proves nothing about one you threw
9955
+ * away — and this is precisely the message an operator would most want to produce.
9956
+ *
9957
+ * At `leafIndex`, so the leaf and the evidence describe one event and DoD 7's leaf placement is
9958
+ * untouched. The ORIGINAL bytes, never the sanitized `deliverContent`: evidence is what they
9959
+ * sent, not what a filter made of it.
9960
+ */
9961
+ if (terminalBlock) {
9962
+ const keptBlocked = this.#quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
9963
+ senderPubkeyHex: senderPubkey, canonicalSeq: leafIndex,
9964
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
9965
+ correlationId,
9966
+ });
9967
+ noteTerminalBlock?.(keptBlocked);
9968
+ /**
9969
+ * ⚠️ **DROP THE WITNESS — A BLOCKED MESSAGE MADE THE SESSION PERMANENTLY UNSEALABLE.**
9970
+ *
9971
+ * THE THIRD INSTANCE of the shape already fixed for document frames at `:10593`, found by the
9972
+ * first journey that ever sealed a session after a screener block.
9973
+ *
9974
+ * `sealReadiness` derives `missingLeaves` from `#witnessedSeq.size` — every position the
9975
+ * ordering authority committed that this tree has not appended. The entry is dropped where the
9976
+ * leaf is credited, and that drop lives inside `#appendVerifiedContent`. A terminal block does
9977
+ * not go through it: the branch above takes `appendSessionLeaf` directly, so the leaf WAS
9978
+ * committed and the witness was never retired.
9979
+ *
9980
+ * **From the operator's chair:** their screener catches one hostile message, and from that
9981
+ * moment `cello_close_session` answers `session_incomplete` forever — *"waiting on an earlier
9982
+ * message from the counterparty that has not arrived"* — about a message that arrived, was
9983
+ * judged, and is sitting in the chain. The only exit is a force-abandon, which forfeits the
9984
+ * notarized receipt. Measured live: `treeSize 3, highWaterSeq 2, missingLeaves 1`.
9985
+ *
9986
+ * Not introduced by `DOD-M15-REFUSEDEVIDENCE-1` — it is older than this unit and simply had no
9987
+ * test that both blocked a message and then sealed. It is fixed here because this unit's own
9988
+ * DoD requires that session to seal.
9989
+ */
9990
+ this.#witnessedSeq.get(key)?.delete(contentHashHex);
9991
+ }
8252
9992
  // DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
8253
9993
  // transcript IS the delivery path — so this message can never be handed to any session. Report
8254
9994
  // the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
@@ -8259,6 +9999,37 @@ export class SessionNodeManager {
8259
9999
  // tidy up a reporting problem would corrupt the frontier the counterparty already co-signs
8260
10000
  // against. The hole is now crossable by delivery (F1), so it costs a gap, not a stall.
8261
10001
  if (!terminalBlock && this.getUndeliverableSeqs(agentName, sessionId).includes(leafIndex)) {
10002
+ /**
10003
+ * DOD-M15-REFUSEDEVIDENCE-1 — **THIS PATH CANNOT RETAIN, because the storage layer is what
10004
+ * just failed.** The write that would keep the evidence is the same `INSERT` into the same
10005
+ * table that has already thrown for this message. Attempting it produces a second error line
10006
+ * and no evidence. Named here rather than left to be rediscovered as a missing case.
10007
+ */
10008
+ // DOD-M15-NO-SILENT-REFUSAL-1. `#appendVerifiedContent` already noted `content_undeliverable`
10009
+ // at the point the write failed; this is the INGEST's own refusal, and it is a different fact
10010
+ // — the sender is told the ingest failed, so it will redeliver, and every redelivery of the
10011
+ // same hash now dedups against a leaf whose plaintext is not there. Two reasons, because a
10012
+ // reader fixing the disk fault needs to know both that the text is gone and that the sender
10013
+ // is retrying into a hole.
10014
+ this.noteContentRefusal(agentName, sessionId, "transcript_write_failed", {
10015
+ kind: REFUSAL_KINDS.LOST,
10016
+ impact: "A message reached this agent, was verified, and was committed to the conversation's record — and then its text could not be written to local storage, so it can never be delivered. There is a permanent gap in your copy of this conversation. This is a fault on THIS machine; the counterparty did nothing wrong and cannot fix it.",
10017
+ /**
10018
+ * ⚠️ THE READER IS USUALLY ALREADY IN A CODING AGENT, so the guidance says GO AND LOOK
10019
+ * rather than listing symptoms. Andre, 2026-09-03: *"The message should mention to try and
10020
+ * figure out why you cannot store it — it is likely a local machine problem. But if you
10021
+ * truly cannot figure this out using a coding agent, then we advise reaching out to
10022
+ * CELLO_Support."*
10023
+ *
10024
+ * That ordering matters: this is a machine fault with an ordinary cause, and an operator
10025
+ * sent straight to support for a full disk has been wasted. Support is the exit, not the
10026
+ * first step.
10027
+ */
10028
+ guidance: "Find out why the write failed — this is almost always something ordinary on this machine. " +
10029
+ "If you are reading this through a coding agent, have it check: free disk space, the permissions on ~/.cello, whether the database file is readable and writable, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
10030
+ "Waiting cannot recover the message. Once the fault is fixed, ask them to resend — the text is gone and only its hash remains. " +
10031
+ "If you genuinely cannot work out the cause, reach out to CELLO_Support.",
10032
+ });
8262
10033
  return { ok: false, reason: "transcript_write_failed" };
8263
10034
  }
8264
10035
  // NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
@@ -8356,10 +10127,172 @@ export class SessionNodeManager {
8356
10127
  this.#leafFetchTimers.delete(timerKey);
8357
10128
  }
8358
10129
  }
10130
+ /**
10131
+ * DOD-M15-REFUSALTERMINAL-1 — the funnel. A refusal stops the work ONLY if its reason is in
10132
+ * `TERMINAL_REFUSAL_REASONS`; every other reason keeps retrying, which is what makes a transient
10133
+ * screener block or a version skew recoverable.
10134
+ *
10135
+ * One place decides, so "is this reason terminal?" is answerable from the set rather than from
10136
+ * thirteen call sites.
10137
+ */
10138
+ #considerTerminalRefusal(agentName, sessionId, contentHashHex, reason) {
10139
+ if (!TERMINAL_REFUSAL_REASONS.has(reason))
10140
+ return;
10141
+ this.#markContentTerminallyRefused(agentName, sessionId, contentHashHex, reason);
10142
+ }
10143
+ /**
10144
+ * DOD-M15-REFUSALTERMINAL-1: this content can NEVER be accepted on this session — cancel the
10145
+ * pending fetch and make sure no future one is scheduled, across restarts.
10146
+ *
10147
+ * The durable write comes FIRST and the in-memory cache second, so a process that dies between
10148
+ * them wakes up with the stop still in force. A failed write is announced at ERROR and the
10149
+ * in-memory mark is still taken: the loop stops for the life of THIS process, and the log says
10150
+ * plainly that it will resume after a restart. That is a degraded stop, not a silent one.
10151
+ */
10152
+ #markContentTerminallyRefused(agentName, sessionId, contentHashHex, reason) {
10153
+ const key = this.#k(agentName, sessionId);
10154
+ /**
10155
+ * Read BEFORE the write, so the announcement below fires on the TRANSITION rather than on every
10156
+ * re-refusal. The same message can be refused again by a drain triggered for another reason, and
10157
+ * an INFO line per repeat is a smaller version of the noise this unit exists to remove.
10158
+ *
10159
+ * The durable write is still ATTEMPTED every time, deliberately: `INSERT OR IGNORE` costs
10160
+ * nothing when the row is already there, and skipping it would mean a write that failed once —
10161
+ * the branch that logs the error below — never got another chance to succeed.
10162
+ */
10163
+ const alreadyKnown = this.#isTerminallyRefused(agentName, sessionId, contentHashHex);
10164
+ try {
10165
+ if (!this.#db)
10166
+ throw new Error("database is not open");
10167
+ const agentId = this.#requireAgentId(agentName);
10168
+ this.#db
10169
+ .prepare(`INSERT OR IGNORE INTO terminal_content_refusals
10170
+ (agent_id, session_id, content_hash, reason, marked_at)
10171
+ VALUES (?, ?, ?, ?, ?)`)
10172
+ .run(agentId, sessionId, contentHashHex, reason, Date.now());
10173
+ /**
10174
+ * ⚠️ **BOUNDED, because the counterparty chooses how many rows exist — review F7.**
10175
+ *
10176
+ * One row per distinct content hash aimed at a closed conversation, and the funnel that calls
10177
+ * this runs even when the byte cap has already stopped RETENTION. So a peer who has exhausted
10178
+ * the session's storage budget can still write rows here, indefinitely, on a table nothing
10179
+ * else deletes. Every sibling store in this file is bounded (`MAX_UNREADABLE_ALG_FRAMES`, the
10180
+ * tier byte cap, `MAX_REFUSAL_READERS`); this one was not.
10181
+ *
10182
+ * Oldest-dropped, so the newest refusals keep their stop and the loop stays closed for what
10183
+ * is arriving now. A dropped row costs at most one extra fetch for content nobody is sending
10184
+ * any more — the pre-fix behaviour for that one hash, and nothing worse.
10185
+ */
10186
+ const dropped = this.#db
10187
+ .prepare(`DELETE FROM terminal_content_refusals
10188
+ WHERE agent_id = ? AND session_id = ? AND content_hash NOT IN (
10189
+ SELECT content_hash FROM terminal_content_refusals
10190
+ WHERE agent_id = ? AND session_id = ?
10191
+ ORDER BY marked_at DESC LIMIT ${MAX_TERMINAL_REFUSALS_PER_SESSION}
10192
+ )`)
10193
+ .run(agentId, sessionId, agentId, sessionId);
10194
+ if (Number(dropped.changes) > 0) {
10195
+ // Loud, because it means a counterparty has aimed more than the cap's worth of distinct
10196
+ // messages at one closed conversation — which is abuse, not ordinary traffic.
10197
+ this.#logger.warn("session.content.terminal_refusal.evicted", {
10198
+ agentName, sessionId, dropped: Number(dropped.changes),
10199
+ cap: MAX_TERMINAL_REFUSALS_PER_SESSION,
10200
+ impact: "more distinct messages have been refused on this closed conversation than the cap keeps a record of, so the oldest stops were dropped. If one of those arrives again it costs one wasted fetch; nothing is delivered and nothing is lost.",
10201
+ });
10202
+ }
10203
+ }
10204
+ catch (err) {
10205
+ this.#logger.error("session.content.terminal_refusal.persist.failed", {
10206
+ agentName, sessionId, reason,
10207
+ contentHash: contentHashHex,
10208
+ error: extractErrorMessage(err),
10209
+ impact: "this message can never be accepted on this conversation, and that fact could not be written down. Fetching for it stops while this daemon runs, and RESUMES after the next restart — which is the loop that filled a log with a quarter of a million refusals for one message.",
10210
+ guidance: "This is a fault on THIS machine, not with the counterparty. Check free disk space and the permissions on ~/.cello; session.refusal.persist.failed in this log usually appears alongside it with the underlying error.",
10211
+ });
10212
+ }
10213
+ let set = this.#terminallyRefused.get(key);
10214
+ if (!set) {
10215
+ set = new Set();
10216
+ this.#terminallyRefused.set(key, set);
10217
+ }
10218
+ set.add(contentHashHex);
10219
+ // The same cancellation `#markContentResolved` performs, for the opposite fact: an already-armed
10220
+ // grace timer must not fire for content we have just decided never to accept.
10221
+ const timerKey = `${key}::${contentHashHex}`;
10222
+ const t = this.#leafFetchTimers.get(timerKey);
10223
+ if (t !== undefined) {
10224
+ clearTimeout(t);
10225
+ this.#leafFetchTimers.delete(timerKey);
10226
+ }
10227
+ if (!alreadyKnown) {
10228
+ this.#logger.info("session.content.terminal_refusal", {
10229
+ agentName, sessionId, reason,
10230
+ contentHash: contentHashHex,
10231
+ impact: "no further attempt will be made to fetch this message. The conversation it was sent to is closed and signed, so no retry could ever have succeeded.",
10232
+ });
10233
+ }
10234
+ }
10235
+ /**
10236
+ * DOD-M15-REFUSALTERMINAL-1: has this content already been refused terminally?
10237
+ *
10238
+ * Reads the durable rows for a session ONCE and caches them, so the hot path — a witnessed leaf
10239
+ * on a healthy session — costs one `Set` lookup rather than a query per message. A read failure
10240
+ * returns `false`: the cost is the loop continuing, which is the pre-fix behaviour, and it is
10241
+ * announced rather than swallowed. Answering `true` on a failed read would be the dangerous
10242
+ * direction, because it silently stops fetching content that was never refused.
10243
+ */
10244
+ #isTerminallyRefused(agentName, sessionId, contentHashHex) {
10245
+ const key = this.#k(agentName, sessionId);
10246
+ /**
10247
+ * ⚠️ **A FAILING READ MUST NOT BE RETRIED PER MESSAGE — review F3.**
10248
+ *
10249
+ * The loaded flag is set only on success, so a database that throws (a full disk, a corrupt
10250
+ * page) sent this method back to SQLite on EVERY witnessed leaf, logged an ERROR each time, and
10251
+ * — because nothing was ever cached — made `alreadyKnown` false forever, so the mark's INFO
10252
+ * fired on every refusal too. That is the ~2/s log growth this unit exists to end, reproduced
10253
+ * by its own fix in the failure mode.
10254
+ *
10255
+ * Backed off instead: one attempt per session per minute, so the read still recovers when the
10256
+ * disk does, and the ERROR is bounded rather than proportional to traffic.
10257
+ */
10258
+ const failedAt = this.#terminalRefusalsReadFailedAt.get(key);
10259
+ const backedOff = failedAt !== undefined && Date.now() - failedAt < TERMINAL_REFUSAL_READ_RETRY_MS;
10260
+ if (!this.#terminalRefusalsLoaded.has(key) && !backedOff && this.#db) {
10261
+ try {
10262
+ const rows = this.#db
10263
+ .prepare("SELECT content_hash FROM terminal_content_refusals WHERE agent_id = ? AND session_id = ?")
10264
+ .all(this.#requireAgentId(agentName), sessionId);
10265
+ let set = this.#terminallyRefused.get(key);
10266
+ if (!set) {
10267
+ set = new Set();
10268
+ this.#terminallyRefused.set(key, set);
10269
+ }
10270
+ for (const r of rows)
10271
+ set.add(r.content_hash);
10272
+ this.#terminalRefusalsLoaded.add(key);
10273
+ this.#terminalRefusalsReadFailedAt.delete(key);
10274
+ }
10275
+ catch (err) {
10276
+ this.#terminalRefusalsReadFailedAt.set(key, Date.now());
10277
+ this.#logger.error("session.content.terminal_refusal.read.failed", {
10278
+ agentName, sessionId,
10279
+ error: extractErrorMessage(err),
10280
+ retryInMs: TERMINAL_REFUSAL_READ_RETRY_MS,
10281
+ impact: "the record of messages this conversation can never accept could not be read, so this daemon may keep fetching one of them. Nothing is lost; the cost is repeated work and log noise. The read is retried once a minute rather than on every message, so this line is bounded — its absence for a while does NOT mean the fault cleared.",
10282
+ guidance: "This is a fault on THIS machine, not with any counterparty. Check free disk space and the permissions on ~/.cello; the error above carries SQLite's own message.",
10283
+ });
10284
+ }
10285
+ }
10286
+ return this.#terminallyRefused.get(key)?.has(contentHashHex) === true;
10287
+ }
8359
10288
  #scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
8360
10289
  const key = this.#k(agentName, sessionId);
8361
10290
  if (this.#resolvedContent.get(key)?.has(contentHashHex))
8362
10291
  return;
10292
+ // DOD-M15-REFUSALTERMINAL-1: a refusal nothing can get past is the end of the work, not a
10293
+ // reason to come back in two seconds.
10294
+ if (this.#isTerminallyRefused(agentName, sessionId, contentHashHex))
10295
+ return;
8363
10296
  const timerKey = `${key}::${contentHashHex}`;
8364
10297
  // ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence —
8365
10298
  // scheduling per redelivery turns a slow relay into a storm against itself.
@@ -8592,25 +10525,34 @@ export class SessionNodeManager {
8592
10525
  if (!own)
8593
10526
  return "none";
8594
10527
  try {
8595
- // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
8596
- const fields = decode(own.structure1Cbor);
8597
- const contentHash = fields[1];
8598
- if (!(contentHash instanceof Uint8Array)) {
10528
+ // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq,
10529
+ // timestamp], plus last_seen_hash at index 6 on a v2 claim (020-ACKHASH). content_hash is
10530
+ // index 1 in both.
10531
+ const s1 = decodeStructure1(own.structure1Cbor);
10532
+ if (!s1.ok) {
8599
10533
  this.#logger.warn("session.seal.leaf.recover.failed", {
8600
- sessionId, agentName, reason: "structure1_content_hash_missing",
10534
+ // NAMED AT ITS CAUSE — review F2. This read `structure1_content_hash_missing`, which was
10535
+ // accurate when the only check was `contentHash instanceof Uint8Array`. It now fires for an
10536
+ // unknown layout, undecodable CBOR and a malformed field too, and sends an operator to
10537
+ // audit a content hash when the layout is what disagreed. `structure1Reason` carries which.
10538
+ sessionId, agentName, reason: "structure1_decode_failed", structure1Reason: s1.reason,
8601
10539
  impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
8602
10540
  });
8603
10541
  return "unknown";
8604
10542
  }
8605
- const contentHashHex = Buffer.from(contentHash).toString("hex");
10543
+ const contentHashHex = Buffer.from(s1.fields.contentHash).toString("hex");
8606
10544
  return {
8607
10545
  reportedRootHex: this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex),
8608
10546
  sequenceNumber: own.sequenceNumber,
8609
10547
  };
8610
10548
  }
8611
10549
  catch (err) {
10550
+ // NOT a decode failure — review F3. `decodeStructure1` never throws, so the only thrower left
10551
+ // inside this try is the tree derivation below it. Calling this `structure1_decode_failed`
10552
+ // pointed at CBOR for a fault in `rootWithAppendedHex`, and made one reason string mean two
10553
+ // unrelated things in the same log event.
8612
10554
  this.#logger.warn("session.seal.leaf.recover.failed", {
8613
- sessionId, agentName, reason: "structure1_decode_failed",
10555
+ sessionId, agentName, reason: "seal_root_derivation_threw",
8614
10556
  error: err instanceof Error ? err.message : String(err),
8615
10557
  impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
8616
10558
  });
@@ -8682,9 +10624,13 @@ export class SessionNodeManager {
8682
10624
  originalContent,
8683
10625
  /**
8684
10626
  * DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
8685
- * that has it — `#recordFrameOrdering` verified this signature against the pubkey inside the
8686
- * sender's own signed bytes and matched the signer to this session's counterparty. It reaches
8687
- * the transcript row from here or not at all.
10627
+ * that has it — `#verifyAuthorshipClaim` verified this signature (carried on the frame beside
10628
+ * the bytes it signs) against the pubkey inside those bytes, and matched the signer to this
10629
+ * session's counterparty. It reaches the transcript row from here or not at all.
10630
+ *
10631
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, true until `DOD-M15-AUTHORSHIP-ABSENT-1` moved the
10632
+ * check off the relay's record and onto the frame's own signature. Rewritten rather than
10633
+ * deleted: the old name is the evidence of what authorship used to depend on.
8688
10634
  *
8689
10635
  * Undefined on the held-release and soft-fallback paths; the row records that as
8690
10636
  * `local_session_state` rather than leaving it indistinguishable from a proven one.
@@ -8780,6 +10726,17 @@ export class SessionNodeManager {
8780
10726
  this.#undeliverableSeqs.set(recvKey, lost);
8781
10727
  }
8782
10728
  lost.add(leafIndex);
10729
+ // DOD-M15-NO-SILENT-REFUSAL-1: noted HERE, where the write actually fails, and not on the
10730
+ // cello_receive exit that reports it. `#undeliverableSeqs` is in memory, so the receive exit
10731
+ // stops being able to say this after a restart while the transcript hole stays permanent —
10732
+ // and the exit only runs if somebody is attending, which is the case this whole line is for.
10733
+ this.noteContentRefusal(agentName, sessionId, "content_undeliverable", {
10734
+ kind: REFUSAL_KINDS.LOST,
10735
+ impact: `a message arrived and was committed to the hash chain at sequence ${leafIndex}, and then its text could not be written to the local transcript. Delivery reads the transcript, so that message can never be handed to any session — it is a permanent hole in this side's copy of the conversation.`,
10736
+ guidance: "This is a fault on THIS machine; the counterparty did nothing wrong. Find out why the write failed — it is almost always something ordinary. " +
10737
+ "If you are reading this through a coding agent, have it check free disk space, the permissions on ~/.cello, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
10738
+ "Waiting cannot recover it. Once the fault is fixed, ask them to resend. If you genuinely cannot work out the cause, reach out to CELLO_Support.",
10739
+ });
8783
10740
  }
8784
10741
  // Review finding #6: the witness for this hash has done its ordering job once the leaf is
8785
10742
  // appended — drop it so #witnessedSeq stays proportional to held/pending content, not the whole
@@ -9502,6 +11459,11 @@ export class SessionNodeManager {
9502
11459
  }
9503
11460
  else if (entry.screenedOut) {
9504
11461
  this.appendSessionLeaf(agentName, sessionId, "msg", entry.contentHashHex, entry.correlationId);
11462
+ // The SAME witness leak as the immediate-append terminal-block branch (see the block comment
11463
+ // at the `if (terminalBlock)` append in `ingestReceivedContent`), on the held path. This
11464
+ // branch also bypasses `#appendVerifiedContent`, where the drop lives — so a blocked message
11465
+ // that arrived out of order left `missingLeaves` stuck at 1 and the session unsealable.
11466
+ this.#witnessedSeq.get(key)?.delete(entry.contentHashHex);
9505
11467
  }
9506
11468
  else {
9507
11469
  this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId, entry.originalContent);
@@ -10013,6 +11975,14 @@ export class SessionNodeManager {
10013
11975
  */
10014
11976
  const memoKey = this.#k(agentName, sessionId);
10015
11977
  const priorDeclaredAlg = this.#unreadableAlgSeen.get(memoKey)?.get(contentHashHex);
11978
+ /**
11979
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` review H1 — READ BEFORE THE INGEST, reported after it.
11980
+ *
11981
+ * Same reasoning as `priorDeclaredAlg` directly above: the memo says what THIS side did to this
11982
+ * content on the direct path, and the ingest below is what decides whether the other route
11983
+ * succeeded. Reading it after would race the clear.
11984
+ */
11985
+ const refusedForAuthorship = this.#refusedOnDirectPath.get(memoKey)?.has(contentHashHex) === true;
10016
11986
  const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
10017
11987
  // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
10018
11988
  // `sha256` and is exactly right for a peer that predates the field.
@@ -10053,6 +12023,48 @@ export class SessionNodeManager {
10053
12023
  impact: "THIS EXACT MESSAGE was refused on the direct path because it named an algorithm this build cannot read, and the same content has now been accepted via the relay park under an algorithm this build CAN read. The refusal did not hold: the message was delivered by the other route.",
10054
12024
  });
10055
12025
  }
12026
+ /**
12027
+ * ⚠️ **THE AUTHORSHIP REFUSAL DOES NOT HOLD EITHER, AND THIS IS WHERE IT SAYS SO** — review H1.
12028
+ *
12029
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` refuses a direct-path frame with no usable proof of who wrote
12030
+ * it. Refusing sends no delivery ACK, so the sender's TTF backstop parks the message and it
12031
+ * arrives here — where the ENVELOPE's signature over (session_id, recipient_pubkey,
12032
+ * content_hash) is what authenticates it, and `authenticateParkedEntry` above has already
12033
+ * accepted it. That is correct and it is deliberately NOT changed here: gating mail retrieval
12034
+ * on a per-message record the relay-degraded path is allowed to omit is the false-positive shape
12035
+ * this whole unit is careful to avoid, and the order that added the refusal scopes the park
12036
+ * envelope out explicitly.
12037
+ *
12038
+ * What must not stand is the SILENCE. Without this line the operator reads "refused" and then
12039
+ * watches the message appear, with nothing connecting the two — the same reconciliation gap the
12040
+ * algorithm refusal above already pays for. What they need to know is the part that is really
12041
+ * lost: the message arrived, and its INDIVIDUAL author is attested by the mailbox envelope
12042
+ * rather than by a signature over that message's own bytes.
12043
+ *
12044
+ * Same `ok && !held && !screenedOut` predicate as above, and for the same reason: `ok` is not
12045
+ * "delivered".
12046
+ */
12047
+ if (refusedForAuthorship && result.ok && result.held !== true && result.screenedOut !== true) {
12048
+ const hashes = this.#refusedOnDirectPath.get(memoKey);
12049
+ hashes?.delete(contentHashHex);
12050
+ if (hashes && hashes.size === 0)
12051
+ this.#refusedOnDirectPath.delete(memoKey);
12052
+ /**
12053
+ * ⚠️ RENAMED FROM `…authorship_refusal_reconciled` by `029c` review F4, because the memo it
12054
+ * reads now covers EVERY direct-path refusal and not only the authorship one. Keeping the old
12055
+ * name would have put "no usable proof of who wrote it" on a message that was actually
12056
+ * refused for not decrypting — a wrong cause is worse than a general one.
12057
+ *
12058
+ * The specific reason is already on the operator's notice; what this event adds is that the
12059
+ * refusal did not hold.
12060
+ */
12061
+ this.#logger.warn("content.recover.refusal_reconciled", {
12062
+ agentName, sessionId, correlationId,
12063
+ contentHash: contentHashHex,
12064
+ impact: "THIS EXACT MESSAGE was refused on the direct path and the same content has now been accepted from the relay mailbox, where the sealed envelope proves the sender. The refusal did not hold: the message WAS delivered by the other route. What the direct path could not confirm is still unconfirmed — the mailbox proves WHO sent it and nothing about the check that refused it — so the receipt can show this message arrived without showing everything a directly-delivered one would.",
12065
+ guidance: "Nothing to do about this message. The fix named on the original refusal still stands: until the cause clears, every message on this session takes the slower route and lands with less attached to it.",
12066
+ });
12067
+ }
10056
12068
  return result;
10057
12069
  }
10058
12070
  /**
@@ -12206,31 +14218,355 @@ export class SessionNodeManager {
12206
14218
  });
12207
14219
  }
12208
14220
  }
14221
+ /**
14222
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — SIGN OUR OWN CLAIM, with no relay involved.
14223
+ *
14224
+ * The relay submit has always built these bytes and signed them (`session-relay-client.ts`); this
14225
+ * is the same construction, for the path where no submit happens. It is not a fallback in the
14226
+ * silent sense — it produces exactly the artifact the witnessed path produces, minus the relay's
14227
+ * countersigned position, which was never part of the authorship claim.
14228
+ *
14229
+ * ⚠️ THROWS when this agent has no identity key, and the throw is the correct outcome. It lands in
14230
+ * the direct-send catch, which parks the message to the relay mailbox exactly as a failed dial
14231
+ * does — so the message is not lost, and the operator hears about a local fault instead of a
14232
+ * counterparty who mysteriously stopped receiving. Shipping the frame unsigned would guarantee a
14233
+ * refusal at the far end and blame the wrong machine for it.
14234
+ */
14235
+ async #signOwnContentClaim(agentName, sessionId, entry, contentHash) {
14236
+ const signer = this.#keyProviderResolver?.(agentName);
14237
+ if (!signer) {
14238
+ throw new Error("content_not_signable: this machine has no identity key for this agent, so it cannot sign " +
14239
+ "the message it is about to send and the counterparty would refuse it as unattributable");
14240
+ }
14241
+ // The 16-byte relay session id when this session has one, so a frame built here is
14242
+ // byte-comparable with one built by the submit. Falling back to the local id is not a
14243
+ // second meaning: for every session created without an assignment the two are the same value
14244
+ // (`relaySessionIdBytes` is set from `sessionId` on exactly those paths).
14245
+ const sessionIdBytes = entry.relaySessionIdBytes ?? Uint8Array.from(Buffer.from(sessionId, "hex"));
14246
+ const structure1 = encodeStructure1({
14247
+ contentHash,
14248
+ senderPubkey: await signer.getPublicKey(),
14249
+ sessionId: sessionIdBytes,
14250
+ // The highest counterparty position this session has seen, from the same source the submit
14251
+ // reads. Zero when there is no relay client at all, which is honest: nothing has been
14252
+ // witnessed on this session, so there is no position to acknowledge.
14253
+ lastSeenSeq: entry.relayClient?.lastSeenSeq(Buffer.from(sessionIdBytes).toString("hex")) ?? 0,
14254
+ timestamp: Date.now(),
14255
+ // v1 DELIBERATELY. `last_seen_hash` (v2) is `WITHHOLD-SEAL-1`'s emitter and is not owed here;
14256
+ // a v1 claim makes no content acknowledgement at all, which is honest, where an invented one
14257
+ // would not be.
14258
+ });
14259
+ return { structure1, signature: await signer.sign(structure1) };
14260
+ }
14261
+ /**
14262
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — DID THIS SENDER PROVE THEY WROTE THIS MESSAGE?
14263
+ *
14264
+ * The one place that answers it, for both callers, so "checked" cannot mean two different things
14265
+ * in two places. It takes the signature as an ARGUMENT rather than digging it out of a structure,
14266
+ * which is the whole of the fix: the signature used to be read only from `structure2_cbor` — the
14267
+ * RELAY's record — so a receiver could not check authorship without a relay record, and refusing
14268
+ * on its absence would have made the relay a precondition for reading mail. The content frame now
14269
+ * carries the signature beside the bytes it signs, exactly as `hash_submit` always has, and this
14270
+ * method does not care which of the two handed it over.
14271
+ *
14272
+ * It VERIFIES and it does not LOG. The severity of each verdict differs by caller — the content
14273
+ * frame refuses an `unusable`, the park path shrugs at one — and a method that logged its own
14274
+ * conclusion would either report a refusal that did not happen or stay silent on one that did.
14275
+ */
14276
+ #verifyAuthorshipClaim(agentName, sessionId, structure1Cbor, senderSignature, contentHash) {
14277
+ // Structure 1 content_hash is index 1 and sender_pubkey index 2 in BOTH layouts — 020-ACKHASH
14278
+ // appended last_seen_hash at 6 rather than inserting it, so neither read moved. A v2 claim
14279
+ // decodes here exactly as a v1 one does; its hash is not consulted, because this unit ships
14280
+ // reading and not enforcing.
14281
+ const s1 = decodeStructure1(structure1Cbor);
14282
+ // A layout this build cannot name yields no pubkey and no hash, so there is nothing to check the
14283
+ // signature against. Its reason is carried out so an unreadable CLAIM and a wrong SIGNATURE stay
14284
+ // distinguishable — they take the same outcome by different routes.
14285
+ if (!s1.ok)
14286
+ return { verdict: "unusable", reason: s1.reason };
14287
+ const s1Hash = s1.fields.contentHash;
14288
+ const s1Pubkey = s1.fields.senderPubkey;
14289
+ // The SENDER's Ed25519 signature over the exact signed bytes — the same check the relay
14290
+ // performs. `verify` never throws, so a wrong-width or garbage signature lands here as `false`:
14291
+ // supplied and refuted, which is a different fact from not supplied at all.
14292
+ if (!verify(s1Pubkey, structure1Cbor, senderSignature)) {
14293
+ return { verdict: "refuted", reason: "bad_signature" };
14294
+ }
14295
+ // Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
14296
+ // key. Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from
14297
+ // the IPC param and is never case-normalized, so a string compare would fail for a mixed-case
14298
+ // pubkey and silently strip the canonical ordering from every message in that session.
14299
+ const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
14300
+ if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
14301
+ /**
14302
+ * REFUTED when the counterparty is KNOWN and the signer is someone else.
14303
+ *
14304
+ * This is the session-open MITM detection from the 2026-08-21 T-of-N investigation, which
14305
+ * found this check *"fires correctly, and its answer is thrown away."* A rogue quorum of the
14306
+ * directories holding shares for agent B can sign a false SessionAssignment naming M's key as
14307
+ * B's, and everything downstream is genuinely real — M signs with M's own valid key. Nothing
14308
+ * is missing for A to notice. This comparison is where the substitution shows, because
14309
+ * `counterparty_pubkey` comes from A's own request and is untouched by anything the directory
14310
+ * returns.
14311
+ *
14312
+ * ⚠️ AN EARLIER VERSION OF THIS COMMENT ADDED "it shows only when the record is present" —
14313
+ * true when the proof was optional, and no longer the shape of the code: a content frame with
14314
+ * no checkable proof is refused before it reaches ingest, so M cannot decline to supply one
14315
+ * and be admitted anyway. Rewritten rather than deleted, because that sentence is the evidence
14316
+ * of what the gap was. The park envelope is the remaining path where the ordering record is
14317
+ * genuinely optional, and there the sender's authorship is proven by the envelope's own
14318
+ * signature instead.
14319
+ *
14320
+ * `verified_unmatched` stays soft deliberately: with no counterparty on record we cannot prove
14321
+ * the signer either way, and refusing there would strand a session whose row we failed to read
14322
+ * rather than one that is under attack. It is also the only signal the orphan branch has.
14323
+ */
14324
+ return counterparty
14325
+ ? { verdict: "refuted", reason: "signer_not_counterparty" }
14326
+ : { verdict: "verified_unmatched", senderPubkey: s1Pubkey };
14327
+ }
14328
+ /**
14329
+ * ─── THE BINDING CHECKS RUN LAST, AND THE ORDER IS A SECURITY PROPERTY ───────────────────────
14330
+ *
14331
+ * ⚠️ **THEY USED TO RUN FIRST, AND THAT HANDED THE ATTACKER A FREEZE-SUPPRESSION SWITCH** —
14332
+ * review of `029b`, and it is the finding that mattered most.
14333
+ *
14334
+ * Everything below this line is `unusable`: the message is REFUSED and the session lives.
14335
+ * Everything above it is `refuted`: the session FREEZES. So a check that can answer `unusable`
14336
+ * before the signature has been verified lets a peer choose the softer outcome — flip one
14337
+ * unauthenticated byte of `session_id` inside your own claim and a garbage signature, or a
14338
+ * signature by a MITM's own key, stops being an identity incident and becomes a quiet refusal.
14339
+ * The session-open MITM detection this function exists to serve was bypassable by exactly the
14340
+ * party it detects.
14341
+ *
14342
+ * So the order is: decode → SIGNATURE → SIGNER → then what the proof is about. By the time
14343
+ * either check below runs, the claim provably came from this session's counterparty, and the
14344
+ * only question left is which message and which conversation they made it for.
14345
+ *
14346
+ * `seal-frontier-verify` already does it in this order (verify at :55, session id at :77). This
14347
+ * code had it inverted.
14348
+ */
14349
+ // The claim must bind to THIS content. A signature over somebody else's bytes verifies perfectly
14350
+ // and proves nothing about this message — without this, one signed claim could be replayed onto
14351
+ // every frame that follows it.
14352
+ if (!bytesEqual(s1Hash, contentHash)) {
14353
+ return { verdict: "unusable", reason: AUTHORSHIP_CONTENT_HASH_MISMATCH };
14354
+ }
14355
+ /**
14356
+ * ⚠️ **AND IT MUST BIND TO THIS CONVERSATION** — review M4, ruled in by Andre 2026-09-04.
14357
+ *
14358
+ * Binding the content and the signer is not enough on its own: a claim the counterparty
14359
+ * genuinely signed in another session verifies unchanged here for the same bytes. Not a
14360
+ * stranger and not forged content — a real line of theirs, landing in a transcript it was never
14361
+ * written for, with a signature that checks out. That is worse than an unsigned message,
14362
+ * because the receipt then PROVES something that did not happen.
14363
+ *
14364
+ * `session_id` has been in Structure 1 since v1 and this path never read it.
14365
+ * `seal-frontier-verify` already compares it; the live receive path simply did not.
14366
+ *
14367
+ * **THE TWO VALUES CANNOT DIVERGE, and that is what makes this safe to enforce.** Both are
14368
+ * derived from ONE session id on each side, by construction:
14369
+ * initiator — `sessionId = hex(assignment.session_id)` (`initiate-session-handler`) and
14370
+ * `relayParams.sessionIdBytes = assignment.session_id` (`daemon.ts`);
14371
+ * responder — `acceptSession(parsed.sessionIdHex)` and
14372
+ * `sessionIdBytes = Buffer.from(parsed.sessionIdHex, "hex")` (`inbound-sessions`);
14373
+ * direct/persisted — `relaySessionIdBytes = Buffer.from(sessionId, "hex")`.
14374
+ * The two names exist because one keys the in-memory maps and one goes on the wire, not because
14375
+ * they can hold different values. Getting this wrong would refuse EVERY message on EVERY live
14376
+ * session, so it is stated rather than assumed.
14377
+ *
14378
+ * REFUSED, not frozen — and the sentence is TRUE where it now stands. The signature has
14379
+ * verified and the signer has been matched to this session's counterparty three lines above;
14380
+ * what is wrong is only the conversation the claim was made for, which is a replay rather than
14381
+ * an identity fault. Said because an earlier version of this comment made the same claim from
14382
+ * ABOVE the verification, where none of it had happened yet.
14383
+ */
14384
+ const expectedSessionId = this.#activeNodes.get(this.#k(agentName, sessionId))?.relaySessionIdBytes
14385
+ ?? Uint8Array.from(Buffer.from(sessionId, "hex"));
14386
+ if (!bytesEqual(s1.fields.sessionId, expectedSessionId)) {
14387
+ return { verdict: "unusable", reason: AUTHORSHIP_SESSION_MISMATCH };
14388
+ }
14389
+ return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
14390
+ }
14391
+ /**
14392
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — the refusal an inbound frame gets when its authorship cannot be
14393
+ * established. NOT a freeze: see `AuthorshipVerdict` for why those are different facts.
14394
+ *
14395
+ * Both surfaces, always. The ERROR is the durable forensic record an investigation reads days
14396
+ * later; the notice is the CONTROL — the thing that actually reaches the operator, who otherwise
14397
+ * watches a conversation go quiet and concludes the other person stopped replying.
14398
+ */
14399
+ #refuseUnprovenAuthorship(agentName, sessionId, reason, contentHash, detail, correlationId) {
14400
+ /**
14401
+ * ⚠️ **THREE REASONS, THREE SENTENCES — AND THE THIRD USED TO BORROW THE SECOND'S** (review of
14402
+ * `029b`, and it is the operator half of the same finding as the check order).
14403
+ *
14404
+ * A replayed claim is the one branch on this path that is potentially ADVERSARIAL: a real,
14405
+ * valid, correctly-signed line of your counterparty's, presented in a conversation it was not
14406
+ * written for. It was reaching the operator under the `unusable` wording, which says the proof
14407
+ * was "unreadable, or signed over different content" — neither is true — and under guidance
14408
+ * telling them to go and ask their counterparty to upgrade. A version number is not the
14409
+ * question, and sending someone to chase one spends their attention on the wrong thing.
14410
+ */
14411
+ const impact = reason === "authorship_proof_absent"
14412
+ ? "a message arrived carrying no proof of who wrote it, so it was NOT ingested, NOT shown and NOT attributed to anyone. Every message in this conversation has to be provable to whoever reads its receipt later, and this one could not be."
14413
+ : reason === "authorship_wrong_conversation"
14414
+ ? "a message arrived carrying a VALID signature by this conversation's counterparty — made for a DIFFERENT conversation. The same message, or an old one of theirs, was presented here. It was NOT ingested, NOT shown and NOT added to this conversation's record."
14415
+ : "a message arrived whose proof of authorship could not be checked against it — it was unreadable, or it was signed over different content. It was NOT ingested, NOT shown and NOT attributed to anyone.";
14416
+ /**
14417
+ * ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
14418
+ * operator, and there is nothing on their machine to change — the missing signature is produced
14419
+ * on the sender's. Telling them to do something local would be an affordance that resolves to
14420
+ * nothing. So it names the one move that works (tell them to upgrade) and the one that settles
14421
+ * the other explanation (confirm out of band), and it stops at two.
14422
+ *
14423
+ * ⚠️ **IT USED TO OPEN "Nothing was shown and nothing was stored." THAT SENTENCE WAS FALSE** —
14424
+ * review H1, and it is kept here rather than deleted because it is the exact shape this
14425
+ * milestone exists to catch: a refusal that announces a stronger outcome than it delivers.
14426
+ *
14427
+ * Refusing sends no delivery ACK, so the sender's TTF backstop parks the message and it arrives
14428
+ * through the relay mailbox seconds later, where the ENVELOPE's signature authenticates it and
14429
+ * recovery accepts it — correctly, and with no per-message proof. So the message may well be
14430
+ * delivered, moments after the operator was told it was not. The reconciliation is logged
14431
+ * (`content.recover.refusal_reconciled`) and the sentence below now says what is
14432
+ * actually true of this path: nothing was shown YET, and this refusal does not stop the copy
14433
+ * coming the other way.
14434
+ */
14435
+ const guidance = reason === "authorship_wrong_conversation"
14436
+ ? "STOPPED ON PURPOSE, and this one is NOT a version problem — do not go and ask them about " +
14437
+ "their build. The signature is real and it is theirs; what is wrong is that it was made for " +
14438
+ "another conversation, so something replayed it into this one. That is either software on " +
14439
+ "one of your machines re-sending an old message into the wrong session, or someone in " +
14440
+ "between doing it deliberately. ONE thing to do: ask your counterparty OUT OF BAND (a " +
14441
+ "channel that is not this one) whether they meant to send this, before you continue here."
14442
+ /**
14443
+ * ⚠️ **THIS BRANCH SHIPPED AS `NaNcopy in the relay mailbox…` AND NOTHING NOTICED** — review
14444
+ * F1, and it is worth more than the one-line fix.
14445
+ *
14446
+ * Splitting the guidance in two dropped the opening literal and left behind the `+` that had
14447
+ * joined it, which is not a concatenation with nothing on its left — it is a UNARY PLUS on the
14448
+ * next string. `+"REACH YOU BY…"` is `NaN`, and `NaN + "copy in the relay mailbox…"` is a
14449
+ * perfectly good string. So the flagship refusal of this whole milestone reached the operator
14450
+ * beginning mid-word with `NaN`, with its "STOPPED ON PURPOSE" framing and its reason gone.
14451
+ *
14452
+ * **The test was green because it asked the wrong question.** The only assertion on this
14453
+ * string was `.toMatch(/upgrade/i)`, and "tell them to upgrade" survives at the tail. A
14454
+ * substring match on a sentence cannot see that the sentence lost its head — so the assertion
14455
+ * below pins what it OPENS with, which a truncation cannot survive.
14456
+ */
14457
+ : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
14458
+ // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
14459
+ // key cannot open a mailbox copy either, and telling them to wait for one would be the same
14460
+ // false promise on a different refusal.
14461
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
14462
+ " Almost always their CELLO build is older than this one: a build from before message signing " +
14463
+ "does not attach a signature at all. Ask which version they are running, and tell them to " +
14464
+ "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
14465
+ "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
14466
+ "before opening another session.";
14467
+ this.#logger.error("session.content.refused", {
14468
+ agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
14469
+ });
14470
+ this.noteContentRefusal(agentName, sessionId, reason, { kind: REFUSAL_KINDS.REFUSED, impact, guidance });
14471
+ // Armed AFTER the refusal is filed, so the memo can never claim a refusal that did not happen.
14472
+ this.#noteRefusedOnDirectPath(agentName, sessionId, contentHash);
14473
+ }
14474
+ /**
14475
+ * An inbound content frame refused before it could be read — the ENCRYPTION gate's three causes.
14476
+ *
14477
+ * ⚠️ **THESE LOGGED AND FILED NOTHING, AND THAT IS WHY THIS EXISTS.** All three carried a good
14478
+ * `impact` and `guidance` at ERROR and none of them called `noteContentRefusal`, so the sentences
14479
+ * an operator needed were in a file they have no reason to open. From their chair a message never
14480
+ * arrived and the conversation went quiet — the exact defect `DOD-M15-NO-SILENT-REFUSAL-1` was
14481
+ * built to end, on the same path, three checks above the one that respected it.
14482
+ *
14483
+ * Both surfaces, always: the ERROR is the durable forensic record an investigation reads days
14484
+ * later, and the notice is the control — the thing that actually reaches the person.
14485
+ */
14486
+ /**
14487
+ * Can a refused message still reach this operator through the relay mailbox? — review F2.
14488
+ *
14489
+ * Feature-detected, not assumed: `openContentSeal` is documented OPTIONAL on `KeyProvider`, and
14490
+ * `content-park.ts` refuses recovery without it. Asking the same resolver `content-park.ts` asks
14491
+ * is what keeps the sentence on the operator's screen tied to what their machine can actually do.
14492
+ */
14493
+ #mailboxRouteAvailable(agentName) {
14494
+ const kp = this.#keyProviderResolver?.(agentName);
14495
+ return kp !== undefined && typeof kp.openContentSeal === "function";
14496
+ }
14497
+ #refuseInboundContent(agentName, sessionId, reason, contentHash, detail, correlationId) {
14498
+ // The sentence about the other route is chosen HERE, from what this machine can actually do —
14499
+ // never written into a caller's literal, where it would be a promise nobody re-checked.
14500
+ const guidance = `${detail.guidance} ${this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE}`;
14501
+ this.#logger.error("session.content.refused", { agentName, sessionId, correlationId, reason, ...detail, guidance });
14502
+ this.noteContentRefusal(agentName, sessionId, reason, {
14503
+ kind: REFUSAL_KINDS.REFUSED, impact: detail.impact, guidance,
14504
+ });
14505
+ /**
14506
+ * Review F4 — A PROMISE MADE HERE IS CLOSED IN `recoverParkedEntry`, not left standing.
14507
+ *
14508
+ * The guidance above tells the operator the message may arrive by the mailbox. Both sibling
14509
+ * refusals on this path already arm a memo so the recovery can say the refusal did not hold;
14510
+ * this one armed nothing, so a delivered message would have left a permanent alarm sitting in
14511
+ * `cello_check_notifications` saying it had been turned away.
14512
+ *
14513
+ * Armed AFTER the notice is filed, so the memo can never claim a refusal that did not happen.
14514
+ */
14515
+ this.#noteRefusedOnDirectPath(agentName, sessionId, contentHash);
14516
+ }
12209
14517
  #recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
12210
14518
  try {
12211
- const s1 = decode(structure1Cbor);
12212
14519
  const s2 = decode(structure2Cbor);
12213
- const s1Hash = s1?.[1];
12214
- const s1Pubkey = s1?.[2];
12215
14520
  const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
12216
14521
  const s2Sig = s2?.[3];
12217
- if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
14522
+ if (!(s2Sig instanceof Uint8Array) || seq < 1) {
12218
14523
  // SOFT: we could not read the record, so we learned nothing about the signer either way.
12219
14524
  // Position falls back to the witness stream, exactly as an absent record does.
12220
- this.#logger.warn("session.content.ordering.malformed", { sessionId, correlationId });
14525
+ // The Structure 1 reason is carried so an unreadable RECORD and an unnamed LAYOUT are
14526
+ // distinguishable in the log — they arrive at the same soft outcome by different routes.
14527
+ const s1Layout = decodeStructure1(structure1Cbor);
14528
+ this.#logger.warn("session.content.ordering.malformed", {
14529
+ sessionId,
14530
+ correlationId,
14531
+ ...(s1Layout.ok ? {} : { structure1Reason: s1Layout.reason }),
14532
+ });
12221
14533
  return { seq: null };
12222
14534
  }
12223
- // The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
12224
- const contentHashHex = Buffer.from(contentHash).toString("hex");
12225
- if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
12226
- // SOFT: the record does not describe this content. Nothing is proven about the signer's
12227
- // identity only that this record and these bytes do not belong together.
12228
- this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
14535
+ /**
14536
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` the same verifier the content frame uses, handed the
14537
+ * signature the RELAY committed (`structure2_cbor` index 3) instead of the one the frame
14538
+ * carries. Two claims about the same message, and both must hold: if the relay's copy of the
14539
+ * sender's signature does not verify against the bytes on the frame, one of them has been
14540
+ * altered in flight.
14541
+ *
14542
+ * The verdicts map to this path's own severities, which are NOT the content frame's:
14543
+ * an `unusable` record leaves POSITION unknown and is soft here, because position may always
14544
+ * fall back to the witness stream. Identity is the half that may never be soft, and it is
14545
+ * established before this is called.
14546
+ */
14547
+ const auth = this.#verifyAuthorshipClaim(agentName, sessionId, structure1Cbor, s2Sig, contentHash);
14548
+ if (auth.verdict === "unusable") {
14549
+ if (auth.reason === AUTHORSHIP_CONTENT_HASH_MISMATCH) {
14550
+ // SOFT: the record does not describe this content. Nothing is proven about the signer's
14551
+ // identity — only that this record and these bytes do not belong together.
14552
+ this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
14553
+ }
14554
+ else if (auth.reason === AUTHORSHIP_SESSION_MISMATCH) {
14555
+ // Its own name, because `…malformed` points a reader at a decoder and this record decoded
14556
+ // perfectly — it belongs to another conversation. Unreachable in practice on this path:
14557
+ // `authenticateParkedEntry` binds `session_id` in the park TBS before anything is
14558
+ // unsealed, so a mismatched record cannot get this far. Named anyway, because an event
14559
+ // that lies about its cause is worse the day it does fire.
14560
+ this.#logger.warn("session.content.ordering.session_mismatch", { sessionId, correlationId });
14561
+ }
14562
+ else {
14563
+ this.#logger.warn("session.content.ordering.malformed", {
14564
+ sessionId, correlationId, structure1Reason: auth.reason,
14565
+ });
14566
+ }
12229
14567
  return { seq: null };
12230
14568
  }
12231
- // Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
12232
- // check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
12233
- if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
14569
+ if (auth.verdict === "refuted" && auth.reason === "bad_signature") {
12234
14570
  // FATAL. The sender supplied a signature and it does not verify against the key inside its
12235
14571
  // own record. That is not an absence we could not resolve — it is a proof that failed.
12236
14572
  this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
@@ -12240,41 +14576,23 @@ export class SessionNodeManager {
12240
14576
  // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
12241
14577
  // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
12242
14578
  // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
12243
- // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
12244
- // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
12245
- // pubkey and silently strip the canonical ordering from every message in that session.
12246
- const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
12247
- if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
14579
+ if (auth.verdict !== "verified") {
12248
14580
  /**
12249
- * FATAL when the counterparty is KNOWN and the signer is someone else. SOFT when we simply
12250
- * do not know who the counterparty is.
12251
- *
12252
- * The fatal half is the session-open MITM detection from the 2026-08-21 T-of-N
12253
- * investigation, which found this check *"fires correctly, and its answer is thrown away."*
12254
- * A rogue quorum of the directories holding shares for agent B can sign a false
12255
- * SessionAssignment naming M's key as B's, and everything downstream is genuinely real —
12256
- * M signs with M's own valid key. Nothing is missing for A to notice. This comparison is
12257
- * where the substitution shows, because `counterparty_pubkey` comes from A's own request
12258
- * and is untouched by anything the directory returns.
12259
- *
12260
- * ⚠️ IT SHOWS ONLY WHEN THE RECORD IS PRESENT (review F3). An earlier version of this
12261
- * comment said this was "the one place the substitution shows", full stop — and that
12262
- * asserted a property the code does not have: M can decline to supply an ordering record
12263
- * and be ingested without ever reaching this line. The caller logs
12264
- * `session.content.ordering.absent` so the weaker case is at least visible, and closing it
12265
- * needs a check that does not depend on the sender's cooperation — the relay's independent
12266
- * copy, `DOD-M15-CORROBORATE-1`.
14581
+ * FATAL when the counterparty is KNOWN and the signer is someone else (`refuted`). SOFT when
14582
+ * we simply do not know who the counterparty is (`verified_unmatched`) — the reasoning for
14583
+ * both, and the MITM substitution this catches, lives on `#verifyAuthorshipClaim`.
12267
14584
  *
12268
- * The soft half stays soft deliberately: `counterparty_unknown` means we cannot prove the
12269
- * signer either way, and refusing there would strand sessions whose record we failed to
12270
- * read rather than sessions that are under attack.
14585
+ * The soft half is the one that matters HERE: `counterparty_unknown` means we cannot prove
14586
+ * the signer either way, so we decline to take a POSITION from a record we cannot attribute,
14587
+ * and the caller falls back to the witness stream. Nothing about the message is refused on
14588
+ * this path — that decision was already made, on the frame's own proof.
12271
14589
  */
12272
- const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
14590
+ const reason = auth.verdict === "refuted" ? auth.reason : "counterparty_unknown";
12273
14591
  this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
12274
- return counterparty ? { seq: null, fatal: { reason } } : { seq: null };
14592
+ return auth.verdict === "refuted" ? { seq: null, fatal: { reason } } : { seq: null };
12275
14593
  }
12276
14594
  // Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
12277
- this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
14595
+ this.recordWitnessedSequence(agentName, sessionId, Buffer.from(contentHash).toString("hex"), seq - 1);
12278
14596
  this.#logger.info("session.content.ordering.recorded", {
12279
14597
  sessionId,
12280
14598
  canonicalSeq: seq - 1,
@@ -12282,18 +14600,15 @@ export class SessionNodeManager {
12282
14600
  correlationId,
12283
14601
  });
12284
14602
  /**
12285
- * DOD-M15-SEALWIRE-1 bullet 5: return the VERIFIED proof, not just the position.
14603
+ * THE POSITION, AND ONLY THE POSITION `DOD-M15-AUTHORSHIP-ABSENT-1`.
12286
14604
  *
12287
- * Three lines above, `verify(s1Pubkey, structure1Cbor, s2Sig)` has already passed and the
12288
- * signer has been matched to this session's counterparty. That is the strongest statement
12289
- * this daemon ever makes about who wrote a message and until now it was made, used to
12290
- * decide a sequence number, and then discarded. The transcript row that outlives it recorded
12291
- * only a direction.
12292
- *
12293
- * Returned rather than stashed, for the same reason `seq` is: a caller that has to go looking
12294
- * for it in a side map is a caller that will not.
14605
+ * `DOD-M15-SEALWIRE-1` bullet 5 had this return the verified proof as well, because this was
14606
+ * the only place a signer was ever checked and the transcript row needed it from somewhere.
14607
+ * The frame now carries the sender's signature beside the bytes it signs and the caller
14608
+ * verifies it before calling this at all, so the proof reaches the transcript from there. What
14609
+ * this answers is the question it is named for: WHERE the relay says this message sits.
12295
14610
  */
12296
- return { seq: seq - 1, senderPubkey: s1Pubkey, senderSig: s2Sig };
14611
+ return { seq: seq - 1 };
12297
14612
  }
12298
14613
  catch (err) {
12299
14614
  this.#logger.warn("session.content.ordering.decode_failed", {
@@ -12535,40 +14850,37 @@ export class SessionNodeManager {
12535
14850
  const encState = this.#contentEncryptionState(agentName, sessionId);
12536
14851
  let plaintextBody;
12537
14852
  if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
12538
- this.#logger.error("session.content.refused", {
12539
- agentName, sessionId, correlationId,
12540
- reason: "content_encryption_absent_or_unknown",
14853
+ this.#refuseInboundContent(agentName, sessionId, "content_encryption_absent_or_unknown", contentHash, {
12541
14854
  declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
12542
- impact: "the frame did not say it was encrypted under this session's key, so it was refused unread. Nothing was shown and nothing was stored.",
14855
+ impact: "the frame did not say it was encrypted under this session's key, so it was refused unread nothing was shown and this copy was not kept.",
12543
14856
  guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
12544
14857
  "This build never sends one, so either something between you rewrote the frame, or your " +
12545
14858
  "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
12546
14859
  "before opening another session.",
12547
- });
14860
+ }, correlationId);
12548
14861
  return;
12549
14862
  }
12550
14863
  if (encState.key === null) {
12551
- this.#logger.error("session.content.refused", {
12552
- agentName, sessionId, correlationId,
12553
- reason: "no_session_key",
14864
+ this.#refuseInboundContent(agentName, sessionId, "no_session_key", contentHash, {
12554
14865
  detail: encState.reason,
12555
14866
  impact: "an encrypted message arrived and this side has no agreed key to open it, so it was refused unread rather than shown as garbage.",
12556
- guidance: CONTENT_ENCRYPTION_GUIDANCE[encState.reason],
12557
- });
14867
+ // Review F6: the RECEIVE-side wording. The send-side table explains what became of a
14868
+ // message this operator sent, which is the wrong direction entirely for a message they
14869
+ // cannot open.
14870
+ guidance: CONTENT_ENCRYPTION_INBOUND_GUIDANCE[encState.reason],
14871
+ }, correlationId);
12558
14872
  return;
12559
14873
  }
12560
14874
  const opened = openSessionContent(encState.key, contentBytes);
12561
14875
  if (opened === null) {
12562
14876
  // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
12563
14877
  // side must not branch on which — that would be branching on attacker-controlled input.
12564
- this.#logger.error("session.content.refused", {
12565
- agentName, sessionId, correlationId,
12566
- reason: "decrypt_failed",
14878
+ this.#refuseInboundContent(agentName, sessionId, "decrypt_failed", contentHash, {
12567
14879
  impact: "the message did not decrypt under this session's agreed key — it was modified in flight, or it was encrypted under a different key. Refused unread.",
12568
- guidance: "STOPPED ON PURPOSE. Nothing was shown and nothing was stored. A message that fails this " +
12569
- "check has either been altered on its way to you or was not encrypted for this session. " +
12570
- "Confirm with your counterparty OUT OF BAND, then start a new session.",
12571
- });
14880
+ guidance: "STOPPED ON PURPOSE. Nothing was shown and this copy was not kept. A message that fails " +
14881
+ "this check has either been altered on its way to you or was not encrypted for this " +
14882
+ "session. Confirm with your counterparty OUT OF BAND, then start a new session.",
14883
+ }, correlationId);
12572
14884
  return;
12573
14885
  }
12574
14886
  plaintextBody = opened;
@@ -12586,6 +14898,12 @@ export class SessionNodeManager {
12586
14898
  // different fact, and it is now refused.
12587
14899
  const s1Cbor = frame["structure1_cbor"];
12588
14900
  const s2Cbor = frame["structure2_cbor"];
14901
+ /**
14902
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — the sender's own signature, carried BESIDE the bytes it
14903
+ * signs, exactly as `hash_submit` has always carried it. This field is why identity no longer
14904
+ * depends on the relay: it arrives whether or not a relay witnessed the message.
14905
+ */
14906
+ const senderSig = frame["sender_signature"];
12589
14907
  let framedSeq = null;
12590
14908
  /**
12591
14909
  * DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
@@ -12595,36 +14913,93 @@ export class SessionNodeManager {
12595
14913
  * and the transcript row must say so rather than imply a proof it does not have.
12596
14914
  */
12597
14915
  let verifiedAuthorship;
12598
- if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
14916
+ /**
14917
+ * 024-ORPHANTRIAGE: the signer when the signature verified but there was no counterparty to
14918
+ * match it against. Its ONLY consumer is the orphan branch inside ingest — everywhere else a
14919
+ * session record exists, so this stays `undefined` and nothing reads it.
14920
+ */
14921
+ let verifiedSignerUnmatched;
14922
+ /**
14923
+ * ─── NO PASSPORT, NO ENTRY — `DOD-M15-AUTHORSHIP-ABSENT-1` ───────────────────────────────
14924
+ *
14925
+ * ⚠️ **THIS COMMENT USED TO SAY THE OPPOSITE, AND THE SENTENCE IT REPLACES IS THE DEFECT.**
14926
+ * It read: *"it means the per-message signer check is **opt-in for the sender** — a party that
14927
+ * passed the peer gate and wants to avoid the comparison simply omits the proof."* That was an
14928
+ * accurate description of the code, which is why it is rewritten here rather than deleted: it
14929
+ * is the sentence a reader with a coding agent finds, and it must now describe what the code
14930
+ * does. A frame that supplies nothing checkable is REFUSED. Omitting the proof buys the sender
14931
+ * nothing except a message that does not arrive.
14932
+ *
14933
+ * The old reasoning was sound as far as it went — the signature was only ever DELIVERED inside
14934
+ * the relay's Structure 2, so refusing on its absence would have made the relay a precondition
14935
+ * for reading mail. It stopped one field short: the signature travels beside the bytes it
14936
+ * signs now, on every content frame, so identity no longer needs the relay and position still
14937
+ * does not require identity.
14938
+ *
14939
+ * ⚠️ REFUSED, NOT FROZEN. A frozen session is only cleared by opening a new one, and the
14940
+ * overwhelmingly likely cause of an absent proof is a counterparty on an older build. The
14941
+ * freeze is for a proof that FAILED (below, and in `#recordFrameOrdering`) — a positive fact
14942
+ * about their key.
14943
+ */
14944
+ if (!(s1Cbor instanceof Uint8Array) || !(senderSig instanceof Uint8Array)) {
14945
+ this.#refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_absent", contentHash, {
14946
+ // WHICH half is missing. A sender on an older build supplies neither; a stripped frame is
14947
+ // likelier to be missing one, and an investigator should not have to guess which.
14948
+ hasStructure1: s1Cbor instanceof Uint8Array,
14949
+ hasSenderSignature: senderSig instanceof Uint8Array,
14950
+ }, correlationId);
14951
+ return;
14952
+ }
14953
+ const authorship = this.#verifyAuthorshipClaim(agentName, sessionId, s1Cbor, senderSig, contentHash);
14954
+ if (authorship.verdict === "refuted") {
14955
+ /**
14956
+ * THE FORENSIC LINE, BEFORE THE FREEZE. `session.content.identity.frozen` records that a
14957
+ * session was stopped; this records WHICH check stopped it and on WHICH proof — the frame's
14958
+ * own signature, not the relay's copy of it. The two used to be the same event because there
14959
+ * was only one place a signer was checked; there are two now, and an investigation that
14960
+ * cannot tell them apart is looking at the wrong half of the wire.
14961
+ */
14962
+ this.#logger.warn("session.content.authorship.refuted", {
14963
+ agentName, sessionId, correlationId, reason: authorship.reason,
14964
+ impact: "a message arrived with a proof of authorship that FAILED — it does not verify, or it is signed by a key that is not this session's counterparty. Nothing was ingested and the session is being frozen.",
14965
+ });
14966
+ await this.#freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
14967
+ return;
14968
+ }
14969
+ if (authorship.verdict === "unusable") {
14970
+ // A replayed claim gets its own name on BOTH surfaces, not just in the log context: it is
14971
+ // the one `unusable` cause that may be adversarial, and it is the one the operator can act
14972
+ // on. The others are a peer whose build or bytes we could not read.
14973
+ this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH ? "authorship_wrong_conversation" : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
14974
+ return;
14975
+ }
14976
+ if (authorship.verdict === "verified") {
14977
+ verifiedAuthorship = { senderPubkey: authorship.senderPubkey, senderSig: authorship.senderSig };
14978
+ }
14979
+ else {
14980
+ verifiedSignerUnmatched = authorship.senderPubkey;
14981
+ }
14982
+ if (s2Cbor instanceof Uint8Array) {
12599
14983
  const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
12600
14984
  if (ordering.fatal) {
12601
14985
  await this.#freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
12602
14986
  return;
12603
14987
  }
12604
14988
  framedSeq = ordering.seq;
12605
- if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
12606
- verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
12607
- }
12608
14989
  }
12609
14990
  else {
12610
14991
  /**
12611
- * Review F3 THE WEAKER GUARANTEE MUST NOT BE INDISTINGUISHABLE FROM THE STRONGER ONE.
12612
- *
12613
- * A frame with no ordering record is still ingested, and that is correct: it is the
12614
- * documented relay-degraded path, and refusing it would make the relay a precondition for
12615
- * reading mail. But it means the per-message signer check is **opt-in for the sender** — a
12616
- * party that passed the peer gate and wants to avoid the comparison simply omits the proof.
12617
- * Silently, until now: nothing recorded that a message arrived unverified, so the log looked
12618
- * identical to one where every message had been checked.
14992
+ * POSITION IS THE ONLY THING THAT CAN BE ABSENT NOW, and this event is about position.
12619
14993
  *
12620
- * Not fatal, and deliberately not: an absent record proves nothing about the signer, and
12621
- * refusing on an absence would strand every relay-degraded session. What closes the omission
12622
- * case is relay-side corroboration `DOD-M15-CORROBORATE-1` where the relay holds the
12623
- * sender's signed hash independently and never routes it through this daemon.
14994
+ * It fires on the relay-degraded path, where the sender had no witnessed record to stamp
14995
+ * on. The message is ingested its author is proven, above, by the frame's own signature —
14996
+ * and only its place in the canonical sequence falls back to the witness stream. Refusing
14997
+ * here would make the relay a precondition for reading mail, which is the thing this unit
14998
+ * was careful NOT to do.
12624
14999
  */
12625
15000
  this.#logger.info("session.content.ordering.absent", {
12626
15001
  agentName, sessionId, correlationId,
12627
- impact: "this frame carried no signed ordering record, so its SIGNER was not verified for this message it was ingested on the strength of the authenticated transport alone",
15002
+ impact: "this frame carried no relay ordering record, so its POSITION in the canonical sequence is not known from the frame and falls back to the witness stream. Its AUTHOR was verified from the frame's own signature.",
12628
15003
  });
12629
15004
  }
12630
15005
  // AC-001: carry the sender's correlationId from the frame into the receive
@@ -12641,7 +15016,7 @@ export class SessionNodeManager {
12641
15016
  const ingest = await this.ingestReceivedContent(
12642
15017
  // THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
12643
15018
  // the delivery buffer) works on plaintext, exactly as it did before this layer existed.
12644
- agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
15019
+ agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship, verifiedSignerUnmatched);
12645
15020
  // AC-001: after the content is durably ingested AND its hash cross-check
12646
15021
  // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
12647
15022
  // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF