@cello-protocol/daemon 0.0.114 → 0.0.116

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.
@@ -63,11 +63,25 @@ export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = DEFAULT_TIER_BOUNDS[TIER.UN
63
63
  * scalar across the whole unknown pool — not per-tier — so it stays a standalone const. */
64
64
  export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
65
65
  // ─── SessionNodeManager ───────────────────────────────────────────────────────
66
+ /**
67
+ * DOD-COATTEND-1: how much of the arrival buffer is kept. Delivery reads the durable transcript
68
+ * now, so this buffer is only a recency hint (`peekLatestReceivedContentHex` for M8C-AWAY-1's
69
+ * [[WRAP]] check). Small, and stated: an unstated cap is a silent truncation, and no cap at all is
70
+ * the leak the old destructive read was accidentally preventing.
71
+ */
72
+ const RECEIVED_BUFFER_CAP = 32;
66
73
  export class SessionNodeManager {
67
74
  #factory;
68
75
  #logger;
69
76
  #dbPath;
70
77
  #db = null;
78
+ /**
79
+ * DOD-COATTEND-1 (review F2): sessions whose RECEIVED transcript row failed to write, and are
80
+ * therefore holding content that can never be delivered. Read by `cello_receive` so the timeout
81
+ * answer names the local failure instead of telling the operator to keep waiting on a
82
+ * counterparty who already sent. Keyed (agent, session) → the leaf sequences that were lost.
83
+ */
84
+ #undeliverableSeqs = new Map();
71
85
  /** RELAYSIG-1: shared immutable store of the relay's signed ordering-record receipts (keyed by agent). */
72
86
  #relayReceiptStore = null;
73
87
  /** FED-OPTIONB-SEAL-001: the per-session leaf log (both parties) carried at a unilateral seal. */
@@ -669,12 +683,16 @@ export class SessionNodeManager {
669
683
  * DOD-LOG-1 / PERSIST-002 (AC-010): append one readable message to the durable transcript, keyed
670
684
  * by the canonical leaf `sequence` so it joins to the committed hash chain. The blob is stored as
671
685
  * plaintext bytes: the whole DB is SQLCipher-encrypted at rest, so there is no per-column cipher.
672
- * Idempotent on replay (INSERT OR IGNORE). Never throws into the caller's content path: a
673
- * transcript-write failure is logged, not fatal.
686
+ * Idempotent on replay (INSERT OR IGNORE). Never throws into the caller's content path — but it
687
+ * REPORTS: returns false when the row did not land, so a caller for whom the row is a delivery
688
+ * precondition can fail instead of proceeding (review F2). Before Tier 1 the return value would
689
+ * have been pointless, because `cello_receive` served content from the in-memory buffer and the
690
+ * lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
691
+ * row is TOTAL content loss and the caller has to know.
674
692
  */
675
693
  recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId) {
676
694
  if (!this.#db)
677
- return;
695
+ return false;
678
696
  try {
679
697
  const agentId = this.#requireAgentId(agentName);
680
698
  const blob = Buffer.from(plaintext);
@@ -683,21 +701,28 @@ export class SessionNodeManager {
683
701
  VALUES (?, ?, ?, ?, ?, ?)`)
684
702
  .run(agentId, sessionId, sequence, direction, blob, Date.now());
685
703
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
704
+ return true;
686
705
  }
687
706
  catch (err) {
688
707
  // M8C-INBOX-1 (reviewer F2): a RECEIVED-row write failure is not cosmetic — since INBOX-1 the
689
- // transcript is the AUTHORITY for unread (getUnreadSummary). A swallowed received write means
690
- // the message never shows as unread in cello_check_notifications AND is lost on restart, while
691
- // cello_receive still delivers it live from the in-memory buffer (masking the loss). Surface it
692
- // LOUDLY (error) so the reconcile-after-loss guarantee's dependency is visible. Sent-row
693
- // failures stay a warning (they only affect the durable readable transcript, not unread).
708
+ // transcript is the AUTHORITY for unread (getUnreadSummary).
709
+ //
710
+ // UPDATED for DOD-COATTEND-1 (review F2). This comment used to end "...while cello_receive
711
+ // still delivers it live from the in-memory buffer (masking the loss)", and that mitigation
712
+ // was the whole reason a swallowed write was survivable. Tier 1 DELETED it: delivery reads
713
+ // the transcript now, so a lost received row is not an undercount, it is the message never
714
+ // reaching ANY session while the doorbell rings and the leaf sits in the hash chain. The
715
+ // sentence is corrected rather than kept, because as written it reassured a reader about a
716
+ // safety net that no longer exists. Sent-row failures stay a warning (they only affect the
717
+ // durable readable transcript, not delivery).
694
718
  const level = direction === "received" ? "error" : "warn";
695
719
  this.#logger[level]("transcript.message.record.failed", {
696
720
  sessionId, agentName, sequence, direction,
697
721
  reason: err instanceof Error ? err.message : String(err),
698
722
  correlationId,
699
- ...(direction === "received" ? { impact: "unread_reconciliation_may_undercount" } : {}),
723
+ ...(direction === "received" ? { impact: "content_undeliverable_message_lost" } : {}),
700
724
  });
725
+ return false;
701
726
  }
702
727
  }
703
728
  /**
@@ -727,6 +752,33 @@ export class SessionNodeManager {
727
752
  }
728
753
  return { messages, undecryptable: 0 };
729
754
  }
755
+ /**
756
+ * DOD-COATTEND-1 (review F5) — the single next RECEIVED message after `afterSeq`, or null.
757
+ *
758
+ * The delivery path asks this question inside a 20 ms poll, so it is asked ~47 times a second per
759
+ * blocked connection — ~1,400 times over a default 30 s receive. Answering it with
760
+ * `readTranscript()` meant, every single time: SELECT every row of the session with no predicate
761
+ * and no limit, `TextDecoder().decode()` every blob in it, build the array, then `.find()` one
762
+ * row and discard the rest. On a 200-message session with three co-attending connections blocking
763
+ * — which is the M8D use case, not a worst case — that is tens of thousands of blob decodes per
764
+ * second on the daemon's single synchronous SQLCipher handle, contending with the write path.
765
+ *
766
+ * The predicate belongs in SQL. This is O(1) on the existing (agent_id, session_id, sequence)
767
+ * key and decodes exactly the one blob it returns.
768
+ */
769
+ findNextReceivedAfter(agentName, sessionId, afterSeq) {
770
+ if (!this.#db)
771
+ return null;
772
+ const row = this.#db
773
+ .prepare(`SELECT sequence, blob FROM transcript
774
+ WHERE agent_id = ? AND session_id = ? AND direction = 'received' AND sequence > ?
775
+ ORDER BY sequence ASC LIMIT 1`)
776
+ .get(this.#requireAgentId(agentName), sessionId, afterSeq);
777
+ if (!row)
778
+ return null;
779
+ const blob = row.blob instanceof Uint8Array ? row.blob : new Uint8Array(row.blob);
780
+ return { sequence: row.sequence, text: new TextDecoder().decode(blob) };
781
+ }
730
782
  // ─── M8C-INBOX-1 (N2/N3): read-watermark accessors ───────────────────────────
731
783
  /** The highest RECEIVED transcript sequence delivered to the operator for (agent, session).
732
784
  * -1 when nothing has been delivered yet (so a seq-0 message reads as unread). */
@@ -1976,7 +2028,11 @@ export class SessionNodeManager {
1976
2028
  // retireSessionNode and is not blocking on receive.
1977
2029
  if (reason === "sealed") {
1978
2030
  const tkey = this.#k(agentName, sessionId);
1979
- const unreadCount = this.#receivedContent.get(tkey)?.length ?? 0;
2031
+ // DOD-COATTEND-1: counted from the DURABLE read watermark, not the buffer's length.
2032
+ // Delivery no longer drains that buffer (it reads the transcript against a per-connection
2033
+ // bookmark), so its length is now "everything that ever arrived", not "what nobody read" —
2034
+ // reporting it would tell the operator every message of a healthy conversation went unread.
2035
+ const unreadCount = this.getUnreadReceivedCount(agentName, sessionId);
1980
2036
  this.#sessionTerminal.set(tkey, { type: "sealed", unreadCount });
1981
2037
  }
1982
2038
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
@@ -2079,7 +2135,9 @@ export class SessionNodeManager {
2079
2135
  // F1-c: dropping a NON-empty received-content buffer means deliverable plaintext the app
2080
2136
  // never read live is being discarded (still durable in the transcript). Make that silent
2081
2137
  // drop diagnosable — it fires on both the destroy (sealed) and retire (sealing) paths.
2082
- const unreadCount = this.#receivedContent.get(key)?.length ?? 0;
2138
+ // DOD-COATTEND-1: same correction as the terminal marker above — the buffer is no longer
2139
+ // drained by delivery, so its length no longer means "unread". The watermark does.
2140
+ const unreadCount = this.getUnreadReceivedCount(agentName, sessionId);
2083
2141
  if (unreadCount > 0) {
2084
2142
  this.#logger.info("session.receive.buffer.evicted", { sessionId, agentName, unreadCount });
2085
2143
  }
@@ -3485,6 +3543,18 @@ export class SessionNodeManager {
3485
3543
  const leafIndex = terminalBlock
3486
3544
  ? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
3487
3545
  : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId).leafIndex;
3546
+ // DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
3547
+ // transcript IS the delivery path — so this message can never be handed to any session. Report
3548
+ // the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
3549
+ // 30 seconds later and one subsystem away, as "no content arrived — keep waiting": the operator
3550
+ // is sent to debug a counterparty who did nothing wrong.
3551
+ //
3552
+ // The leaf STAYS. It is genuinely committed to the hash chain, and unwinding a committed leaf to
3553
+ // tidy up a reporting problem would corrupt the frontier the counterparty already co-signs
3554
+ // against. The hole is now crossable by delivery (F1), so it costs a gap, not a stall.
3555
+ if (!terminalBlock && this.getUndeliverableSeqs(agentName, sessionId).includes(leafIndex)) {
3556
+ return { ok: false, reason: "transcript_write_failed" };
3557
+ }
3488
3558
  // NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
3489
3559
  // precondition for reading mail, so a relay outage would render the inbox unreadable, and the
3490
3560
  // direct path and park backstop exist precisely to survive that. But this append is a WEAKER
@@ -3549,6 +3619,13 @@ export class SessionNodeManager {
3549
3619
  * live arrivals until its tree reaches this, because it has more to recover than it has appended);
3550
3620
  * NOT yet consumed by the gate. Also `recordWitnessedSequence` maintains it.
3551
3621
  */
3622
+ /**
3623
+ * DOD-COATTEND-1 (review F2): leaf sequences whose plaintext failed to reach the transcript and
3624
+ * are therefore undeliverable. Empty is the overwhelmingly normal case.
3625
+ */
3626
+ getUndeliverableSeqs(agentName, sessionId) {
3627
+ return [...(this.#undeliverableSeqs.get(this.#k(agentName, sessionId)) ?? [])];
3628
+ }
3552
3629
  getHighWaterSeq(agentName, sessionId) {
3553
3630
  return this.#highWaterSeq.get(this.#k(agentName, sessionId)) ?? -1;
3554
3631
  }
@@ -3558,8 +3635,19 @@ export class SessionNodeManager {
3558
3635
  // DOD-LOG-1: persist the readable RECEIVED plaintext to the durable transcript, keyed by the
3559
3636
  // canonical leaf sequence so it joins the committed hash chain (survives restart; INV-3 — the
3560
3637
  // relay/directory never see this plaintext, only the hash).
3561
- this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId);
3638
+ const durable = this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId);
3562
3639
  const recvKey = this.#k(agentName, sessionId);
3640
+ if (!durable) {
3641
+ // The leaf is committed and the plaintext is not. Delivery reads the transcript, so this
3642
+ // message is now unreachable by every session — record it so the receive path can SAY that
3643
+ // rather than time out wearing the quiet-counterparty answer (review F2).
3644
+ let lost = this.#undeliverableSeqs.get(recvKey);
3645
+ if (!lost) {
3646
+ lost = new Set();
3647
+ this.#undeliverableSeqs.set(recvKey, lost);
3648
+ }
3649
+ lost.add(leafIndex);
3650
+ }
3563
3651
  // Review finding #6: the witness for this hash has done its ordering job once the leaf is
3564
3652
  // appended — drop it so #witnessedSeq stays proportional to held/pending content, not the whole
3565
3653
  // transcript. A later replay of the same hash is still caught by the dedup leaf-scan, which is
@@ -3571,6 +3659,12 @@ export class SessionNodeManager {
3571
3659
  this.#receivedContent.set(recvKey, buf);
3572
3660
  }
3573
3661
  buf.push({ contentHex: Buffer.from(content).toString("hex"), senderPubkey, sequenceNumber: leafIndex });
3662
+ // DOD-COATTEND-1: BOUNDED, because delivery no longer drains this. Its remaining job is
3663
+ // `peekLatestReceivedContentHex` (M8C-AWAY-1 reads the TAIL to spot a [[WRAP]]), so only the
3664
+ // recent tail is load-bearing — but an unbounded array holding every message of every live
3665
+ // session, in memory, for the life of the daemon, is a leak the old destructive read hid.
3666
+ if (buf.length > RECEIVED_BUFFER_CAP)
3667
+ buf.splice(0, buf.length - RECEIVED_BUFFER_CAP);
3574
3668
  this.#logger.info("session.content.received", {
3575
3669
  sessionId,
3576
3670
  senderPubkey,