@cello-protocol/daemon 0.0.32 → 0.0.34

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.
@@ -81,7 +81,18 @@ import { encodeSealPayload } from "@cello-protocol/protocol-types";
81
81
  import { AgentRelayClient, LEAF_KIND_CTRL } from "./session-relay-client.js";
82
82
  import { RelayReceiptStore } from "./relay-receipt-store.js";
83
83
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
84
+ import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
84
85
  const CBOR_ENC = new Encoder({ tagUint8Array: false });
86
+ // M8C-ABUSE-1: persistence bounds — the non-M9 remainder (per-message cap + outbound rate are
87
+ // M9's, not rebuilt here). "Whitelisted senders bounded only by disk" (DoD) — these bounds apply
88
+ // ONLY to sessions/senders that are NOT a known contact (CONTACT-1's whitelist).
89
+ /** Anti-drip-feed: cumulative RECEIVED bytes per session, from a non-contact sender. */
90
+ export const ABUSE_MAX_SESSION_RECEIVED_BYTES = 25 * 1024 * 1024; // 25 MB
91
+ /** Anti-drip-feed via many sessions: active sessions a single unknown counterparty may hold open
92
+ * with one agent at once. */
93
+ export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = 3;
94
+ /** Anti-swarm: total active sessions from ALL unknown (non-contact) counterparties combined, per agent. */
95
+ export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
85
96
  // ─── SessionNodeManager ───────────────────────────────────────────────────────
86
97
  export class SessionNodeManager {
87
98
  #factory;
@@ -92,6 +103,11 @@ export class SessionNodeManager {
92
103
  #relayReceiptStore = null;
93
104
  /** FED-OPTIONB-SEAL-001: the per-session leaf log (both parties) carried at a unilateral seal. */
94
105
  #sealLeafStore = null;
106
+ // M9-CORE-001: the inbound screening seam. Every byte that reaches the agent passes
107
+ // through #appendVerifiedContent's buffer write; screenInbound gates it there, on every
108
+ // arrival path (direct, held-release, recovered-park). Defaults to always-allow when no
109
+ // gateway is configured (SI-001: still a verdict, not an ungated pass).
110
+ #securityGateway;
95
111
  #activeNodes = new Map();
96
112
  // M7 DOD-SPINE-6 / MSG-001-3b: ONE relay witness client per AGENT (keyed by agent name).
97
113
  // The relay authenticates and keys delivery by the agent's K_local pubkey, so all of an
@@ -219,6 +235,7 @@ export class SessionNodeManager {
219
235
  }
220
236
  this.#autoNatProbers = opts.autoNatProbers ?? (() => []);
221
237
  this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
238
+ this.#securityGateway = opts.securityGateway ?? new PassthroughGatewayClient();
222
239
  }
223
240
  /**
224
241
  * CELLO-M7-MSG-001: wire the durable-backstop side effects of the awaiting-ACK
@@ -234,6 +251,14 @@ export class SessionNodeManager {
234
251
  * MSG-001-3b (2b): inject the live content-park deposit (seal + ContentParkClient.deposit).
235
252
  * Injected by the composition root (daemon.ts). When absent, a not-confirmed send still records
236
253
  * the durable awaiting entry (crash backstop) but does not deposit live.
254
+ * DOD-LEAVEMSG-1 (cello-unit-reviewer HIGH fix): the hook returns a TYPED result — `{ok:true}` or
255
+ * `{ok:false, reason}` — mirroring RetryQueue's ParkFn contract. It must NEVER resolve `{ok:true}`
256
+ * merely because it didn't throw: the production hook's own failure branches (standing receiver
257
+ * unavailable, relay explicitly rejects the deposit) log-and-return without throwing, and a
258
+ * throw-only contract would silently report those as success — the exact "system lies about its
259
+ * own health" bug the reviewer caught (a park that never happened reported to the operator as
260
+ * "dispatched to relay," with the durable retry_queue backstop skipped because sendContent's own
261
+ * caller only enqueues on an honest {ok:false}).
237
262
  */
238
263
  setContentParkHook(fn) {
239
264
  this.#contentParkHook = fn;
@@ -242,30 +267,52 @@ export class SessionNodeManager {
242
267
  * MSG-001-3b (2b): deposit un-confirmed content to the relay store-and-forward backstop — keyed
243
268
  * to the recipient, on the SAME relay this session is witnessed by — so an offline recipient
244
269
  * recovers it (at the sequence the witness already assigned, R1). Best-effort, never throws.
270
+ * DOD-LEAVEMSG-1: returns whether the deposit actually succeeded (false if no hook/relay is
271
+ * configured, or the hook rejects) so a caller with a live response to shape (sendContent) can
272
+ * distinguish "genuinely parked" from "nothing recoverable" instead of guessing. Callers that
273
+ * fire this from an async backstop with no live caller (the TTF-expiry path) may ignore the
274
+ * result — the deposit itself and its logging are unchanged either way.
245
275
  */
246
- #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
276
+ async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
247
277
  const hook = this.#contentParkHook;
248
278
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
249
279
  if (!hook || !entry || !entry.relayPeerId || !entry.relayAddrs)
250
- return;
251
- void hook({
252
- sessionId,
253
- recipientPubkeyHex: entry.counterpartyPubkey,
254
- relayPeerId: entry.relayPeerId,
255
- relayAddrs: entry.relayAddrs,
256
- contentHashHex,
257
- content,
258
- // DOD-MSG-4 (2b): carry the relay's signed ordering record so the parked entry is self-ordering
259
- // on recover too (sealed INTO the ciphertext envelope INV-3: the relay still sees only ciphertext).
260
- structure1Cbor,
261
- structure2Cbor,
262
- }).catch((err) => {
280
+ return false;
281
+ try {
282
+ const result = await hook({
283
+ sessionId,
284
+ recipientPubkeyHex: entry.counterpartyPubkey,
285
+ relayPeerId: entry.relayPeerId,
286
+ relayAddrs: entry.relayAddrs,
287
+ contentHashHex,
288
+ content,
289
+ // DOD-MSG-4 (2b): carry the relay's signed ordering record so the parked entry is self-ordering
290
+ // on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
291
+ structure1Cbor,
292
+ structure2Cbor,
293
+ });
294
+ // DOD-LEAVEMSG-1 (reviewer HIGH fix): check the TYPED result, not just "didn't throw" — the
295
+ // production hook's own failure branches (standing receiver unavailable, relay explicitly
296
+ // rejects) resolve normally after logging, they never throw. A throw-only check would report
297
+ // those as success.
298
+ if (!result.ok) {
299
+ this.#logger.warn("content.park.deposit.failed", {
300
+ sessionId,
301
+ contentHash: contentHashHex,
302
+ reason: result.reason,
303
+ });
304
+ return false;
305
+ }
306
+ return true;
307
+ }
308
+ catch (err) {
263
309
  this.#logger.warn("content.park.deposit.failed", {
264
310
  sessionId,
265
311
  contentHash: contentHashHex,
266
312
  error: err instanceof Error ? err.message : String(err),
267
313
  });
268
- });
314
+ return false;
315
+ }
269
316
  }
270
317
  // ─── Initialization ──────────────────────────────────────────────────────
271
318
  async initialize() {
@@ -396,6 +443,31 @@ export class SessionNodeManager {
396
443
  last_delivered_seq INTEGER NOT NULL,
397
444
  PRIMARY KEY (agent_name, session_id)
398
445
  )
446
+ `);
447
+ // M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
448
+ // setting — it belongs alongside message_watermarks/sessions as its own real subsystem, not
449
+ // behind the parked M9-CFG-001 config store. Identity PINS to the pubkey at add time (never
450
+ // re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
451
+ this.#db.exec(`
452
+ CREATE TABLE IF NOT EXISTS contacts (
453
+ agent_name TEXT NOT NULL,
454
+ pubkey TEXT NOT NULL,
455
+ added_at INTEGER NOT NULL,
456
+ PRIMARY KEY (agent_name, pubkey)
457
+ )
458
+ `);
459
+ // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
460
+ // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
461
+ // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
462
+ // defaults) and can't legitimately wait for M9. Singleton row (id=1) — "token = daemon
463
+ // setting" (DoD), not per-agent.
464
+ this.#db.exec(`
465
+ CREATE TABLE IF NOT EXISTS telegram_settings (
466
+ id INTEGER PRIMARY KEY CHECK (id = 1),
467
+ bot_token TEXT NOT NULL,
468
+ allowlisted_chat_id TEXT NOT NULL,
469
+ updated_at INTEGER NOT NULL
470
+ )
399
471
  `);
400
472
  // Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
401
473
  // Any 'active' row in a freshly-started daemon is a remnant of a prior
@@ -574,6 +646,121 @@ export class SessionNodeManager {
574
646
  .all(agentName);
575
647
  return rows;
576
648
  }
649
+ /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
650
+ isContact(agentName, pubkey) {
651
+ if (!this.#db)
652
+ return false;
653
+ const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_name = ? AND pubkey = ?").get(agentName, pubkey);
654
+ return row !== undefined;
655
+ }
656
+ /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
657
+ * no-op, never refreshes added_at; identity does not get re-resolved). */
658
+ addContact(agentName, pubkey) {
659
+ if (!this.#db || !pubkey)
660
+ return;
661
+ this.#db
662
+ .prepare("INSERT OR IGNORE INTO contacts (agent_name, pubkey, added_at) VALUES (?, ?, ?)")
663
+ .run(agentName, pubkey, Date.now());
664
+ }
665
+ /** M8C-CONTACT-1: known stays known until explicitly removed. */
666
+ removeContact(agentName, pubkey) {
667
+ if (!this.#db)
668
+ return false;
669
+ const res = this.#db.prepare("DELETE FROM contacts WHERE agent_name = ? AND pubkey = ?").run(agentName, pubkey);
670
+ return res.changes > 0;
671
+ }
672
+ /** M8C-CONTACT-1: list an agent's known contacts, oldest-added first. */
673
+ listContacts(agentName) {
674
+ if (!this.#db)
675
+ return [];
676
+ return this.#db
677
+ .prepare("SELECT pubkey, added_at FROM contacts WHERE agent_name = ? ORDER BY added_at ASC")
678
+ .all(agentName);
679
+ }
680
+ /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
681
+ #getReceivedBytesTotal(agentName, sessionId) {
682
+ if (!this.#db)
683
+ return 0;
684
+ const row = this.#db
685
+ .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_name = ? AND session_id = ? AND direction = 'received'")
686
+ .get(agentName, sessionId);
687
+ return row.total;
688
+ }
689
+ /** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
690
+ * buffer for this session — NOT yet committed leaves, but real bytes in memory that would
691
+ * otherwise let multiple held chunks each individually pass the size gate while cumulatively
692
+ * exceeding it once #releaseHeld drains them. */
693
+ #getHeldBytesTotal(agentName, sessionId) {
694
+ const held = this.#heldContent.get(this.#k(agentName, sessionId));
695
+ if (!held)
696
+ return 0;
697
+ let total = 0;
698
+ for (const entry of held.values())
699
+ total += entry.content.length;
700
+ return total;
701
+ }
702
+ /** M8C-ABUSE-1: non-terminal sessions this agent currently holds with the given counterparty.
703
+ * Reviewer HIGH fix (aeffb82f, D18): counting `status = 'active'` ONLY let a counterparty
704
+ * evade the bound for free by disconnecting (a trivial, attacker-controlled action that flips
705
+ * a session to 'interrupted' — markInterruptedWithDetails) and opening a fresh session,
706
+ * repeated indefinitely. 'interrupted' sessions still accept content (ingestReceivedContent
707
+ * explicitly allows both statuses) and are NOT terminal (sealed/seal_interrupted_pending are),
708
+ * so they must still count against the bound. */
709
+ countActiveSessionsForCounterparty(agentName, counterpartyPubkey) {
710
+ if (!this.#db)
711
+ return 0;
712
+ const row = this.#db
713
+ .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_name = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
714
+ .get(agentName, counterpartyPubkey);
715
+ return row.n;
716
+ }
717
+ /** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with counterparties that are
718
+ * NOT a known contact — the global cap counts across ALL unknown senders combined. Same
719
+ * 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
720
+ countActiveSessionsFromUnknownSenders(agentName) {
721
+ if (!this.#db)
722
+ return 0;
723
+ const row = this.#db
724
+ .prepare(`SELECT COUNT(*) AS n FROM sessions s
725
+ WHERE s.agent_name = ? AND s.status IN ('active', 'interrupted')
726
+ AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.agent_name = s.agent_name AND c.pubkey = s.counterparty_pubkey)`)
727
+ .get(agentName);
728
+ return row.n;
729
+ }
730
+ /** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the acceptance bounds?
731
+ * Known contacts are exempt entirely ("bounded only by disk" — DoD). Checked BEFORE accepting
732
+ * a fresh inbound session (the counts reflect sessions already active, not yet counting this one). */
733
+ checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
734
+ if (this.isContact(agentName, counterpartyPubkey))
735
+ return { ok: true };
736
+ const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
737
+ if (perSender >= ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER) {
738
+ return { ok: false, reason: "abuse_bound_sessions_per_sender" };
739
+ }
740
+ const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
741
+ if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
742
+ return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
743
+ }
744
+ return { ok: true };
745
+ }
746
+ /** M8C-TGDOOR-1: the daemon-wide Telegram bot settings, or null if never configured. */
747
+ getTelegramSettings() {
748
+ if (!this.#db)
749
+ return null;
750
+ const row = this.#db
751
+ .prepare("SELECT bot_token, allowlisted_chat_id FROM telegram_settings WHERE id = 1")
752
+ .get();
753
+ return row ? { botToken: row.bot_token, allowlistedChatId: row.allowlisted_chat_id } : null;
754
+ }
755
+ /** M8C-TGDOOR-1: persist (or replace) the singleton Telegram settings row. */
756
+ setTelegramSettings(botToken, allowlistedChatId) {
757
+ if (!this.#db)
758
+ return;
759
+ this.#db
760
+ .prepare(`INSERT INTO telegram_settings (id, bot_token, allowlisted_chat_id, updated_at) VALUES (1, ?, ?, ?)
761
+ ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
762
+ .run(botToken, allowlistedChatId, Date.now());
763
+ }
577
764
  /** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
578
765
  getStandingReceiverReady(agentName) {
579
766
  if (agentName !== undefined)
@@ -1839,7 +2026,7 @@ export class SessionNodeManager {
1839
2026
  await stream.close();
1840
2027
  }
1841
2028
  catch { /* best-effort close */ }
1842
- return { ok: true };
2029
+ return { ok: true, delivered: true };
1843
2030
  }
1844
2031
  catch (err) {
1845
2032
  // The send failed after (possibly) arming the awaiting tracking — drop it so a
@@ -1848,7 +2035,13 @@ export class SessionNodeManager {
1848
2035
  // 2b: direct delivery failed (counterparty offline). The hash is already witnessed (R1, the
1849
2036
  // sequence is assigned), so deposit the content to the relay store-and-forward backstop now;
1850
2037
  // the recipient pulls + recovers it on next online (DOD-MSG-3/4).
1851
- this.#parkContent(agentName, sessionId, Buffer.from(contentHash).toString("hex"), content, orderingS1, orderingS2);
2038
+ // DOD-LEAVEMSG-1: the deposit is now AWAITED (was fire-and-forget) so a genuine park success
2039
+ // can be reported as "dispatched to relay" instead of a raw stream failure — the operator/
2040
+ // agent sees the truth (the message IS in flight, just not direct), not a false negative.
2041
+ const parked = await this.#parkContent(agentName, sessionId, Buffer.from(contentHash).toString("hex"), content, orderingS1, orderingS2);
2042
+ if (parked) {
2043
+ return { ok: true, delivered: false, parked: true };
2044
+ }
1852
2045
  // error.message extracted — never [object Object]. libp2p/cross-package errors are not
1853
2046
  // always `instanceof Error` in this realm, so fall back to a message property / JSON.
1854
2047
  const errMsg = err instanceof Error
@@ -2078,7 +2271,7 @@ export class SessionNodeManager {
2078
2271
  *
2079
2272
  * @returns the appended leaf index (as sequenceNumber) on success.
2080
2273
  */
2081
- ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId) {
2274
+ async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId) {
2082
2275
  // The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
2083
2276
  // 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
2084
2277
  // notarization attests that exact root; a late leaf would diverge from it.
@@ -2140,6 +2333,151 @@ export class SessionNodeManager {
2140
2333
  // entry (e.g. after auto-recover already drained it) must not count it as a fresh recovery.
2141
2334
  return { ok: true, leafIndex: existingIdx, sequenceNumber: existingIdx, appendedCount: 0 };
2142
2335
  }
2336
+ // M8C-ABUSE-1 (reviewer HIGH fix, D18): per-session total-size cap (anti-drip-feed) —
2337
+ // "whitelisted senders bounded only by disk" (DoD), so a known contact is exempt entirely.
2338
+ // MUST run BEFORE the hold-branch below — the original placement (after it) let a
2339
+ // non-contact sender drip-feed unbounded bytes by making every message arrive "out of order"
2340
+ // relative to the relay witness (held content skipped the cap entirely, then #releaseHeld
2341
+ // appended it later with no re-check). Accounts for bytes already committed AND bytes
2342
+ // currently sitting in the hold buffer (multiple held chunks could otherwise each individually
2343
+ // pass the check while cumulatively exceeding it once released). Runs BEFORE the M9 screening
2344
+ // seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
2345
+ // content headed for rejection anyway); both gates are independent and either rejects on its
2346
+ // own criteria, so ordering between them does not change correctness.
2347
+ if (!this.isContact(agentName, senderPubkey)) {
2348
+ const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2349
+ const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2350
+ if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
2351
+ this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2352
+ sessionId,
2353
+ agentName,
2354
+ senderPubkey,
2355
+ priorTotal,
2356
+ heldTotal,
2357
+ incoming: content.length,
2358
+ cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
2359
+ correlationId,
2360
+ });
2361
+ return { ok: false, reason: "session_size_limit_exceeded" };
2362
+ }
2363
+ }
2364
+ // M9-CORE-001: the inbound screening seam (INV-5). Screen here — after the content is proven
2365
+ // authentic (hash cross-check) and confirmed not a duplicate, before it is either held for
2366
+ // ordering or appended to the agent-facing buffer. This is the SINGLE inbound funnel: direct
2367
+ // arrivals, recovered/parked content (daemon recover → here), and held-then-released content
2368
+ // (held below, screened now, released already-screened) all pass this point. A non-allow
2369
+ // verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
2370
+ // no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
2371
+ // it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
2372
+ const inboundVerdict = await this.#securityGateway.screenInbound(content, {
2373
+ direction: "inbound",
2374
+ agentName,
2375
+ sessionId,
2376
+ correlationId,
2377
+ });
2378
+ // M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
2379
+ // rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
2380
+ // injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
2381
+ // identically on redelivery, so holding them un-acked would loop the sender forever. Instead a
2382
+ // terminal block is `screenedOut`: it records a leaf binding the ORIGINAL content hash and is
2383
+ // acknowledged (the sender stops), but is NEVER buffered for the agent (cello_receive never sees
2384
+ // it). The leaf is REQUIRED, not cosmetic: the sender appended this leaf at its CANONICAL position
2385
+ // on send, so a terminal block must take the SAME strict-in-order path as a delivered message —
2386
+ // record the leaf at its canonical index, not in arrival order — or the two parties' hash chains
2387
+ // diverge by POSITION and the bilateral seal cross-check mismatches (code-review HIGH-1). The only
2388
+ // difference from a normal message is that it leafs WITHOUT buffering. A TRANSIENT block (a
2389
+ // fail-closed gateway_unavailable / governance_timeout) records nothing and is not acked.
2390
+ const terminalBlock = inboundVerdict.disposition === "block" && inboundVerdict.terminal === true;
2391
+ if (inboundVerdict.disposition !== "allow" && inboundVerdict.disposition !== "redact" && !terminalBlock) {
2392
+ // TRANSIENT block / warn HOLD (do not deliver, do not leaf, do not ack). The message stays
2393
+ // un-acked so the sender's TTF/park/retry redelivers and re-screens it once the gateway recovers.
2394
+ // (If we committed a leaf, dedup would later swallow the redelivery and the agent would never
2395
+ // receive it.)
2396
+ if (inboundVerdict.reason === GOVERNANCE_TIMEOUT) {
2397
+ this.#logger.error("security.gateway.timeout", {
2398
+ sessionId,
2399
+ reason: inboundVerdict.reason,
2400
+ correlationId,
2401
+ });
2402
+ }
2403
+ else if (inboundVerdict.reason === GATEWAY_UNAVAILABLE) {
2404
+ this.#logger.error("security.gateway.unavailable", {
2405
+ direction: "inbound",
2406
+ reason: inboundVerdict.reason,
2407
+ correlationId,
2408
+ });
2409
+ }
2410
+ else {
2411
+ this.#logger.warn("security.gateway.inbound.blocked", {
2412
+ sessionId,
2413
+ disposition: inboundVerdict.disposition,
2414
+ reason: inboundVerdict.reason,
2415
+ correlationId,
2416
+ });
2417
+ }
2418
+ return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
2419
+ }
2420
+ if (terminalBlock) {
2421
+ this.#logger.warn("security.gateway.inbound.terminal_block", {
2422
+ sessionId,
2423
+ disposition: inboundVerdict.disposition,
2424
+ reason: inboundVerdict.reason,
2425
+ correlationId,
2426
+ });
2427
+ }
2428
+ // M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
2429
+ // while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
2430
+ // the peer actually sent; the agent sees the sanitized form. `allow` leaves the content unchanged.
2431
+ // A terminal block carries the original bytes here only so its leaf binds the right hash; it is
2432
+ // never delivered (the screenedOut flag below routes it to a leaf-without-buffer).
2433
+ const deliverContent = inboundVerdict.disposition === "redact" && inboundVerdict.content !== undefined
2434
+ ? inboundVerdict.content
2435
+ : content;
2436
+ // B1 (review): screenInbound above is the ONLY suspension point in this method. Before M9 the
2437
+ // dedup check (indexOfHash, above) and the leaf append (below) ran with no yield between them,
2438
+ // so check-then-append was atomic under Node's single thread. The await reopened that window:
2439
+ // two concurrent ingests of the SAME content hash — e.g. a direct retry and a park-recovery
2440
+ // racing on reconnect — could BOTH pass the dedup check before either appends, producing two
2441
+ // leaves for one hash (DOD-MSG-5 break → leafIndex≠canonicalSeq → root divergence). Re-check
2442
+ // dedup now that we have resumed; everything from here to the append is synchronous (atomic),
2443
+ // so the first to resume appends and the second sees its leaf and dedups.
2444
+ const dedupAfterScreen = this.getSessionTree(agentName, sessionId).indexOfHash(contentHashHex);
2445
+ if (dedupAfterScreen >= 0) {
2446
+ this.#logger.info("session.content.deduplicated", {
2447
+ sessionId,
2448
+ contentHashHex,
2449
+ sequenceNumber: dedupAfterScreen,
2450
+ correlationId,
2451
+ });
2452
+ return { ok: true, leafIndex: dedupAfterScreen, sequenceNumber: dedupAfterScreen, appendedCount: 0, ...(terminalBlock ? { screenedOut: true } : {}) };
2453
+ }
2454
+ // M8C-ABUSE-1 (cello-unit-reviewer HIGH fix, post-M9INT-1 merge): re-check the size cap here,
2455
+ // in the SAME synchronous window as the dedup re-check above. The original check (before the
2456
+ // screenInbound await) used totals that can go stale: two concurrent ingests for the same
2457
+ // non-contact session — e.g. a live direct arrival racing a recoverParkedFromRelay pull —
2458
+ // could each independently pass the pre-await check using the SAME stale totals, then both
2459
+ // append/hold, jointly exceeding the cap. Symmetric to the dedup fix: everything from here to
2460
+ // the append/hold branch is synchronous, so whichever call resumes first appends/holds before
2461
+ // the second's re-check runs, and the second's freshly-recomputed totals correctly include the
2462
+ // first's contribution.
2463
+ if (!this.isContact(agentName, senderPubkey)) {
2464
+ const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2465
+ const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2466
+ if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
2467
+ this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2468
+ sessionId,
2469
+ agentName,
2470
+ senderPubkey,
2471
+ priorTotal,
2472
+ heldTotal,
2473
+ incoming: content.length,
2474
+ cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
2475
+ correlationId,
2476
+ recheck: true,
2477
+ });
2478
+ return { ok: false, reason: "session_size_limit_exceeded" };
2479
+ }
2480
+ }
2143
2481
  // DOD-MSG-4 (strict in-order gate): the RELAY is the ordering authority. If B holds the
2144
2482
  // canonical sequence for this hash (witnessed via leaf_deliver) and it is AHEAD of the next
2145
2483
  // expected leaf, HOLD the content rather than append it out of order. The missing in-between
@@ -2157,18 +2495,22 @@ export class SessionNodeManager {
2157
2495
  held = new Map();
2158
2496
  this.#heldContent.set(key, held);
2159
2497
  }
2160
- held.set(canonicalSeq, { content, contentHashHex, correlationId });
2498
+ // A terminal block out of canonical order is held WITHOUT delivery (screenedOut): #releaseHeld
2499
+ // leafs it at its canonical index when the gap fills, but never buffers it for the agent. This
2500
+ // keeps leafIndex === canonicalSeq for screened-out content too (code-review HIGH-1).
2501
+ held.set(canonicalSeq, { content: deliverContent, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
2161
2502
  this.#logger.info("session.content.held", {
2162
2503
  sessionId,
2163
2504
  canonicalSeq,
2164
2505
  nextExpected,
2165
2506
  gap: canonicalSeq - nextExpected,
2507
+ screenedOut: terminalBlock,
2166
2508
  correlationId,
2167
2509
  });
2168
2510
  // Held content is NOT yet a durable leaf, so it is deliberately NOT acknowledged `persisted`
2169
2511
  // (the caller checks `held`). The sender's TTF→park backstop and the recover/dedup path
2170
2512
  // guarantee eventual delivery; B never claims persisted for content it only holds in memory.
2171
- return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true };
2513
+ return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true, ...(terminalBlock ? { screenedOut: true } : {}) };
2172
2514
  }
2173
2515
  if (canonicalSeq !== undefined && canonicalSeq < nextExpected) {
2174
2516
  // Contradiction (review finding #2): the witness says this hash belongs BEHIND the current
@@ -2184,12 +2526,16 @@ export class SessionNodeManager {
2184
2526
  correlationId,
2185
2527
  });
2186
2528
  }
2187
- const { leafIndex } = this.#appendVerifiedContent(agentName, sessionId, content, contentHashHex, senderPubkey, correlationId);
2529
+ // In-order append. A terminal block leafs the ORIGINAL content hash WITHOUT buffering it for the
2530
+ // agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
2531
+ const leafIndex = terminalBlock
2532
+ ? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
2533
+ : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId).leafIndex;
2188
2534
  // A just-appended leaf may unblock held out-of-order arrivals whose turn is now next.
2189
2535
  // appendedCount = this leaf + any held leaves released by it, so a caller (recover) can tally the
2190
2536
  // leaves ACTUALLY written, not just the directly-ingested one (review #3).
2191
2537
  const released = this.#releaseHeld(agentName, sessionId, senderPubkey);
2192
- return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released };
2538
+ return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released, ...(terminalBlock ? { screenedOut: true } : {}) };
2193
2539
  }
2194
2540
  /**
2195
2541
  * DOD-MSG-4: record the relay-witnessed canonical sequence for a content hash. The relay is the
@@ -2277,11 +2623,19 @@ export class SessionNodeManager {
2277
2623
  if (!entry)
2278
2624
  break;
2279
2625
  held.delete(nextExpected);
2280
- this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId);
2626
+ // A screened-out (terminal-blocked) held entry leafs at its canonical index but is NEVER
2627
+ // buffered for the agent; a normal held entry buffers + leafs (code-review HIGH-1).
2628
+ if (entry.screenedOut) {
2629
+ this.appendSessionLeaf(agentName, sessionId, "msg", entry.contentHashHex, entry.correlationId);
2630
+ }
2631
+ else {
2632
+ this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId);
2633
+ }
2281
2634
  released++;
2282
2635
  this.#logger.info("session.content.released", {
2283
2636
  sessionId,
2284
2637
  sequenceNumber: nextExpected,
2638
+ screenedOut: entry.screenedOut === true,
2285
2639
  correlationId: entry.correlationId,
2286
2640
  });
2287
2641
  if (held.size === 0) {
@@ -2425,7 +2779,10 @@ export class SessionNodeManager {
2425
2779
  // store-and-forward so the recipient recovers it (at the witnessed sequence). The durable
2426
2780
  // awaiting entry above remains the crash backstop. Carry the retained ordering record (review #1)
2427
2781
  // so a TTF-parked entry self-orders on recover, exactly like the direct-dial-fail park.
2428
- this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
2782
+ // Fire-and-forget: unlike sendContent's live caller, nothing here is awaiting an IPC response
2783
+ // to shape (the TTF timer fires long after cello_send already returned) — the deposit's own
2784
+ // success/failure logging inside #parkContent is the only observability this path needs.
2785
+ void this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
2429
2786
  }
2430
2787
  /**
2431
2788
  * Send an unsigned `persisted` delivery ACK back to the sender over the same
@@ -2447,6 +2804,15 @@ export class SessionNodeManager {
2447
2804
  correlation_id: correlationId,
2448
2805
  });
2449
2806
  stream.send(lp.encode.single(frame));
2807
+ // The receiver-side counterpart to the sender's content.delivery.acked: B has acknowledged
2808
+ // this content `persisted`, so the sender stops retrying/parking. Emitted for BOTH a normally
2809
+ // delivered message AND a terminal-screen block (the block is a definitive receipt — the leaf
2810
+ // is recorded, so the sender must stop) — and deliberately NOT for a transient hold.
2811
+ this.#logger.info("content.delivery.ack.sent", {
2812
+ sessionId,
2813
+ contentHash: Buffer.from(contentHash).toString("hex"),
2814
+ correlationId,
2815
+ });
2450
2816
  try {
2451
2817
  await stream.close();
2452
2818
  }
@@ -2658,7 +3024,7 @@ export class SessionNodeManager {
2658
3024
  }
2659
3025
  // AC-001: carry the sender's correlationId from the frame into the receive
2660
3026
  // path so both sides log the same flow id (never re-minted on receipt).
2661
- const ingest = this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId);
3027
+ const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId);
2662
3028
  // AC-001: after the content is durably ingested AND its hash cross-check
2663
3029
  // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
2664
3030
  // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF