@cello-protocol/daemon 0.0.170 → 0.0.172

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.
@@ -97,6 +97,55 @@ const SHUTDOWN_STEP_DEADLINE_MS = 2_000;
97
97
  * operator says. It is cleared on a successful dial, so it never delays a live counterparty.
98
98
  */
99
99
  const REDIAL_COOLDOWN_MS = 15_000;
100
+ /**
101
+ * DOD-CAP-SELF-HEAL-1 — how long an interrupted session keeps consuming a cap slot.
102
+ *
103
+ * ATTRIBUTION ALONE DID NOT FIX THIS, and the reason is worth keeping. Recording who ended a
104
+ * session only works for sessions ended after the recording started: every row written before the
105
+ * column existed is unlabelled, and an unlabelled row counts. So the operator's actual backlog —
106
+ * five finished conversations that were blocking two of their own agents — was untouched by it.
107
+ * Attribution can never clear history, and history is what fills a cap.
108
+ *
109
+ * Age can. An interrupted session nobody has touched for hours is debris, not a live obligation,
110
+ * and that is true whether our restart or their disconnect produced it.
111
+ *
112
+ * D18 SURVIVES BECAUSE THE ATTACK IS A RATE. The disconnect-evasion peer has to drop and reopen
113
+ * faster than this window to gain anything, so everything it churns is recent and everything it
114
+ * churns still counts. What ages out is the thing that was never an attack: a conversation that
115
+ * finished. An attacker who waits out the window to gain one slot per window is not evading the
116
+ * bound, they are obeying a slower one — and the global anti-swarm cap still applies on top.
117
+ *
118
+ * Two hours: comfortably longer than any churn worth attacking with, comfortably shorter than
119
+ * "yesterday's conversation still blocks me".
120
+ */
121
+ export const CAP_INTERRUPTED_TTL_MS = Number(process.env["CELLO_CAP_INTERRUPTED_TTL_MS"]) || 2 * 60 * 60 * 1000;
122
+ /**
123
+ * DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
124
+ *
125
+ * `active` always. `interrupted` ONLY when the counterparty caused it.
126
+ *
127
+ * D18 is why `interrupted` has to count at all: a peer can flip a session to `interrupted` for free
128
+ * by dropping its stream, then open a fresh one, indefinitely. Those are theirs and still count.
129
+ *
130
+ * What broke was charging them for OURS. A daemon restart flips every live session to
131
+ * `interrupted`, nothing resolves them, and the reaper correctly refuses to take any with received
132
+ * content — so the bound became all-time instead of concurrent. Measured 2026-08-17: two of one
133
+ * operator's own agents could not open a session, because one held five finished conversations with
134
+ * the other against a stranger cap of three.
135
+ *
136
+ * NULL counts as the counterparty's. The column is new, so every pre-existing row is unlabelled,
137
+ * and the safe default for an anti-abuse bound is to count rather than to excuse.
138
+ */
139
+ const CAP_COUNTS = (alias = "") => {
140
+ const p = alias ? `${alias}.` : "";
141
+ return `(${p}status = 'active'
142
+ OR (${p}status = 'interrupted'
143
+ AND COALESCE(${p}interrupted_by, 'counterparty') != 'local'
144
+ AND ${p}updated_at >= ?))`;
145
+ };
146
+ const CAP_COUNT_SQL = (where) => `SELECT COUNT(*) AS n FROM sessions WHERE ${where} AND ${CAP_COUNTS()}`;
147
+ /** The cutoff an interrupted session must be newer than to still count. */
148
+ const capStaleBefore = () => Date.now() - CAP_INTERRUPTED_TTL_MS;
100
149
  // Persistence bounds are TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-migration). The two
101
150
  // consts below DERIVE from the grid's UNKNOWN row rather than restating it — the grid is the single
102
151
  // source (DOD-TIER-2 AC4), so these can never drift from it.
@@ -743,6 +792,18 @@ export class SessionNodeManager {
743
792
  // Deliberately NOT a status — the session stays sealable, so the operator can still take a
744
793
  // unilateral receipt. It stops this side calling them, nothing more.
745
794
  "ALTER TABLE sessions ADD COLUMN counterparty_abandoned_at INTEGER",
795
+ // DOD-CAP-SELF-HEAL-1: WHO caused this session to be interrupted — 'counterparty' when their
796
+ // stream dropped, 'local' when OUR daemon stopped or started. Only theirs counts against the
797
+ // acceptance bound. Without this the bound is all-time rather than concurrent: every restart
798
+ // flips every live session to `interrupted`, nothing ever resolves them, and a pair of agents
799
+ // that has talked three times can never talk again. NULL means "not recorded" and is treated
800
+ // as the counterparty's, because the safe default for an anti-abuse bound is to count it.
801
+ "ALTER TABLE sessions ADD COLUMN interrupted_by TEXT",
802
+ // DOD-M12B-RESTART-SEAL-1: when automatic sealing exhausted this session, and why. Durable
803
+ // because the resolver's attempt budget is in memory — without it a machine that restarts
804
+ // several times a day re-runs the whole budget against a hopeless session on every boot.
805
+ "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_at INTEGER",
806
+ "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_reason TEXT",
746
807
  ]) {
747
808
  try {
748
809
  this.#db.exec(ddl);
@@ -1057,7 +1118,10 @@ export class SessionNodeManager {
1057
1118
  for (const row of activeRows) {
1058
1119
  try {
1059
1120
  this.#db
1060
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?")
1121
+ .prepare(
1122
+ // DOD-CAP-SELF-HEAL-1: OURS. This is the boot sweep finding sessions a previous
1123
+ // process left `active`; the counterparty did nothing, so they are not charged for it.
1124
+ "UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
1061
1125
  .run(now, interruptedAt, row.agent_id, row.session_id);
1062
1126
  if (row.agent_name === null) {
1063
1127
  this.#logger.error("session.agent.orphaned", {
@@ -1738,8 +1802,8 @@ export class SessionNodeManager {
1738
1802
  if (!this.#db)
1739
1803
  return 0;
1740
1804
  const row = this.#db
1741
- .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
1742
- .get(this.#requireAgentId(agentName), counterpartyPubkey);
1805
+ .prepare(CAP_COUNT_SQL("agent_id = ? AND counterparty_pubkey = ?"))
1806
+ .get(this.#requireAgentId(agentName), counterpartyPubkey, capStaleBefore());
1743
1807
  return row.n;
1744
1808
  }
1745
1809
  /** M8C-ABUSE-1 (anti-swarm) + DOD-TIER-2: non-terminal sessions this agent holds with UNKNOWN-tier
@@ -1754,13 +1818,14 @@ export class SessionNodeManager {
1754
1818
  return 0;
1755
1819
  const row = this.#db
1756
1820
  .prepare(`SELECT COUNT(*) AS n FROM sessions s
1757
- WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
1821
+ WHERE s.agent_id = ?
1822
+ AND ${CAP_COUNTS("s")}
1758
1823
  AND NOT EXISTS (
1759
1824
  SELECT 1 FROM contacts c
1760
1825
  WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
1761
1826
  AND c.tier >= ${TIER.KNOWN} AND c.tier <= ${TIER.VIP}
1762
1827
  )`)
1763
- .get(this.#requireAgentId(agentName));
1828
+ .get(this.#requireAgentId(agentName), capStaleBefore());
1764
1829
  return row.n;
1765
1830
  }
1766
1831
  /** M8C-ABUSE-1 + DOD-TIER-2/3: is a NEW inbound session from this counterparty within the
@@ -1776,6 +1841,10 @@ export class SessionNodeManager {
1776
1841
  const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
1777
1842
  const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
1778
1843
  if (perSender >= perSenderCap) {
1844
+ // BYTE-IDENTICAL to every other refusal, deliberately — DOD-TIER-3. A BLOCKED sender and an
1845
+ // over-cap UNKNOWN must be indistinguishable, or the refusal tells someone they are blocked.
1846
+ // The operator's alarm needs numbers, so it asks for them SEPARATELY via capDiagnostics;
1847
+ // hanging them off this object would put a distinguishing oracle in the return value.
1779
1848
  return { ok: false, reason: "abuse_bound_sessions_per_sender" };
1780
1849
  }
1781
1850
  // The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
@@ -2572,6 +2641,18 @@ export class SessionNodeManager {
2572
2641
  };
2573
2642
  await this.#handleContentStream(agentName, sessionId, source, remotePeerId);
2574
2643
  }
2644
+ /** DOD-CAP-SELF-HEAL-1 test seam: the shutdown sweep's effect, without a shutdown. Mirrors the
2645
+ * real UPDATE at gracefulShutdown so a test cannot pass against a label production never sets. */
2646
+ markSessionsInterruptedByLocalShutdownForTest() {
2647
+ // SCOPED TO UNLABELLED ROWS. Unscoped, this would relabel rows already marked
2648
+ // `counterparty` — one call excusing every interruption every attacker ever caused, on every
2649
+ // agent. Production never relabels; nor does this.
2650
+ this.#db?.prepare("UPDATE sessions SET interrupted_by = 'local' WHERE status = 'interrupted' AND interrupted_by IS NULL").run();
2651
+ }
2652
+ /** DOD-CAP-SELF-HEAL-1 test seam: the counterparty's stream closing, without a real peer. */
2653
+ markInterruptedByCounterpartyForTest(agentName, sessionId) {
2654
+ this.#db?.prepare("UPDATE sessions SET interrupted_by = 'counterparty' WHERE agent_id = ? AND session_id = ?").run(this.#requireAgentId(agentName), sessionId);
2655
+ }
2575
2656
  /** Test seam (same spirit as getDb()): seed per-session direct-path liveness, which is otherwise
2576
2657
  * only set by the live node's onPeerConnect/onPeerDisconnect (#wireSessionLiveness). Lets a
2577
2658
  * DB-seeded test exercise the CC-5 reaper's "alive counterparty must survive" gate without standing
@@ -2726,7 +2807,10 @@ export class SessionNodeManager {
2726
2807
  // surface as interrupted so AC-010 recovery handles them at next login.
2727
2808
  // The session.node.destroyed log preserves the original reason for observability.
2728
2809
  const dbStatus = reason === "sealed" ? "sealed" : "interrupted";
2729
- this.#updateSessionStatus(agentName, sessionId, dbStatus);
2810
+ // DOD-CAP-SELF-HEAL-1: OURS. Every caller of this with a non-sealed reason is a local teardown
2811
+ // — the operator's kill switch (`cello_set_agent_offline`), an internal error, a node replaced.
2812
+ // The counterparty did nothing, so they must not be charged a cap slot for it.
2813
+ this.#updateSessionStatus(agentName, sessionId, dbStatus, dbStatus === "interrupted" ? "local" : undefined);
2730
2814
  this.#activeNodes.delete(this.#k(agentName, sessionId));
2731
2815
  // Evict the in-memory per-session caches on teardown. The tree is durable in
2732
2816
  // SQLite (getSessionTree reloads it on demand), and the received-content buffer
@@ -2979,7 +3063,9 @@ export class SessionNodeManager {
2979
3063
  else {
2980
3064
  const interruptedAt = new Date(now).toISOString();
2981
3065
  try {
2982
- this.#db.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE status = 'active'").run(now, interruptedAt);
3066
+ this.#db.prepare(
3067
+ // DOD-CAP-SELF-HEAL-1: OURS. Our own shutdown ended these, not the counterparty.
3068
+ "UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE status = 'active'").run(now, interruptedAt);
2983
3069
  }
2984
3070
  catch (err) {
2985
3071
  this.#logger.error("session.interrupt.db.write.failed", {
@@ -3076,6 +3162,69 @@ export class SessionNodeManager {
3076
3162
  WHERE s.status = ? AND a.state != 'retired'`)
3077
3163
  .all(status);
3078
3164
  }
3165
+ /**
3166
+ * DOD-M12B-RESTART-SEAL-1 — the sessions OUR OWN stop orphaned, and only those.
3167
+ *
3168
+ * `interrupted_by` is the whole safety argument. `'local'` means the boot sweep, the shutdown
3169
+ * sweep, or the operator's own kill switch ended this session — nobody else did, and it cannot be
3170
+ * resumed because the transport keypairs died with the process. Those are the ones the resolver
3171
+ * may seal on its own.
3172
+ *
3173
+ * Everything else is excluded and must stay excluded:
3174
+ * 'counterparty' — they hung up. SI-001: the operator may still want to wait.
3175
+ * 'relay_stream_close' — our relay witness link ended; the session itself may be fine.
3176
+ * NULL — written before the column existed, so the cause is UNKNOWN. An
3177
+ * unknown cause is not a licence to notarize; it is the reason not to.
3178
+ *
3179
+ * Same INNER JOIN discipline as getSessionsByStatus: a retired agent's rows are kept for
3180
+ * accountability and are not resumable, so they are not offered for sealing either.
3181
+ */
3182
+ listRestartOrphanedSessions() {
3183
+ if (!this.#db)
3184
+ return [];
3185
+ const rows = this.#db
3186
+ .prepare(
3187
+ // `message_count > 0` — a never-messaged interrupted session is a dead HANDSHAKE, which
3188
+ // `classifySession` deliberately hides in the "failed" bucket so it does not clutter
3189
+ // status. Sealing one spends a directory ceremony to obtain a receipt over nothing, and
3190
+ // then moves it into the operator's CLOSED list, making the clutter visible. The whole
3191
+ // justification for this work is "3,576 messages produced nothing" — zero messages is
3192
+ // nothing to produce.
3193
+ //
3194
+ // `restart_seal_gave_up_at IS NULL` — a session we have already exhausted. Without it a
3195
+ // machine restarting ~6 times a day re-runs five ceremonies against a hopeless session on
3196
+ // every boot, forever.
3197
+ `SELECT s.session_id AS session_id, s.message_count AS message_count, a.agent_name AS agent_name
3198
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
3199
+ WHERE s.status = 'interrupted' AND s.interrupted_by = 'local' AND a.state != 'retired'
3200
+ AND s.message_count > 0
3201
+ AND s.restart_seal_gave_up_at IS NULL
3202
+ ORDER BY s.updated_at ASC`)
3203
+ .all();
3204
+ return rows.map((r) => ({
3205
+ agentName: r.agent_name,
3206
+ sessionId: r.session_id,
3207
+ messageCount: r.message_count ?? 0,
3208
+ }));
3209
+ }
3210
+ /**
3211
+ * DOD-M12B-RESTART-SEAL-1 — record that automatic sealing has exhausted this session.
3212
+ *
3213
+ * Durable on purpose. The resolver's attempt budget is in memory, so without this the budget
3214
+ * resets on every boot and a session that can never seal costs five directory ceremonies a day
3215
+ * for the life of the machine.
3216
+ *
3217
+ * IT IS NOT A DEAD END FOR THE OPERATOR. The row keeps status `interrupted`, so a manual
3218
+ * `cello_close_session` still works on it and — since DOD-M12B-INTERRUPTED-ESCALATE-1 — still
3219
+ * escalates to a unilateral seal. This column only withdraws the session from AUTOMATIC retries.
3220
+ */
3221
+ markRestartSealGaveUp(agentName, sessionId, reason) {
3222
+ if (!this.#db)
3223
+ return;
3224
+ this.#db
3225
+ .prepare("UPDATE sessions SET restart_seal_gave_up_at = ?, restart_seal_gave_up_reason = ? WHERE agent_id = ? AND session_id = ?")
3226
+ .run(Date.now(), reason, this.#requireAgentId(agentName), sessionId);
3227
+ }
3079
3228
  /**
3080
3229
  * cello_list_sessions: every persisted session for one agent, regardless of
3081
3230
  * status (active, interrupted, sealed, seal_interrupted_pending). Ordered most
@@ -3121,6 +3270,31 @@ export class SessionNodeManager {
3121
3270
  * persisted); in that case we no-op rather than throw — the cert still flows through the
3122
3271
  * live return path. The legibility content is identical regardless of delivery timing.
3123
3272
  */
3273
+ /**
3274
+ * DOD-M12B-INTERRUPTED-ESCALATE-1 — flip a session to `sealed`, synchronously, without needing a
3275
+ * live node.
3276
+ *
3277
+ * **`destroySessionNode(…, "sealed")` cannot be relied on to do this.** It returns early at
3278
+ * `if (!entry) return`, and the status write lives 26 lines BELOW that guard — so it flips the
3279
+ * status only for a session that still has an `#activeNodes` entry. An interrupted session has
3280
+ * none by construction: every producer of that status deletes the entry. Before this method, a
3281
+ * unilateral seal on an interrupted session stored the notarized root and the certificate and
3282
+ * left the row saying `interrupted` — the receipt landed and nothing that represents it moved.
3283
+ * `cello_sessions` still showed it stuck, `cello_close_session` still refused it by name, and the
3284
+ * restart-seal resolver re-selected it on the next boot to run the whole ceremony again against a
3285
+ * session that already held a receipt.
3286
+ *
3287
+ * STATUS FIRST AND SYNCHRONOUS, teardown second — the order `abandonSession` uses and the one
3288
+ * `retireSession` documents. The flip is the load-bearing half; the teardown makes memory agree
3289
+ * with it. `#updateSessionStatus` also runs the terminal disposition hooks (held content is
3290
+ * annexed, not stranded), which the early return skipped entirely.
3291
+ */
3292
+ markSealed(agentName, sessionId) {
3293
+ // The terminal guard (abandoned/sealed must not be overwritten) lives in #updateSessionStatus,
3294
+ // so it holds for destroySessionNode and retireSession too — not only for callers of this
3295
+ // wrapper.
3296
+ return this.#updateSessionStatus(agentName, sessionId, "sealed");
3297
+ }
3124
3298
  recordSealCertificate(agentName, sessionId, sealedRootHex, legibilityJson) {
3125
3299
  if (!this.#db)
3126
3300
  return;
@@ -3227,7 +3401,21 @@ export class SessionNodeManager {
3227
3401
  // the pre-check above raced (it cannot — DatabaseSync is synchronous), the
3228
3402
  // UPDATE only mutates a row that is still active.
3229
3403
  this.#db
3230
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_id = ? AND session_id = ? AND status = 'active'")
3404
+ .prepare(
3405
+ // DOD-CAP-SELF-HEAL-1: labelled by SOURCE, because the two are not the same event.
3406
+ //
3407
+ // relay_frame — the relay telling us the counterparty went. THEIRS. The D18
3408
+ // disconnect-evasion move, and it must keep counting.
3409
+ // stream_close — OUR witness stream to the relay ended. That fires on a relay restart,
3410
+ // a relay fleet roll, or a local network blip. Claiming the counterparty
3411
+ // did it means three relay deploys permanently refuse a peer who was
3412
+ // never involved — and relay deploys are routine, so it ratchets faster
3413
+ // than daemon restarts do.
3414
+ //
3415
+ // `relay_stream_close` is its own label and STILL COUNTS (the bound excuses only 'local'),
3416
+ // because an attacker who can disturb our relay link must not get a free cap reset. It is
3417
+ // recorded honestly rather than blamed on the wrong party.
3418
+ `UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ?, interrupted_by = '${source === "relay_frame" ? "counterparty" : "relay_stream_close"}' WHERE agent_id = ? AND session_id = ? AND status = 'active'`)
3231
3419
  .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
3232
3420
  }
3233
3421
  catch (err) {
@@ -4026,6 +4214,33 @@ export class SessionNodeManager {
4026
4214
  // this point wins, the second short-circuits. The check+set is SYNCHRONOUS (before any await) so
4027
4215
  // two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
4028
4216
  // both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
4217
+ // DOD-M12B-INTERRUPTED-ESCALATE-1 — THE MARK MUST SURVIVE A RESTART, or one automatic retry
4218
+ // permanently forfeits the receipt.
4219
+ //
4220
+ // `#responderSealSubmitted` is in memory. A session whose close was in flight when the daemon
4221
+ // stopped already has our SEAL ctrl leaf in the relay log — and on the next boot the mark is
4222
+ // empty, so the restart-seal resolver's automatic close would submit a SECOND one. The
4223
+ // directory requires exactly one ctrl leaf (`ctrlLeaves.length !== 1 → unilateral_seal_leaf_invalid`)
4224
+ // and the carry is durable, so every future attempt would carry both and be refused forever.
4225
+ //
4226
+ // The durable evidence already exists and was simply not consulted: our own ctrl leaf is in
4227
+ // `session_seal_leaves`. Recover the escalation values from it instead of submitting again.
4228
+ if (!this.#responderSealSubmitted.has(sealKey)) {
4229
+ const durable = this.#recoverOwnSealCtrlLeaf(agentName, sessionId);
4230
+ if (durable === "unknown") {
4231
+ // REFUSE, do not submit. A second ctrl leaf makes the session unsealable forever, and the
4232
+ // question "is one already there?" just failed to answer. Refusing costs this close; a
4233
+ // second leaf costs the receipt permanently.
4234
+ return { ok: false, reason: "seal_leaf_recovery_unavailable" };
4235
+ }
4236
+ if (durable !== "none") {
4237
+ this.#logger.info("session.seal.leaf.already_submitted.recovered", {
4238
+ sessionId, agentName, sequenceNumber: durable.sequenceNumber,
4239
+ impact: "our SEAL ctrl leaf is already in the relay log from a previous run; submitting a second would make this session unsealable forever",
4240
+ });
4241
+ this.#responderSealSubmitted.set(sealKey, durable);
4242
+ }
4243
+ }
4029
4244
  if (this.#responderSealSubmitted.has(sealKey)) {
4030
4245
  // M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
4031
4246
  // still escalate to a unilateral seal. A null value means that submit is still in
@@ -4881,6 +5096,69 @@ export class SessionNodeManager {
4881
5096
  /** DOD-M12B-INDEX-1 — this agent's own K_local pubkey, for attributing its own held content.
4882
5097
  * Null when it cannot be resolved: an UNATTRIBUTED annex row is true, a falsely attributed one
4883
5098
  * is not, and this is the record that outlives the session. */
5099
+ /**
5100
+ * DOD-M12B-INTERRUPTED-ESCALATE-1 — our own SEAL ctrl leaf, if a previous run already posted one.
5101
+ *
5102
+ * Returns the two values a unilateral escalation runs on, rebuilt from durable state:
5103
+ * the ctrl leaf's relay-assigned sequence, and the root the tree WOULD have with that leaf
5104
+ * appended. The content hash cannot be recomputed — the seal payload embeds a `close_timestamp`
5105
+ * — so it is read out of the signed Structure 1 the store already holds.
5106
+ *
5107
+ * Null when there is no own ctrl leaf, which is the ordinary first-close case.
5108
+ */
5109
+ #recoverOwnSealCtrlLeaf(agentName, sessionId) {
5110
+ const ownPubkey = this.#ownPubkeyHex(agentName);
5111
+ // "I CANNOT TELL" IS NOT "THERE IS NONE". Returning the absent answer here would let the caller
5112
+ // submit a second SEAL ctrl leaf — the exact permanent loss this method exists to prevent — on
5113
+ // the strength of a lookup that failed. Every path that could not determine the answer says so.
5114
+ if (!ownPubkey) {
5115
+ this.#logger.warn("session.seal.leaf.recover.failed", {
5116
+ sessionId, agentName, reason: "own_pubkey_unresolved",
5117
+ impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
5118
+ });
5119
+ return "unknown";
5120
+ }
5121
+ let own;
5122
+ try {
5123
+ own = this.getSealCarry(ownPubkey, sessionId)
5124
+ .find((l) => l.leafKind === LEAF_KIND_CTRL && l.senderPubkeyHex === ownPubkey);
5125
+ }
5126
+ catch (err) {
5127
+ this.#logger.warn("session.seal.leaf.recover.failed", {
5128
+ sessionId, agentName, reason: "carry_read_failed",
5129
+ error: err instanceof Error ? err.message : String(err),
5130
+ impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
5131
+ });
5132
+ return "unknown";
5133
+ }
5134
+ if (!own)
5135
+ return "none";
5136
+ try {
5137
+ // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
5138
+ const fields = decode(own.structure1Cbor);
5139
+ const contentHash = fields[1];
5140
+ if (!(contentHash instanceof Uint8Array)) {
5141
+ this.#logger.warn("session.seal.leaf.recover.failed", {
5142
+ sessionId, agentName, reason: "structure1_content_hash_missing",
5143
+ impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
5144
+ });
5145
+ return "unknown";
5146
+ }
5147
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
5148
+ return {
5149
+ reportedRootHex: this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex),
5150
+ sequenceNumber: own.sequenceNumber,
5151
+ };
5152
+ }
5153
+ catch (err) {
5154
+ this.#logger.warn("session.seal.leaf.recover.failed", {
5155
+ sessionId, agentName, reason: "structure1_decode_failed",
5156
+ error: err instanceof Error ? err.message : String(err),
5157
+ impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
5158
+ });
5159
+ return "unknown";
5160
+ }
5161
+ }
4884
5162
  #ownPubkeyHex(agentName) {
4885
5163
  if (!this.#db)
4886
5164
  return null;
@@ -5180,6 +5458,41 @@ export class SessionNodeManager {
5180
5458
  return false;
5181
5459
  }
5182
5460
  }
5461
+ /**
5462
+ * DOD-CAP-SELF-HEAL-1 — the numbers behind a cap refusal, for the OPERATOR'S alarm only.
5463
+ *
5464
+ * Kept off `checkUnknownSenderAcceptanceBound`'s return on purpose. That refusal is byte-identical
5465
+ * across tiers by design (DOD-TIER-3) so a blocked party cannot tell blocking from throttling;
5466
+ * attaching the counts to it would put the oracle straight into the value the refusal path
5467
+ * carries. This is a separate, purely local read, and nothing it returns crosses the wire.
5468
+ */
5469
+ capDiagnostics(agentName, counterpartyPubkey) {
5470
+ const tier = this.getTier(agentName, counterpartyPubkey);
5471
+ const cap = this.resolveTierBound(agentName, tier, "max_sessions");
5472
+ const counted = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
5473
+ return {
5474
+ tier, cap, counted,
5475
+ // How many to close to get UNDER the cap — not how many exist. At 5 against a cap of 3 the
5476
+ // answer is 3, and "close 5" tells the operator to do more than the job needs.
5477
+ mustClear: Math.max(0, counted - cap + 1),
5478
+ blocked: tier === TIER.BLOCKED,
5479
+ };
5480
+ }
5481
+ /** DOD-CAP-SELF-HEAL-1: the sessions with this counterparty that are consuming cap slots, oldest
5482
+ * first. The operator is told to close some — this is WHICH, because "close three of them" with
5483
+ * no list is not an instruction they can follow. */
5484
+ sessionsConsumingCap(agentName, counterpartyPubkey, limit = 10) {
5485
+ if (!this.#db)
5486
+ return [];
5487
+ try {
5488
+ const rows = this.#db.prepare(`SELECT session_id FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND ${CAP_COUNTS()}
5489
+ ORDER BY updated_at ASC LIMIT ?`).all(this.#requireAgentId(agentName), counterpartyPubkey, capStaleBefore(), limit);
5490
+ return rows.map((r) => r.session_id);
5491
+ }
5492
+ catch {
5493
+ return [];
5494
+ }
5495
+ }
5183
5496
  /** DOD-M12B-ABANDON-NOTIFY-1: has the counterparty told us they hung up? */
5184
5497
  counterpartyAbandonedAt(agentName, sessionId) {
5185
5498
  if (!this.#db)
@@ -7001,6 +7314,14 @@ export class SessionNodeManager {
7001
7314
  const sender = row.origin === "sent" ? this.#ownPubkeyHex(agentName) : counterparty;
7002
7315
  if (this.recordSealedAnnex(agentName, sessionId, row.content_hash_hex, new Uint8Array(row.content_blob), sender)) {
7003
7316
  this.#deleteHeldContent(agentName, sessionId, row.canonical_seq);
7317
+ // AND OUT OF THE IN-MEMORY MAP. Measured live 2026-08-17 on daemon 0.0.170: this frame is
7318
+ // now safe in the annex and its durable row is gone — but teardown still found it in the
7319
+ // map, counted `held_content` for the session, got 0, and fired
7320
+ // `session.content.held.lost`: "verified content was destroyed". Ten frames were annexed
7321
+ // and the same ten were reported destroyed, in the same second. A false alarm on the most
7322
+ // serious event in the system is worse than no alarm, because the next investigation goes
7323
+ // looking for content that was never lost.
7324
+ this.#heldContent.get(this.#k(agentName, sessionId))?.delete(row.canonical_seq);
7004
7325
  annexed++;
7005
7326
  }
7006
7327
  else {
@@ -7014,13 +7335,53 @@ export class SessionNodeManager {
7014
7335
  impact: "these messages arrived and verified but never joined the chain — they are readable from the annex, not the transcript",
7015
7336
  });
7016
7337
  }
7017
- #updateSessionStatus(agentName, sessionId, status) {
7338
+ #updateSessionStatus(agentName, sessionId, status,
7339
+ // DOD-CAP-SELF-HEAL-1: who caused an interruption, when this call is the one causing it.
7340
+ // Omitting it leaves the column NULL, which the acceptance bound reads as the counterparty's —
7341
+ // so a LOCAL teardown that forgets to say so is charged to the peer. That is exactly how the
7342
+ // operator's own kill switch (`cello_set_agent_offline` → destroySessionNode) was locking out
7343
+ // a counterparty who had done nothing.
7344
+ interruptedBy) {
7018
7345
  if (!this.#db)
7019
7346
  return false;
7347
+ // THE TERMINAL GUARD LIVES HERE, not in one wrapper, because there are three writers of
7348
+ // "sealed": markSealed, destroySessionNode, and retireSession on the witnessed-submit path.
7349
+ // Guarding only the wrapper asserts the invariant in a test while two other paths still break
7350
+ // it.
7351
+ //
7352
+ // abandoned → sealed REFUSED. A force-abandon is the documented way to give up a receipt.
7353
+ // A certificate arriving afterwards must not silently overturn the operator's decision. The
7354
+ // certificate is still stored by recordSealCertificate and stays retrievable.
7355
+ // sealed → sealed REFUSED. Nothing to write, and re-running the terminal disposition
7356
+ // hooks for a no-op should not be reported as a status that landed.
7357
+ if (status === "sealed") {
7358
+ const current = this.getSessionRecord(agentName, sessionId)?.status;
7359
+ if (current === "abandoned" || current === "sealed") {
7360
+ this.#logger.info("session.seal.status.not_written", {
7361
+ agentName, sessionId, currentStatus: current,
7362
+ impact: current === "abandoned"
7363
+ ? "a certificate arrived for a session the operator force-abandoned; it is stored and retrievable, but the row keeps saying abandoned"
7364
+ : "already sealed — nothing to write",
7365
+ });
7366
+ return false;
7367
+ }
7368
+ if (current === undefined) {
7369
+ // ORDINARY, not an error. recordSealCertificate documents this case: the seal can arrive
7370
+ // before the row is persisted. Falling through would emit `session.status.write.missed` at
7371
+ // ERROR level for a shape the system expects.
7372
+ this.#logger.info("session.seal.status.no_row", {
7373
+ agentName, sessionId,
7374
+ impact: "no session row yet — the certificate is still recorded and retrievable",
7375
+ });
7376
+ return false;
7377
+ }
7378
+ }
7020
7379
  const now = Date.now();
7021
7380
  try {
7022
7381
  const res = this.#db
7023
- .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
7382
+ .prepare(interruptedBy === undefined
7383
+ ? "UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?"
7384
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
7024
7385
  .run(status, now, this.#requireAgentId(agentName), sessionId);
7025
7386
  // "Did not throw" is NOT "landed". An UPDATE whose WHERE matches no row — a wrong agent_id, a
7026
7387
  // session_id with no row — succeeds silently and changes nothing. Reporting that as a written