@cello-protocol/daemon 0.0.188 → 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.
@@ -23,7 +23,7 @@
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";
@@ -38,7 +38,7 @@ import { publishableEndpoint, relayOnlyState } from "./relay-only.js";
38
38
  import { randomUUID, createHash, randomBytes } from "node:crypto";
39
39
  import * as lp from "it-length-prefixed";
40
40
  import { decode } from "cbor-x";
41
- import { encodeCbor, decodeStructure1 } from "@cello-protocol/protocol-types";
41
+ import { encodeCbor, decodeStructure1, encodeStructure1 } from "@cello-protocol/protocol-types";
42
42
  import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
43
43
  import { SessionConnectionGater } from "./session-connection-gater.js";
44
44
  import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
@@ -381,6 +381,127 @@ export const REVIVE_RESERVATION_CANDIDATES = 2;
381
381
  * the healthy direct latency and far below anything a person would notice.
382
382
  */
383
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
+ }
384
505
  /**
385
506
  * The relay's peer id out of a circuit listen address, or `null` if the address does not name one.
386
507
  *
@@ -911,6 +1032,21 @@ export class SessionNodeManager {
911
1032
  /** In-flight grace timers, keyed session+hash, so a redelivered leaf does not schedule a second
912
1033
  * fetch for the same content — a slow relay must not be turned into a storm against itself. */
913
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();
914
1050
  /** Test seam: collapse the grace window so a test does not have to wait two real seconds. The
915
1051
  * window itself is covered by its own case. */
916
1052
  #leafFetchGraceMs = LEAF_FETCH_GRACE_MS;
@@ -1324,6 +1460,26 @@ export class SessionNodeManager {
1324
1460
  * entry is removed the moment it is reconciled.
1325
1461
  */
1326
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();
1327
1483
  // DOD-MSG-4 (strict in-order): the RELAY is the ordering authority (Structure 2). For each
1328
1484
  // message the relay witnesses, it delivers B a (content_hash -> canonical sequence) binding via
1329
1485
  // the leaf_deliver stream. B records it here — keyed #k(agent,session) -> (contentHashHex -> seq)
@@ -2111,9 +2267,17 @@ export class SessionNodeManager {
2111
2267
  -- else — which is the whole point of a notarized record.
2112
2268
  --
2113
2269
  -- sender_sig holds one of TWO things, and which one is told by direction:
2114
- -- RECEIVED row -> the Structure-2 signature, stored ONLY after the receiver verified it
2115
- -- against the pubkey inside the sender's own signed bytes
2116
- -- (#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.
2117
2281
  -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
2118
2282
  -- from the submit result. Produced, not verified — there was no
2119
2283
  -- counterparty in the act, so it must NEVER be labelled verified_signature.
@@ -2121,11 +2285,16 @@ export class SessionNodeManager {
2121
2285
  -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
2122
2286
  -- Named here because it is the same shape this column exists to prevent, one level up: a
2123
2287
  -- provable sent row and an unprovable one share a label, so a reader keying on attribution
2124
- -- alone cannot tell them apart. An unprovable sent row is legitimate — an UNWITNESSED send
2125
- -- never put a Structure 1 on the wire, so there is nothing signed to store — but the reader
2126
- -- has to be told where the distinction lives, or it will be rediscovered as a bug.
2288
+ -- alone cannot tell them apart.
2127
2289
  -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
2128
- -- 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.
2129
2298
  --
2130
2299
  -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
2131
2300
  -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
@@ -2135,7 +2304,7 @@ export class SessionNodeManager {
2135
2304
  -- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
2136
2305
  -- impossible rather than merely discouraged.
2137
2306
  sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
2138
- 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
2139
2308
  attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
2140
2309
  PRIMARY KEY (agent_id, session_id, sequence, direction)
2141
2310
  )
@@ -2349,6 +2518,129 @@ export class SessionNodeManager {
2349
2518
  last_at INTEGER NOT NULL,
2350
2519
  PRIMARY KEY (agent_id, session_id, reason)
2351
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
+ )
2352
2644
  `);
2353
2645
  this.#db.exec(`
2354
2646
  CREATE TABLE IF NOT EXISTS content_refusal_reads (
@@ -3467,6 +3759,25 @@ export class SessionNodeManager {
3467
3759
  * a reason the caller is expected to log.
3468
3760
  */
3469
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) {
3470
3781
  if (!this.#db)
3471
3782
  return null;
3472
3783
  try {
@@ -5326,7 +5637,20 @@ export class SessionNodeManager {
5326
5637
  // session ends, most sharply for `session_committed` — a refusal that exists only because the
5327
5638
  // session was already sealed. Dropping them at seal would delete exactly the ones a sealed
5328
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);
5329
5652
  this.#unreadableAlgSeen.delete(key);
5653
+ this.#refusedOnDirectPath.delete(key);
5330
5654
  this.#responderSealSubmitted.delete(key);
5331
5655
  // DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
5332
5656
  // so a torn-down session retains no stale ordering state or buffered plaintext.
@@ -7107,6 +7431,12 @@ export class SessionNodeManager {
7107
7431
  // the leaf_deliver witness stream / arrival order.
7108
7432
  let orderingS1;
7109
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;
7110
7440
  /**
7111
7441
  * DOD-M15-SEALWIRE-1 bullet 5, SENT half. Our own Ed25519 signature over `orderingS1`.
7112
7442
  *
@@ -7135,6 +7465,11 @@ export class SessionNodeManager {
7135
7465
  if (witnessed.ok) {
7136
7466
  orderingS1 = witnessed.structure1_cbor;
7137
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;
7138
7473
  /**
7139
7474
  * PAIRED WITH THE BYTES IT SIGNS, in one place, so the two can never be assigned apart.
7140
7475
  *
@@ -7154,8 +7489,10 @@ export class SessionNodeManager {
7154
7489
  * tell "the relay never witnessed this" from "we witnessed it, held the proof, and
7155
7490
  * dropped it decoding our own bytes."
7156
7491
  *
7157
- * And the asymmetry with the received half is the argument. `#recordFrameOrdering` is
7158
- * 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.
7159
7496
  * Here **we produced them**, in `session-relay-client.ts`, moments earlier. A failure
7160
7497
  * means our own encoder and decoder disagree: an internal invariant break that would
7161
7498
  * strip authorship from every sent row for the life of the process. Soft is still right
@@ -7204,7 +7541,7 @@ export class SessionNodeManager {
7204
7541
  * two different submits would all have been persisted as a row that **looks checkable
7205
7542
  * to an auditor and fails** — strictly worse than the honest unproven row it replaced.
7206
7543
  *
7207
- * The received half has always done this (`#recordFrameOrdering` verifies before
7544
+ * The received half has always done this (`#verifyAuthorshipClaim` verifies before
7208
7545
  * storing and treats a failure as fatal). The sent half did not, and every ingredient
7209
7546
  * was already in scope on this line.
7210
7547
  *
@@ -7372,6 +7709,72 @@ export class SessionNodeManager {
7372
7709
  // connection). See the note on #handleContentStream's finally.
7373
7710
  let sendStream;
7374
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
+ }
7375
7778
  /**
7376
7779
  * 🚨 NO KEY, NO DIRECT SEND — `DOD-M15-EPHEMERAL-AUTH-1`, and there is no plaintext fallback.
7377
7780
  *
@@ -7383,11 +7786,12 @@ export class SessionNodeManager {
7383
7786
  * frame and a system that "carries on, degraded" gives up the body while the operator reads a
7384
7787
  * warning they have learned to scroll past. That is why this is a throw and not a warning.
7385
7788
  */
7386
- const encState = this.#contentEncryptionState(agentName, sessionId);
7387
- if (encState.key === null) {
7388
- 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]}`);
7389
7794
  }
7390
- const sessionKey = encState.key;
7391
7795
  const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
7392
7796
  sendStream = stream;
7393
7797
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
@@ -7414,8 +7818,36 @@ export class SessionNodeManager {
7414
7818
  * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
7415
7819
  * transcript, the seal and the salted hash all depend on it meaning what it means today, and
7416
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.
7417
7841
  */
7418
- 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);
7419
7851
  const frame = encodeCbor({
7420
7852
  type: "content_frame",
7421
7853
  session_id: sessionId,
@@ -7428,9 +7860,12 @@ export class SessionNodeManager {
7428
7860
  // DOD-MSG-4 (self-ordering): the relay's signed ordering record, so the receiver verifies +
7429
7861
  // orders from the frame ALONE (no dependence on the separate leaf_deliver witness timing).
7430
7862
  // structure1_cbor = sender-signed bytes (verify); structure2_cbor = relay's committed seq +
7431
- // prev_root (order). Omitted if the relay was unreachable — receiver falls back to the witness.
7432
- structure1_cbor: orderingS1,
7433
- 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,
7434
7869
  // DOD-M15-SEALWIRE-1 part B2b: HOW `content_hash` was produced. An older peer ignores an
7435
7870
  // unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
7436
7871
  // it and verifies under the named algorithm instead of assuming.
@@ -7462,7 +7897,17 @@ export class SessionNodeManager {
7462
7897
  return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
7463
7898
  }
7464
7899
  catch (err) {
7465
- 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
+ });
7466
7911
  if (sendStream !== undefined) {
7467
7912
  try {
7468
7913
  sendStream.abort(err instanceof Error ? err : new Error(String(err)));
@@ -8041,6 +8486,27 @@ export class SessionNodeManager {
8041
8486
  }
8042
8487
  byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
8043
8488
  }
8489
+ /**
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
+ }
8044
8510
  /**
8045
8511
  * ─── DOD-M15-NO-SILENT-REFUSAL-1: refusals the RECEIVING operator can actually see ────────────
8046
8512
  *
@@ -8090,16 +8556,61 @@ export class SessionNodeManager {
8090
8556
  throw new Error("database is not open");
8091
8557
  const agentId = this.#requireAgentId(agentName);
8092
8558
  const now = Date.now();
8093
- // `count` grows on conflict; impact and guidance are refreshed, because a later refusal of the
8094
- // same reason may know more than the first (the salt branch has four causes and names them).
8095
- this.#db
8096
- .prepare(`INSERT INTO content_refusal_notices
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
8097
8579
  (agent_id, session_id, reason, kind, impact, guidance, count, first_at, last_at)
8098
8580
  VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
8099
8581
  ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
8100
8582
  count = count + 1, kind = excluded.kind, impact = excluded.impact,
8101
8583
  guidance = excluded.guidance, last_at = excluded.last_at`)
8102
- .run(agentId, sessionId, reason, detail.kind, detail.impact, detail.guidance, now, now);
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
+ }
8103
8614
  }
8104
8615
  catch (err) {
8105
8616
  this.#logger.error("session.refusal.persist.failed", {
@@ -8272,21 +8783,29 @@ export class SessionNodeManager {
8272
8783
  */
8273
8784
  const rows = (sessionId === undefined
8274
8785
  ? this.#db
8275
- .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
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
8276
8788
  FROM content_refusal_notices n
8277
8789
  LEFT JOIN content_refusal_reads r
8278
8790
  ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8279
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
8280
8795
  WHERE n.agent_id = ?
8281
8796
  AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8282
8797
  ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
8283
8798
  .all(consumerId, agentId, MAX_REFUSALS_PER_READ + 1)
8284
8799
  : this.#db
8285
- .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
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
8286
8802
  FROM content_refusal_notices n
8287
8803
  LEFT JOIN content_refusal_reads r
8288
8804
  ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8289
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
8290
8809
  WHERE n.agent_id = ? AND n.session_id = ?
8291
8810
  AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8292
8811
  ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
@@ -8323,7 +8842,21 @@ export class SessionNodeManager {
8323
8842
  kind: row.kind,
8324
8843
  impact: row.impact,
8325
8844
  guidance: row.guidance,
8326
- count: row.count,
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 }),
8327
8860
  ...(firstTime ? {} : { repeat: true }),
8328
8861
  });
8329
8862
  }
@@ -8359,9 +8892,12 @@ export class SessionNodeManager {
8359
8892
  if (!oldest.done)
8360
8893
  notice.surfacedTo.delete(oldest.value);
8361
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.
8362
8898
  fromFallback.push({
8363
8899
  sessionId: sid, reason, kind: notice.kind, impact: notice.impact,
8364
- guidance: notice.guidance, count: notice.count,
8900
+ guidance: notice.guidance, timesSinceDismissed: notice.count,
8365
8901
  ...(firstTime ? {} : { repeat: true }),
8366
8902
  });
8367
8903
  }
@@ -8579,12 +9115,19 @@ export class SessionNodeManager {
8579
9115
  contentHashAlgIn,
8580
9116
  /**
8581
9117
  * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
8582
- * has one. The caller is the only place that has it — `#recordFrameOrdering` verifies the
8583
- * signature against the key inside the sender's own signed bytes and matches the signer to this
8584
- * 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.
9122
+ *
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.
8585
9128
  *
8586
- * Optional, because the soft decode-failure path ingests without it. The row records which it
8587
- * was, so absence is never silent.
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.
8588
9131
  */
8589
9132
  verifiedAuthorship,
8590
9133
  /**
@@ -8706,6 +9249,15 @@ export class SessionNodeManager {
8706
9249
  this.#quarantineRefusedContent(agentName, sessionId, "session_committed", content, contentHashHex, {
8707
9250
  senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
8708
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
+ */
8709
9261
  // DOD-M15-NO-SILENT-REFUSAL-1. `currentStatus` on the log line carries the REAL status —
8710
9262
  // sealed, seal_interrupted_pending or abandoned — and the notice must not flatten those into
8711
9263
  // one claim, so it names the record as frozen rather than asserting which way it ended.
@@ -9575,10 +10127,172 @@ export class SessionNodeManager {
9575
10127
  this.#leafFetchTimers.delete(timerKey);
9576
10128
  }
9577
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
+ }
9578
10288
  #scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
9579
10289
  const key = this.#k(agentName, sessionId);
9580
10290
  if (this.#resolvedContent.get(key)?.has(contentHashHex))
9581
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;
9582
10296
  const timerKey = `${key}::${contentHashHex}`;
9583
10297
  // ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence —
9584
10298
  // scheduling per redelivery turns a slow relay into a storm against itself.
@@ -9910,9 +10624,13 @@ export class SessionNodeManager {
9910
10624
  originalContent,
9911
10625
  /**
9912
10626
  * DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
9913
- * that has it — `#recordFrameOrdering` verified this signature against the pubkey inside the
9914
- * sender's own signed bytes and matched the signer to this session's counterparty. It reaches
9915
- * 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.
9916
10634
  *
9917
10635
  * Undefined on the held-release and soft-fallback paths; the row records that as
9918
10636
  * `local_session_state` rather than leaving it indistinguishable from a proven one.
@@ -11257,6 +11975,14 @@ export class SessionNodeManager {
11257
11975
  */
11258
11976
  const memoKey = this.#k(agentName, sessionId);
11259
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;
11260
11986
  const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
11261
11987
  // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
11262
11988
  // `sha256` and is exactly right for a peer that predates the field.
@@ -11297,6 +12023,48 @@ export class SessionNodeManager {
11297
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.",
11298
12024
  });
11299
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
+ }
11300
12068
  return result;
11301
12069
  }
11302
12070
  /**
@@ -13450,41 +14218,355 @@ export class SessionNodeManager {
13450
14218
  });
13451
14219
  }
13452
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
+ }
13453
14517
  #recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
13454
14518
  try {
13455
- // Structure 1 content_hash is index 1 and sender_pubkey index 2 in BOTH layouts — 020-ACKHASH
13456
- // appended last_seen_hash at 6 rather than inserting it, so neither read moved. A v2 claim
13457
- // decodes here exactly as a v1 one does; its hash is not consulted, because this unit ships
13458
- // reading and not enforcing.
13459
- const s1 = decodeStructure1(structure1Cbor);
13460
14519
  const s2 = decode(structure2Cbor);
13461
- const s1Hash = s1.ok ? s1.fields.contentHash : undefined;
13462
- const s1Pubkey = s1.ok ? s1.fields.senderPubkey : undefined;
13463
14520
  const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
13464
14521
  const s2Sig = s2?.[3];
13465
- if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
14522
+ if (!(s2Sig instanceof Uint8Array) || seq < 1) {
13466
14523
  // SOFT: we could not read the record, so we learned nothing about the signer either way.
13467
14524
  // Position falls back to the witness stream, exactly as an absent record does.
13468
14525
  // The Structure 1 reason is carried so an unreadable RECORD and an unnamed LAYOUT are
13469
14526
  // distinguishable in the log — they arrive at the same soft outcome by different routes.
14527
+ const s1Layout = decodeStructure1(structure1Cbor);
13470
14528
  this.#logger.warn("session.content.ordering.malformed", {
13471
14529
  sessionId,
13472
14530
  correlationId,
13473
- ...(s1.ok ? {} : { structure1Reason: s1.reason }),
14531
+ ...(s1Layout.ok ? {} : { structure1Reason: s1Layout.reason }),
13474
14532
  });
13475
14533
  return { seq: null };
13476
14534
  }
13477
- // The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
13478
- const contentHashHex = Buffer.from(contentHash).toString("hex");
13479
- if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
13480
- // SOFT: the record does not describe this content. Nothing is proven about the signer's
13481
- // identity only that this record and these bytes do not belong together.
13482
- 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
+ }
13483
14567
  return { seq: null };
13484
14568
  }
13485
- // Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
13486
- // check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
13487
- if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
14569
+ if (auth.verdict === "refuted" && auth.reason === "bad_signature") {
13488
14570
  // FATAL. The sender supplied a signature and it does not verify against the key inside its
13489
14571
  // own record. That is not an absence we could not resolve — it is a proof that failed.
13490
14572
  this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
@@ -13494,55 +14576,23 @@ export class SessionNodeManager {
13494
14576
  // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
13495
14577
  // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
13496
14578
  // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
13497
- // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
13498
- // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
13499
- // pubkey and silently strip the canonical ordering from every message in that session.
13500
- const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
13501
- if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
14579
+ if (auth.verdict !== "verified") {
13502
14580
  /**
13503
- * FATAL when the counterparty is KNOWN and the signer is someone else. SOFT when we simply
13504
- * do not know who the counterparty is.
13505
- *
13506
- * The fatal half is the session-open MITM detection from the 2026-08-21 T-of-N
13507
- * investigation, which found this check *"fires correctly, and its answer is thrown away."*
13508
- * A rogue quorum of the directories holding shares for agent B can sign a false
13509
- * SessionAssignment naming M's key as B's, and everything downstream is genuinely real —
13510
- * M signs with M's own valid key. Nothing is missing for A to notice. This comparison is
13511
- * where the substitution shows, because `counterparty_pubkey` comes from A's own request
13512
- * and is untouched by anything the directory returns.
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`.
13513
14584
  *
13514
- * ⚠️ IT SHOWS ONLY WHEN THE RECORD IS PRESENT (review F3). An earlier version of this
13515
- * comment said this was "the one place the substitution shows", full stop and that
13516
- * asserted a property the code does not have: M can decline to supply an ordering record
13517
- * and be ingested without ever reaching this line. The caller logs
13518
- * `session.content.ordering.absent` so the weaker case is at least visible, and closing it
13519
- * needs a check that does not depend on the sender's cooperation — the relay's independent
13520
- * copy, `DOD-M15-CORROBORATE-1`.
13521
- *
13522
- * The soft half stays soft deliberately: `counterparty_unknown` means we cannot prove the
13523
- * signer either way, and refusing there would strand sessions whose record we failed to
13524
- * 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.
13525
14589
  */
13526
- const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
14590
+ const reason = auth.verdict === "refuted" ? auth.reason : "counterparty_unknown";
13527
14591
  this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
13528
- /**
13529
- * 024-ORPHANTRIAGE — THE VERIFIED SIGNER SURVIVES THE SESSION LOOKUP NOW.
13530
- *
13531
- * The `verify(...)` three lines above has already passed: whoever produced this record holds
13532
- * the private key for the key inside their own signed bytes. That is session-independent —
13533
- * it needs no `sessions` row and never did. The soft branch then threw it away, so the
13534
- * orphan branch downstream had nothing to go on and told the operator to go and talk to
13535
- * whoever sent it.
13536
- *
13537
- * Carried out only on the `counterparty_unknown` half. The fatal half needs nothing: the
13538
- * session freezes and no triage runs.
13539
- */
13540
- return counterparty
13541
- ? { seq: null, fatal: { reason } }
13542
- : { seq: null, verifiedSignerUnmatched: s1Pubkey };
14592
+ return auth.verdict === "refuted" ? { seq: null, fatal: { reason } } : { seq: null };
13543
14593
  }
13544
14594
  // Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
13545
- this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
14595
+ this.recordWitnessedSequence(agentName, sessionId, Buffer.from(contentHash).toString("hex"), seq - 1);
13546
14596
  this.#logger.info("session.content.ordering.recorded", {
13547
14597
  sessionId,
13548
14598
  canonicalSeq: seq - 1,
@@ -13550,18 +14600,15 @@ export class SessionNodeManager {
13550
14600
  correlationId,
13551
14601
  });
13552
14602
  /**
13553
- * 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`.
13554
14604
  *
13555
- * Three lines above, `verify(s1Pubkey, structure1Cbor, s2Sig)` has already passed and the
13556
- * signer has been matched to this session's counterparty. That is the strongest statement
13557
- * this daemon ever makes about who wrote a message and until now it was made, used to
13558
- * decide a sequence number, and then discarded. The transcript row that outlives it recorded
13559
- * only a direction.
13560
- *
13561
- * Returned rather than stashed, for the same reason `seq` is: a caller that has to go looking
13562
- * 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.
13563
14610
  */
13564
- return { seq: seq - 1, senderPubkey: s1Pubkey, senderSig: s2Sig };
14611
+ return { seq: seq - 1 };
13565
14612
  }
13566
14613
  catch (err) {
13567
14614
  this.#logger.warn("session.content.ordering.decode_failed", {
@@ -13803,40 +14850,37 @@ export class SessionNodeManager {
13803
14850
  const encState = this.#contentEncryptionState(agentName, sessionId);
13804
14851
  let plaintextBody;
13805
14852
  if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
13806
- this.#logger.error("session.content.refused", {
13807
- agentName, sessionId, correlationId,
13808
- reason: "content_encryption_absent_or_unknown",
14853
+ this.#refuseInboundContent(agentName, sessionId, "content_encryption_absent_or_unknown", contentHash, {
13809
14854
  declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
13810
- 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.",
13811
14856
  guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
13812
14857
  "This build never sends one, so either something between you rewrote the frame, or your " +
13813
14858
  "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
13814
14859
  "before opening another session.",
13815
- });
14860
+ }, correlationId);
13816
14861
  return;
13817
14862
  }
13818
14863
  if (encState.key === null) {
13819
- this.#logger.error("session.content.refused", {
13820
- agentName, sessionId, correlationId,
13821
- reason: "no_session_key",
14864
+ this.#refuseInboundContent(agentName, sessionId, "no_session_key", contentHash, {
13822
14865
  detail: encState.reason,
13823
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.",
13824
- guidance: CONTENT_ENCRYPTION_GUIDANCE[encState.reason],
13825
- });
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);
13826
14872
  return;
13827
14873
  }
13828
14874
  const opened = openSessionContent(encState.key, contentBytes);
13829
14875
  if (opened === null) {
13830
14876
  // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
13831
14877
  // side must not branch on which — that would be branching on attacker-controlled input.
13832
- this.#logger.error("session.content.refused", {
13833
- agentName, sessionId, correlationId,
13834
- reason: "decrypt_failed",
14878
+ this.#refuseInboundContent(agentName, sessionId, "decrypt_failed", contentHash, {
13835
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.",
13836
- guidance: "STOPPED ON PURPOSE. Nothing was shown and nothing was stored. A message that fails this " +
13837
- "check has either been altered on its way to you or was not encrypted for this session. " +
13838
- "Confirm with your counterparty OUT OF BAND, then start a new session.",
13839
- });
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);
13840
14884
  return;
13841
14885
  }
13842
14886
  plaintextBody = opened;
@@ -13854,6 +14898,12 @@ export class SessionNodeManager {
13854
14898
  // different fact, and it is now refused.
13855
14899
  const s1Cbor = frame["structure1_cbor"];
13856
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"];
13857
14907
  let framedSeq = null;
13858
14908
  /**
13859
14909
  * DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
@@ -13869,37 +14919,87 @@ export class SessionNodeManager {
13869
14919
  * session record exists, so this stays `undefined` and nothing reads it.
13870
14920
  */
13871
14921
  let verifiedSignerUnmatched;
13872
- if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
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) {
13873
14983
  const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
13874
14984
  if (ordering.fatal) {
13875
14985
  await this.#freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
13876
14986
  return;
13877
14987
  }
13878
14988
  framedSeq = ordering.seq;
13879
- if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
13880
- verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
13881
- }
13882
- verifiedSignerUnmatched = ordering.verifiedSignerUnmatched;
13883
14989
  }
13884
14990
  else {
13885
14991
  /**
13886
- * Review F3 THE WEAKER GUARANTEE MUST NOT BE INDISTINGUISHABLE FROM THE STRONGER ONE.
13887
- *
13888
- * A frame with no ordering record is still ingested, and that is correct: it is the
13889
- * documented relay-degraded path, and refusing it would make the relay a precondition for
13890
- * reading mail. But it means the per-message signer check is **opt-in for the sender** — a
13891
- * party that passed the peer gate and wants to avoid the comparison simply omits the proof.
13892
- * Silently, until now: nothing recorded that a message arrived unverified, so the log looked
13893
- * 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.
13894
14993
  *
13895
- * Not fatal, and deliberately not: an absent record proves nothing about the signer, and
13896
- * refusing on an absence would strand every relay-degraded session. What closes the omission
13897
- * case is relay-side corroboration `DOD-M15-CORROBORATE-1` where the relay holds the
13898
- * 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.
13899
14999
  */
13900
15000
  this.#logger.info("session.content.ordering.absent", {
13901
15001
  agentName, sessionId, correlationId,
13902
- 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.",
13903
15003
  });
13904
15004
  }
13905
15005
  // AC-001: carry the sender's correlationId from the frame into the receive