@cello-protocol/daemon 0.0.174 → 0.0.176

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.
@@ -26,7 +26,7 @@ import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUN
26
26
  import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
27
27
  import { ensureTrustSignalSchema } from "./trust-signal-store.js";
28
28
  import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
29
- import { randomUUID, createHash } from "node:crypto";
29
+ import { randomUUID, createHash, randomBytes } from "node:crypto";
30
30
  import * as lp from "it-length-prefixed";
31
31
  import { decode } from "cbor-x";
32
32
  import { encodeCbor } from "@cello-protocol/protocol-types";
@@ -173,6 +173,26 @@ export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
173
173
  * the leak the old destructive read was accidentally preventing.
174
174
  */
175
175
  const RECEIVED_BUFFER_CAP = 32;
176
+ /**
177
+ * DOD-M12B-REVIVAL-BOUND-1 — how long an interrupted session stays revivable before it is closed.
178
+ *
179
+ * 24 hours. The bound exists because of Andre's 2026-08-18 tenet — *"leave nothing open that is no
180
+ * longer needed"* — and its value is set by the case it must not break: a laptop closed for the
181
+ * night. A window shorter than a night's sleep would abandon exactly the sessions case A/B exist to
182
+ * rescue, which is why this is not zero and not an hour.
183
+ *
184
+ * It is deliberately a plain constant and not a setting. A per-operator knob here is a knob that
185
+ * turns the guarantee off, and the guarantee is the security property, not a preference.
186
+ */
187
+ export const REVIVAL_WINDOW_MS = 24 * 60 * 60 * 1000;
188
+ /**
189
+ * DOD-M12B-REVIVAL-BOUND-1 — how often the revival bound is applied.
190
+ *
191
+ * Hourly. The window is 24 hours, so the worst-case overshoot is ~4% of the bound, and the pass is
192
+ * one DB walk with no network in it. Boot-only was the first build and it is not a bound at all: a
193
+ * daemon left up for a week never applies it, and a long-lived daemon is the normal case.
194
+ */
195
+ export const REVIVAL_BOUND_SWEEP_MS = 60 * 60 * 1000;
176
196
  export class SessionNodeManager {
177
197
  #factory;
178
198
  #logger;
@@ -287,6 +307,29 @@ export class SessionNodeManager {
287
307
  // `hasReservation`: this receiver came up holding a /p2p-circuit address. The
288
308
  // watchdog uses it to tell "lost its reservation" (must recover) apart from
289
309
  // "never had one" (already degraded, and already loud) — see #reservationWatchdogTick.
310
+ /**
311
+ * DOD-M12B-SESSION-SEED-1 — session id → the transport seed its node identity derives from.
312
+ *
313
+ * **DELIBERATELY NOT ON `ActiveSessionEntry`.** That entry is deleted the instant a session is
314
+ * interrupted (`markInterruptedWithDetails` and `destroySessionNode` both do it), which is exactly
315
+ * the moment the seed becomes necessary — storing it there would destroy it precisely when the
316
+ * session needs to come back. It has to outlive the NODE without outliving the SESSION.
317
+ *
318
+ * Andre's 2026-08-18 tenet is the lifetime rule: *"it should be possible to revive that session on
319
+ * those peer IDs. But after that, those peer IDs and that peer connection needs to be shut down."*
320
+ * So this map is cleared in the same step that writes a terminal status (`#updateSessionStatus`),
321
+ * not on a later sweep — and the bytes are zeroed before the reference is dropped, because a seed
322
+ * that stays readable in the heap is exactly the "left open" the tenet forbids.
323
+ *
324
+ * It holds the counterparty's session peer id alongside the seed, and not for convenience: that
325
+ * value lives ONLY on `ActiveSessionEntry` and is destroyed with it on interruption, so without a
326
+ * copy here we could rebuild our own identity and still not know who to let back in. Both halves
327
+ * of the revival have the same lifetime, so they are destroyed in one step.
328
+ *
329
+ * In memory only, never persisted. A daemon restart genuinely destroys these identities, which is
330
+ * why restart is `RESTART-SEAL-1`'s case (resolve with a receipt) and not a revival case.
331
+ */
332
+ #sessionSeeds = new Map();
290
333
  #standingReceivers = new Map();
291
334
  #standingReceiverCreating = new Set();
292
335
  // M8B F14: agents that SHOULD have a standing receiver — marked by
@@ -2176,6 +2219,9 @@ export class SessionNodeManager {
2176
2219
  let node;
2177
2220
  let gater;
2178
2221
  let autoNat;
2222
+ // DOD-M12B-SESSION-SEED-1: whichever branch below runs, the session ends up owning a seed.
2223
+ // Promotion inherits the receiver's; a freshly-built node mints its own.
2224
+ let seed;
2179
2225
  if (reuseStandingReceiver) {
2180
2226
  const sr = this.#standingReceivers.get(agentName);
2181
2227
  if (!sr) {
@@ -2189,7 +2235,7 @@ export class SessionNodeManager {
2189
2235
  guidance: "The standing receiver node is initializing (completes within 200ms). Retry the session in a moment.",
2190
2236
  };
2191
2237
  }
2192
- ({ node, gater, autoNat } = sr);
2238
+ ({ node, gater, autoNat, seed } = sr);
2193
2239
  gater.setAllowedPeer(counterpartyPeerId);
2194
2240
  // Hand this agent's standing receiver off to this session; a replacement is spun up below.
2195
2241
  this.#standingReceivers.delete(agentName);
@@ -2201,7 +2247,8 @@ export class SessionNodeManager {
2201
2247
  logger: this.#logger,
2202
2248
  });
2203
2249
  try {
2204
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "session" });
2250
+ seed = randomBytes(32);
2251
+ node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
2205
2252
  await node.start();
2206
2253
  }
2207
2254
  catch (err) {
@@ -2262,7 +2309,7 @@ export class SessionNodeManager {
2262
2309
  // Log observability event (session.node.created)
2263
2310
  //
2264
2311
  // `counterpartySessionPeerId` IS LOGGED because it is recorded here ONCE and never refreshed,
2265
- // while a standing receiver is rebuilt with a fresh libp2p keypair on every signaling reconnect
2312
+ // while a standing receiver is rebuilt with a fresh libp2p keypair on a lost relay reservation
2266
2313
  // and every lost reservation. If the peer rebuilds between advertising its endpoint and this
2267
2314
  // handoff, we record an identity that no longer exists — and since `newStream` never dials, it
2268
2315
  // only ever looks for an ALREADY-OPEN connection filed under exactly this string, so every send
@@ -2288,6 +2335,7 @@ export class SessionNodeManager {
2288
2335
  counterpartySessionPeerId: counterpartyPeerId,
2289
2336
  autoNat,
2290
2337
  });
2338
+ this.#rememberSessionSeed(agentName, sessionId, seed, counterpartyPeerId, counterpartyPubkey);
2291
2339
  // DAEMON-004: register the content stream handler so inbound content_frames
2292
2340
  // are cross-checked, appended to the daemon-owned tree, and buffered.
2293
2341
  await this.#registerContentHandler(agentName, sessionId, node, counterpartyPubkey);
@@ -2736,7 +2784,7 @@ export class SessionNodeManager {
2736
2784
  "Close an existing session before starting a new one.",
2737
2785
  };
2738
2786
  }
2739
- const { node, gater, autoNat } = inboundSr;
2787
+ const { node, gater, autoNat, seed } = inboundSr;
2740
2788
  // AC-015: update gater BEFORE retrieving multiaddr / returning to caller
2741
2789
  gater.setAllowedPeer(initiatorPeerId);
2742
2790
  const peerId = node.getPeerId();
@@ -2747,6 +2795,11 @@ export class SessionNodeManager {
2747
2795
  // pointed at this initiator.
2748
2796
  if (!this.#insertSessionRow(sessionId, agentName, counterpartyPubkey, "active")) {
2749
2797
  this.#standingReceivers.delete(agentName);
2798
+ // DOD-M12B-SESSION-SEED-1 (review F8): this abort happens BEFORE `#rememberSessionSeed`, so
2799
+ // the identity is not being handed to a session — it is being discarded, and is zeroed like
2800
+ // any other discard. (The two PROMOTION sites deliberately do not zero: there the same bytes
2801
+ // become the session's.)
2802
+ inboundSr.seed.fill(0);
2750
2803
  try {
2751
2804
  await node.stop();
2752
2805
  }
@@ -2792,6 +2845,7 @@ export class SessionNodeManager {
2792
2845
  counterpartySessionPeerId: initiatorPeerId,
2793
2846
  autoNat,
2794
2847
  });
2848
+ this.#rememberSessionSeed(agentName, sessionId, seed, initiatorPeerId, counterpartyPubkey);
2795
2849
  // DAEMON-004: register the content stream handler for the inbound session.
2796
2850
  await this.#registerContentHandler(agentName, sessionId, node, counterpartyPubkey);
2797
2851
  // M7-SESSION-003 AC-004: act on the inbound session node's peer events too.
@@ -3150,6 +3204,13 @@ export class SessionNodeManager {
3150
3204
  // plaintext must not survive shutdown in memory).
3151
3205
  this.#trees.clear();
3152
3206
  this.#receivedContent.clear();
3207
+ // DOD-M12B-SESSION-SEED-1 (review F5): transport identities are key material and belong in the
3208
+ // same sentence as the plaintext above. Shutdown marks every active row `interrupted` by direct
3209
+ // SQL, so no `#updateSessionStatus` destroy fires for them — without this, every live session's
3210
+ // seed survives the shutdown in memory for as long as the process lingers.
3211
+ for (const identity of this.#sessionSeeds.values())
3212
+ identity.seed.fill(0);
3213
+ this.#sessionSeeds.clear();
3153
3214
  // Stop ALL per-agent standing receivers (DOD-LOOP-1). In PARALLEL and BOUNDED: this was a
3154
3215
  // sequential await per agent with no deadline, so five agents meant five chances for one stuck
3155
3216
  // libp2p teardown to hold the exit — and it sits between the operator being told the daemon is
@@ -3168,6 +3229,10 @@ export class SessionNodeManager {
3168
3229
  });
3169
3230
  }
3170
3231
  })), "standing_receivers", this.#standingReceivers.size);
3232
+ // Same reason: a receiver's seed is the identity it has already advertised in
3233
+ // `session_offer_accept` for any session it is mid-handshake on.
3234
+ for (const sr of this.#standingReceivers.values())
3235
+ sr.seed.fill(0);
3171
3236
  this.#standingReceivers.clear();
3172
3237
  this.#srReservationRetry.clear();
3173
3238
  this.#srLastRejectionReason.clear();
@@ -3251,6 +3316,211 @@ export class SessionNodeManager {
3251
3316
  messageCount: r.message_count ?? 0,
3252
3317
  }));
3253
3318
  }
3319
+ /**
3320
+ * DOD-M12B-REVIVAL-BOUND-1 — interrupted sessions that can no longer be revived, and must close.
3321
+ *
3322
+ * Andre, 2026-08-18: *"after that, those peer IDs and that peer connection needs to be shut down.
3323
+ * It is an open connection that a malicious agent can farm for."* The tenet is **leave nothing
3324
+ * open that is no longer needed**, and the threat model is a daemon that has been reprogrammed —
3325
+ * so the guarantee has to hold on the side that is not the attacker.
3326
+ *
3327
+ * WHAT IS OPEN. `ingestReceivedContent` refuses `sealed`, `seal_interrupted_pending` and
3328
+ * `abandoned`, but deliberately ACCEPTS `interrupted` — that acceptance is the only reason
3329
+ * recovery can work. Nothing else ever leaves `interrupted`, so it means accepts FOREVER.
3330
+ *
3331
+ * **THIS IS THE BACKSTOP, NOT THE COMPLEMENT.** The seal path gets first refusal on every
3332
+ * local-cause session, because a session whose ending we can describe truthfully earns a
3333
+ * notarized receipt. But "the seal path owns it" is not the same as "the seal path will finish
3334
+ * it", and two populations fall through the gap between those:
3335
+ *
3336
+ * - **The resolver gave up.** `markRestartSealGaveUp` writes only `restart_seal_gave_up_at`;
3337
+ * the status stays `interrupted`, and `listRestartOrphanedSessions` then excludes the row by
3338
+ * `restart_seal_gave_up_at IS NULL` so it is never retried. `TERMINAL_SEAL_REFUSALS` has ten
3339
+ * entries and the measured figure is that 59% of seals that start never finish, so this is
3340
+ * the common case, not a corner.
3341
+ * - **Zero-message local sessions.** The resolver requires `message_count > 0` — a dead
3342
+ * handshake is not worth a ceremony. It is still an open write surface.
3343
+ *
3344
+ * Excluding those left them permanently interrupted and permanently writable, which is the exact
3345
+ * condition this line exists to end. And the population is about to become the majority: no row
3346
+ * has ever carried `interrupted_by = 'local'` yet, and from the next shutdown onward every
3347
+ * shutdown-orphaned session will. So the sweep takes a local-cause session once the seal path
3348
+ * has either declined it or exhausted it — never before.
3349
+ *
3350
+ * **THE CLOCK MUST BE ONE THE COUNTERPARTY CANNOT MOVE.** The obvious fallback for a row with no
3351
+ * `interrupted_at` is `updated_at` — and it is exactly wrong. `ingestReceivedContent` accepts
3352
+ * content into an `interrupted` session (that acceptance is this whole line's premise), and a
3353
+ * successful ingest runs `UPDATE sessions SET message_count = ?, updated_at = <now>`. So
3354
+ * `updated_at` is a clock the reprogrammed peer holds: one message every 24 hours and the session
3355
+ * never expires, forever. The fallback would have handed the attacker the off switch for the
3356
+ * control built to stop them.
3357
+ *
3358
+ * Instead the missing timestamps are STAMPED ONCE, by `#stampMissingInterruptedAt` immediately
3359
+ * before this query runs, and this query reads `interrupted_at` and nothing else. The stamp is
3360
+ * written under `WHERE interrupted_at IS NULL`, so it is monotone — set once, never moved, by us
3361
+ * and not by a peer. A legacy row therefore gets its full window starting from the first sweep
3362
+ * that sees it, which is later than the true interruption but is the only bound that is sound.
3363
+ *
3364
+ * Same retired-agent INNER JOIN as the sibling query: a retired agent's rows are kept for
3365
+ * accountability, are not resumable, and are not writable either.
3366
+ *
3367
+ * **THE TIME ARITHMETIC IS LOAD-BEARING, AND BOTH OBVIOUS FORMS OF IT ARE WRONG.** These two
3368
+ * columns do not hold the same kind of value:
3369
+ *
3370
+ * `interrupted_at` TEXT, an ISO-8601 string — `new Date(now).toISOString()`.
3371
+ * `updated_at` INTEGER, epoch milliseconds.
3372
+ *
3373
+ * There are FOUR writers of `status = 'interrupted'`, not three. The fourth is
3374
+ * `destroySessionNode` → `#updateSessionStatus(…, "interrupted", "local")`, which historically
3375
+ * wrote `interrupted_by` and **no timestamp at all** — and it is the path that produced the two
3376
+ * rows in Entry 41. It now stamps `interrupted_at` like the others, so NULL is a legacy state
3377
+ * rather than one production keeps creating.
3378
+ *
3379
+ * So a bare `interrupted_at <= ?` against a numeric bound is **always false** — the column has
3380
+ * TEXT affinity and the bound parameter has none, so SQLite applies TEXT affinity to the
3381
+ * parameter and compares them as STRINGS (`'2026-08-18T05:32:04.183Z' <= 1755000000000` → 0).
3382
+ * The query silently returns nothing forever and reads as "nothing has expired yet". And
3383
+ * `CAST(interrupted_at AS
3384
+ * INTEGER)` is worse than useless: SQLite casts by taking the leading digits, so
3385
+ * `'2026-08-18T05:32:04Z'` becomes **2026**, which is older than any epoch bound. That form
3386
+ * abandons every interrupted session on the next boot, immediately, whatever its age. It was
3387
+ * written, and `session-001`/`cello-list-sessions` failed on it in the gate.
3388
+ *
3389
+ * `strftime('%s', …) * 1000` parses the ISO string properly and returns NULL for anything it
3390
+ * cannot parse — so a malformed or differently-formatted value falls through the COALESCE to
3391
+ * `updated_at` rather than being read as the year 2026.
3392
+ */
3393
+ listExpiredUnrevivableSessions(nowMs, windowMs) {
3394
+ if (!this.#db) {
3395
+ // ABSENT IS NOT FINE. An empty array here is indistinguishable from "the store is clean",
3396
+ // which is the state this line exists to end — so the one boot where the sweep could not
3397
+ // read the store must not look like the boots where it read it and found nothing.
3398
+ this.#logger.error("session.revival_bound.enumerate.failed", {
3399
+ error: "db not initialized",
3400
+ impact: "no interrupted session was checked against the revival window this boot; any that "
3401
+ + "have expired are still accepting content",
3402
+ });
3403
+ return [];
3404
+ }
3405
+ const rows = this.#db
3406
+ .prepare(`SELECT s.session_id AS session_id, s.interrupted_by AS cause, a.agent_name AS agent_name
3407
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
3408
+ WHERE s.status = 'interrupted' AND a.state != 'retired'
3409
+ AND (COALESCE(s.interrupted_by, '') != 'local'
3410
+ OR s.restart_seal_gave_up_at IS NOT NULL
3411
+ OR s.message_count = 0)
3412
+ AND CAST(strftime('%s', s.interrupted_at) AS INTEGER) * 1000 <= ?
3413
+ ORDER BY CAST(strftime('%s', s.interrupted_at) AS INTEGER) * 1000 ASC`)
3414
+ .all(nowMs - windowMs);
3415
+ return rows.map((r) => ({ agentName: r.agent_name, sessionId: r.session_id, cause: r.cause }));
3416
+ }
3417
+ /**
3418
+ * DOD-M12B-REVIVAL-BOUND-1 — give every timestamp-less interrupted session a clock, once.
3419
+ *
3420
+ * A row with `interrupted_at IS NULL` has no bound that can be evaluated, and skipping such rows
3421
+ * would exempt the oldest sessions in the store from the control permanently — the same "open
3422
+ * forever" failure wearing a different NULL. The two rows measured in Entry 41 are exactly this
3423
+ * shape, written by a `destroySessionNode` path that set the cause and no timestamp.
3424
+ *
3425
+ * **`WHERE interrupted_at IS NULL` is the security property, not an optimisation.** It makes the
3426
+ * stamp write-once: this can run on every sweep forever and a row's clock still cannot be moved
3427
+ * after the first one. That is what disqualifies `updated_at`, which a peer moves with every
3428
+ * message it sends into the still-accepting session.
3429
+ *
3430
+ * The cost is honest and bounded: a legacy row's window starts at the first sweep that sees it
3431
+ * rather than at its true interruption, so it survives up to one window longer than it should.
3432
+ * A late close is recoverable; a clock the counterparty winds is not.
3433
+ *
3434
+ * @returns how many rows were stamped.
3435
+ */
3436
+ #stampMissingInterruptedAt(nowMs) {
3437
+ if (!this.#db)
3438
+ return 0;
3439
+ try {
3440
+ const res = this.#db
3441
+ .prepare("UPDATE sessions SET interrupted_at = ? WHERE status = 'interrupted' AND interrupted_at IS NULL")
3442
+ .run(new Date(nowMs).toISOString());
3443
+ const stamped = Number(res?.changes ?? 0);
3444
+ if (stamped > 0) {
3445
+ this.#logger.info("session.revival_bound.clock.stamped", {
3446
+ stamped,
3447
+ impact: "these sessions had no interruption timestamp; their revival window starts now",
3448
+ });
3449
+ }
3450
+ return stamped;
3451
+ }
3452
+ catch (err) {
3453
+ this.#logger.error("session.revival_bound.clock.stamp.failed", {
3454
+ error: err instanceof Error ? err.message : String(err),
3455
+ impact: "sessions with no interruption timestamp cannot be evaluated and stay open",
3456
+ });
3457
+ return 0;
3458
+ }
3459
+ }
3460
+ /**
3461
+ * DOD-M12B-REVIVAL-BOUND-1 — close every session the revival window has expired.
3462
+ *
3463
+ * `abandonSession` is the right instrument and already exists: it flips the status FIRST and
3464
+ * synchronously, annexes held content so the operator does not lose mail that has nowhere to go,
3465
+ * and retires the node. It notarizes nothing, which is the point — we are closing a door, not
3466
+ * asserting how it came to be open.
3467
+ *
3468
+ * One session's failure must not strand the rest, so each is caught and logged; the sweep runs at
3469
+ * boot beside the restart-seal resolver and a throw there would take the daemon with it.
3470
+ *
3471
+ * @returns how many sessions actually flipped — not how many were attempted.
3472
+ */
3473
+ async closeExpiredUnrevivableSessions(nowMs, windowMs) {
3474
+ // Stamp FIRST. A row with no clock cannot be evaluated by the query below, and this is the only
3475
+ // thing that gives it one. Running it before every sweep is safe because the write is scoped to
3476
+ // rows that have no timestamp yet.
3477
+ this.#stampMissingInterruptedAt(nowMs);
3478
+ const expired = this.listExpiredUnrevivableSessions(nowMs, windowMs);
3479
+ let closed = 0;
3480
+ for (const s of expired) {
3481
+ try {
3482
+ if (await this.abandonSession(s.agentName, s.sessionId)) {
3483
+ closed += 1;
3484
+ this.#logger.info("session.revival_bound.closed", {
3485
+ agentName: s.agentName,
3486
+ sessionId: s.sessionId,
3487
+ // The cause we could NOT establish is the reason this ends without a receipt — log it
3488
+ // so an operator asking "why no certificate?" gets the answer here.
3489
+ interruptedBy: s.cause ?? "unknown",
3490
+ windowMs,
3491
+ // NAME THE FORFEIT. Until this sweep ran, `cello_close_session` (without `force`) still
3492
+ // accepted this session — it takes `status IN ('active','interrupted')` — so the
3493
+ // operator could have come back days later and obtained a real seal. `abandoned` is in
3494
+ // TERMINAL_SEAL_REFUSALS, so after this they cannot. That is a deliberate trade (SI-001
3495
+ // forbids auto-sealing a session nobody chose to end) and it costs something real, so
3496
+ // it is stated rather than left implicit in a WHERE clause.
3497
+ forfeited: "a seal was still obtainable by hand until now; it is not after this",
3498
+ });
3499
+ }
3500
+ }
3501
+ catch (err) {
3502
+ this.#logger.warn("session.revival_bound.close.failed", {
3503
+ agentName: s.agentName,
3504
+ sessionId: s.sessionId,
3505
+ error: err instanceof Error ? err.message : String(err),
3506
+ });
3507
+ }
3508
+ }
3509
+ // UNCONDITIONAL. A sweep that found nothing and a sweep that never really ran must not produce
3510
+ // the same silence — this line is the only proof the control executed at all.
3511
+ this.#logger.info("session.revival_bound.sweep", { expired: expired.length, closed, windowMs });
3512
+ if (closed !== expired.length) {
3513
+ // Not arithmetic for the reader to do: a session the security control failed to close is one
3514
+ // that is still interrupted and still accepting content.
3515
+ this.#logger.warn("session.revival_bound.sweep.incomplete", {
3516
+ expired: expired.length,
3517
+ closed,
3518
+ failed: expired.length - closed,
3519
+ impact: "these sessions are still interrupted and still accept content from any peer that dials them",
3520
+ });
3521
+ }
3522
+ return closed;
3523
+ }
3254
3524
  /**
3255
3525
  * DOD-M12B-RESTART-SEAL-1 — record that automatic sealing has exhausted this session.
3256
3526
  *
@@ -3562,7 +3832,18 @@ export class SessionNodeManager {
3562
3832
  const result = this.#db
3563
3833
  .prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_id = ? AND session_id = ? AND status IN ('active', 'interrupted')")
3564
3834
  .run(now, this.#requireAgentId(opts.agentName), opts.sessionId);
3565
- return Number(result.changes) > 0;
3835
+ const landed = Number(result.changes) > 0;
3836
+ if (landed) {
3837
+ // DOD-M12B-SESSION-SEED-1 (review F3): `seal_interrupted_pending` is NOT a state revival
3838
+ // exists for, and the first build's comment wrongly grouped it with `interrupted`.
3839
+ // `ingestReceivedContent` refuses it outright, and BOTH sweeps that could otherwise close a
3840
+ // session — `listRestartOrphanedSessions` and `listExpiredUnrevivableSessions` — filter
3841
+ // `status = 'interrupted'`, so a pending-seal session is unrevivable AND unswept. Keeping its
3842
+ // identity meant holding it until the process exited. Entry 42's own measurement is that 59%
3843
+ // of seals that start never finish, so that is the common path, not a corner.
3844
+ this.#destroySessionSeed(opts.agentName, opts.sessionId);
3845
+ }
3846
+ return landed;
3566
3847
  }
3567
3848
  /**
3568
3849
  * M7-SESSION-001 (H-1): read back the persisted bilateral commitment artifacts
@@ -4112,9 +4393,12 @@ export class SessionNodeManager {
4112
4393
  // like a protocol mystery for a night.
4113
4394
  //
4114
4395
  // `counterpartySessionPeerId` is the load-bearing field. It is recorded ONCE at session
4115
- // establishment and never refreshed, while a standing receiver is rebuilt with a fresh keypair
4116
- // on every signaling reconnect — so if the two ever cross, every send goes one-way forever and
4117
- // nothing says so. With this line that becomes a single grep instead of a night.
4396
+ // establishment and never refreshed. (CORRECTED 2026-08-18: this used to say a standing
4397
+ // receiver is rebuilt "on every signaling reconnect"it is not. `ensureStandingReceiverForAgent`
4398
+ // no-ops on a healthy receiver; the only rebuild triggers are a LOST RELAY RESERVATION and the
4399
+ // one-shot upgrade when relay endpoints first arrive.) If the two ever cross, every send goes
4400
+ // one-way forever and nothing says so. With this line that becomes a single grep instead of a
4401
+ // night.
4118
4402
  this.#logger.warn("session.content.direct.send.failed", {
4119
4403
  agentName,
4120
4404
  sessionId,
@@ -6882,6 +7166,22 @@ export class SessionNodeManager {
6882
7166
  const sr = this.#standingReceivers.get(agentName);
6883
7167
  if (sr) {
6884
7168
  this.#standingReceivers.delete(agentName);
7169
+ /**
7170
+ * DOD-M12B-SESSION-SEED-1 (review F8): drop it zeroed, like every other seed.
7171
+ *
7172
+ * (review F7, DECIDED AGAINST — deliberately NOT reusing this seed for the replacement.)
7173
+ * Reuse is attractive: this receiver's peer id may already be inside a `session_offer_accept`
7174
+ * the counterparty is acting on, and a rebuild in that window is the documented "we record
7175
+ * an identity that no longer exists… every send in this direction parks forever" defect. But
7176
+ * a preserved identity would have to be handed to the candidate loop in
7177
+ * `#startReceiverNode`, whose rejected candidates are stopped WITHOUT awaiting `start()` —
7178
+ * so two nodes could be briefly live on one advertised peer id, which is review F1, a HIGH,
7179
+ * and the reason each candidate now mints its own. Fixing F7 properly means bounding and
7180
+ * awaiting the loser's teardown first, and an unawaited stop is precisely what the current
7181
+ * code chose to avoid a stuck libp2p teardown blocking receiver creation. Filed as
7182
+ * follow-on work rather than trading a MEDIUM fix for a HIGH regression.
7183
+ */
7184
+ sr.seed.fill(0);
6885
7185
  try {
6886
7186
  sr.autoNat.stop();
6887
7187
  await sr.node.stop();
@@ -7166,11 +7466,25 @@ export class SessionNodeManager {
7166
7466
  */
7167
7467
  async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
7168
7468
  for (const circuitAddr of candidateCircuitAddrs) {
7469
+ // DOD-M12B-SESSION-SEED-1: A SEED PER CANDIDATE, NOT ONE FOR THE LOOP.
7470
+ //
7471
+ // A rejected candidate is stopped with an unawaited `void …then(() => candidate.stop())`
7472
+ // while its `start()` may still be in flight, so two candidate nodes can briefly be live at
7473
+ // once. Sharing one seed would give both the SAME peer id — and the loser would then be a
7474
+ // second live node under the identity we advertise in `session_offer_accept`, sharing this
7475
+ // gater (so it admits dials) with no content handler registered. Inbound arriving there goes
7476
+ // nowhere, and it is an open endpoint under our advertised id: the "connection a malicious
7477
+ // agent can farm for" the tenet names. Before seeds existed the loser had its own random key
7478
+ // and was harmless; introducing a shared seed is what would have made it dangerous.
7479
+ //
7480
+ // Nothing reads the seed before the winner is installed, so per-candidate costs nothing.
7481
+ const candidateSeed = randomBytes(32);
7169
7482
  const candidate = await this.#factory.createNode({
7170
7483
  sessionId,
7171
7484
  connectionGater: gater,
7172
7485
  nodeType: "standing_receiver",
7173
7486
  circuitRelayListenAddrs: [circuitAddr],
7487
+ transportPrivateKey: candidateSeed,
7174
7488
  });
7175
7489
  let timer;
7176
7490
  const timedOut = Symbol("reservation_timeout");
@@ -7196,7 +7510,7 @@ export class SessionNodeManager {
7196
7510
  // completes the handshake and simply grants nothing, leaving a node that looks
7197
7511
  // started and is reachable by nobody.
7198
7512
  if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
7199
- return candidate;
7513
+ return { node: candidate, seed: candidateSeed };
7200
7514
  }
7201
7515
  const rejectionReason = outcome === "started"
7202
7516
  ? "relay_granted_no_reservation"
@@ -7217,13 +7531,15 @@ export class SessionNodeManager {
7217
7531
  .then(() => candidate.stop())
7218
7532
  .catch(() => { });
7219
7533
  }
7534
+ const plainSeed = randomBytes(32);
7220
7535
  const plain = await this.#factory.createNode({
7221
7536
  sessionId,
7222
7537
  connectionGater: gater,
7223
7538
  nodeType: "standing_receiver",
7539
+ transportPrivateKey: plainSeed,
7224
7540
  });
7225
7541
  await plain.start();
7226
- return plain;
7542
+ return { node: plain, seed: plainSeed };
7227
7543
  }
7228
7544
  /** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
7229
7545
  async #tryCreateStandingReceiver(agentName, correlationId) {
@@ -7242,8 +7558,21 @@ export class SessionNodeManager {
7242
7558
  gater.setAllowedOutboundPeer(relayPeerId);
7243
7559
  }
7244
7560
  let node;
7561
+ /**
7562
+ * DOD-M12B-SESSION-SEED-1 — the transport identity of the receiver that actually survived.
7563
+ *
7564
+ * Minted per CANDIDATE inside `#startReceiverNode` and returned with the winner, not minted
7565
+ * here: a rejected candidate is stopped without awaiting its `start()`, so two candidates can
7566
+ * be briefly live, and one shared seed would put both on the same advertised peer id.
7567
+ *
7568
+ * FRESH EVERY TIME, which is the privacy property rather than an implementation detail. A
7569
+ * receiver serves at most one session (it is promoted into the session at handoff and replaced),
7570
+ * so no identifier is ever shared between two sessions and the 2026-04-11 rationale —
7571
+ * unlinkability of an agent's sessions to a passive observer — survives intact.
7572
+ */
7573
+ let seed;
7245
7574
  try {
7246
- node = await this.#startReceiverNode(agentName, sessionId, gater, reservations.addrs, correlationId);
7575
+ ({ node, seed } = await this.#startReceiverNode(agentName, sessionId, gater, reservations.addrs, correlationId));
7247
7576
  }
7248
7577
  catch (err) {
7249
7578
  // extractErrorMessage, NOT String(err): the transport throws structured
@@ -7314,6 +7643,7 @@ export class SessionNodeManager {
7314
7643
  node,
7315
7644
  gater,
7316
7645
  autoNat,
7646
+ seed,
7317
7647
  hasReservation: circuitAddrs > 0,
7318
7648
  ...(reservedRelayPeerId !== undefined ? { relayPeerId: reservedRelayPeerId } : {}),
7319
7649
  });
@@ -7355,6 +7685,303 @@ export class SessionNodeManager {
7355
7685
  * M8B F14: also called from the inbound accept path (ensure on demand). Marks the agent as
7356
7686
  * WANTING a receiver, which arms the teardown re-arm in destroySessionNode/retireSessionNode.
7357
7687
  */
7688
+ /**
7689
+ * DOD-M12B-SESSION-SEED-1 test seam: the seed the agent's current standing receiver holds.
7690
+ *
7691
+ * The property under test — "the receiver built behind a promoted one never reuses its seed" — is
7692
+ * about an identity that by design never leaves the process, so there is no observable surface for
7693
+ * it short of a live two-node dial. Reading it here is the narrowest way to pin it.
7694
+ */
7695
+ /** DOD-M12B-SESSION-SEED-1: record the identity this session must be able to return at. */
7696
+ #rememberSessionSeed(agentName, sessionId, seed, counterpartyPeerId, counterpartyPubkey) {
7697
+ const key = this.#k(agentName, sessionId);
7698
+ // Defensive: unreachable today because `insertSessionRow` PK-conflicts on a repeat, but an
7699
+ // overwrite that dropped a live seed un-zeroed would leave the one copy we are responsible for
7700
+ // in the heap with nothing tracking it.
7701
+ this.#sessionSeeds.get(key)?.seed.fill(0);
7702
+ this.#sessionSeeds.set(key, { seed, counterpartyPeerId, counterpartyPubkey });
7703
+ }
7704
+ /**
7705
+ * DOD-M12B-SESSION-SEED-1 — destroy a session's transport identity.
7706
+ *
7707
+ * Called from `#updateSessionStatus` on a terminal status, in the SAME step that writes it, so
7708
+ * there is no window in which a session is closed on paper and still revivable in memory.
7709
+ *
7710
+ * **WHAT THE ZERO-FILL DOES AND DOES NOT DO** — checked against the derivation, not assumed.
7711
+ * `createNode` hands the buffer to `generateKeyPairFromSeed`, and `@libp2p/crypto` COPIES it
7712
+ * (`uint8arrayConcat([seed, publicKeyRaw])`, then `Uint8Array.from`). Two consequences:
7713
+ * - zeroing after the node has started is SAFE — the running node holds its own copy;
7714
+ * - it does NOT erase the key from the heap. An identical usable copy is the first 32 bytes of
7715
+ * `privateKey.raw` on the node object until that node is dropped.
7716
+ * So this removes OUR long-lived copy — the one that would otherwise sit in a map for the life of
7717
+ * the process, decoupled from any node — and that is worth doing. It is not a heap scrub, and
7718
+ * the DoD already says the bound rather than secrecy is the control.
7719
+ */
7720
+ #destroySessionSeed(agentName, sessionId) {
7721
+ const key = this.#k(agentName, sessionId);
7722
+ const identity = this.#sessionSeeds.get(key);
7723
+ if (identity === undefined)
7724
+ return;
7725
+ identity.seed.fill(0);
7726
+ this.#sessionSeeds.delete(key);
7727
+ this.#logger.debug("session.seed.destroyed", { agentName, sessionId });
7728
+ }
7729
+ /**
7730
+ * DOD-M12B-SESSION-SEED-1 — bring an interrupted session back on the peer id it already has.
7731
+ *
7732
+ * THE DEFECT THIS CLOSES. `markInterruptedWithDetails` and `destroySessionNode` stop the node and
7733
+ * delete it from `#activeNodes`, and until now **nothing anywhere recreated one**. A laptop-close
7734
+ * session stayed stuck even though both processes were alive and both keypairs were still in
7735
+ * memory — the trace on 2026-08-17 found no missing transport capability, just a missing edge.
7736
+ *
7737
+ * TWO THINGS HAVE TO HAPPEN, and doing only one leaves the session exactly as stuck:
7738
+ * 1. the NODE comes back, at the same peer id, or the counterparty can never dial us again;
7739
+ * 2. the STATUS comes back to `active`, or every send still refuses with `session_not_active`.
7740
+ *
7741
+ * **DEMAND-DRIVEN ONLY.** Nothing calls this on a timer. That is the `REDIAL-1` discipline and it
7742
+ * is also Andre's tenet — a background rebuilder would hold a dialable endpoint open for a session
7743
+ * nobody is using, which is the "open connection a malicious agent can farm for" in as many words.
7744
+ *
7745
+ * **TERMINAL IS TERMINAL.** A sealed or abandoned session had its seed zeroed in the same step
7746
+ * that wrote its status, so there is nothing to come back on. This refuses by name rather than
7747
+ * minting a fresh identity — a revival that quietly mints would hand one session a second peer id
7748
+ * and break the invariant while appearing to work.
7749
+ *
7750
+ * Idempotent: a session that already has a live node returns ok without building a second one.
7751
+ */
7752
+ async reviveSessionNode(agentName, sessionId) {
7753
+ const key = this.#k(agentName, sessionId);
7754
+ const live = this.#activeNodes.get(key);
7755
+ if (live)
7756
+ return { ok: true, peerId: live.node.getPeerId() };
7757
+ const record = this.getSessionRecord(agentName, sessionId);
7758
+ if (!record)
7759
+ return { ok: false, reason: "session_not_found" };
7760
+ if (record.status === "sealed" || record.status === "abandoned" || record.status === "seal_interrupted_pending") {
7761
+ return {
7762
+ ok: false,
7763
+ reason: "session_terminal",
7764
+ guidance: `Session is '${record.status}'. A session that has ended cannot be revived; start a new one.`,
7765
+ };
7766
+ }
7767
+ const identity = this.#sessionSeeds.get(key);
7768
+ if (identity === undefined) {
7769
+ // The honest case: the daemon restarted, so the keypair is genuinely gone. That is
7770
+ // RESTART-SEAL-1's territory (resolve with a receipt), not a revival — and saying so is the
7771
+ // difference between an operator waiting for a reconnect that cannot happen and one closing
7772
+ // the session.
7773
+ return {
7774
+ ok: false,
7775
+ reason: "session_identity_lost",
7776
+ guidance: "This session's transport identity did not survive a daemon restart, so it cannot be " +
7777
+ "revived. It will be sealed automatically, or you can close it now to get its receipt.",
7778
+ };
7779
+ }
7780
+ const gater = new SessionConnectionGater({
7781
+ sessionId,
7782
+ allowedPeerId: identity.counterpartyPeerId,
7783
+ logger: this.#logger,
7784
+ });
7785
+ // The relay peers must be allowed OUTBOUND before the node starts, or the reservation the line
7786
+ // below depends on is refused by our own gater — the same ordering the receiver builder uses.
7787
+ const reservations = this.#reservationCircuitAddrs(agentName);
7788
+ for (const relayPeerId of reservations.relayPeerIds)
7789
+ gater.setAllowedOutboundPeer(relayPeerId);
7790
+ let node;
7791
+ try {
7792
+ node = await this.#factory.createNode({
7793
+ sessionId,
7794
+ connectionGater: gater,
7795
+ nodeType: "session",
7796
+ transportPrivateKey: identity.seed,
7797
+ // review HIGH-1: reachable, and holding a circuit reservation. A revived node that binds
7798
+ // loopback preserves an identity nobody can dial — the counterparty's next message parks
7799
+ // again and case B is only half delivered.
7800
+ inboundReachable: true,
7801
+ ...(reservations.addrs.length > 0 ? { circuitRelayListenAddrs: reservations.addrs } : {}),
7802
+ });
7803
+ await node.start();
7804
+ }
7805
+ catch (err) {
7806
+ this.#logger.error("session.revive.node.failed", {
7807
+ agentName,
7808
+ sessionId,
7809
+ error: err instanceof Error ? err.message : String(err),
7810
+ impact: "the session stays interrupted; the next send will attempt this again",
7811
+ });
7812
+ return { ok: false, reason: "session_node_creation_failed" };
7813
+ }
7814
+ const autoNat = new NodeAutoNatService({
7815
+ node,
7816
+ logger: this.#logger,
7817
+ nodeType: "session",
7818
+ probers: this.#autoNatProbers(),
7819
+ });
7820
+ autoNat.emitInitialResult();
7821
+ const correlationId = randomUUID();
7822
+ this.#activeNodes.set(key, {
7823
+ node,
7824
+ agentName,
7825
+ sessionId,
7826
+ counterpartyPubkey: identity.counterpartyPubkey,
7827
+ gater,
7828
+ correlationId,
7829
+ counterpartySessionPeerId: identity.counterpartyPeerId,
7830
+ autoNat,
7831
+ });
7832
+ await this.#registerContentHandler(agentName, sessionId, node, identity.counterpartyPubkey);
7833
+ /**
7834
+ * review HIGH-2 — REWIRE LIVENESS, or this session can never be interrupted again.
7835
+ *
7836
+ * Both creation paths call this; the first build of the revival did not. Without it the revived
7837
+ * session is pinned `active`: a later disconnect fires no transition, no `session_state_changed`
7838
+ * reaches the MCP client, and the receive surface renders unknown liveness as healthy-and-quiet.
7839
+ * So the SECOND laptop close would leave the operator staring at a session that reports fine and
7840
+ * is dead — this milestone's founding defect, one revival later, and with no status change left
7841
+ * to trigger the next revival either.
7842
+ */
7843
+ this.#wireSessionLiveness(agentName, sessionId, node, identity.counterpartyPubkey, correlationId, identity.counterpartyPeerId);
7844
+ // THE REVERSE EDGE. A transport event took this session out of `active` and nothing has ever
7845
+ // put one back. Written after the node is live and its handler registered, so the row never
7846
+ // claims `active` for a session that cannot yet receive.
7847
+ //
7848
+ // review MEDIUM-3: the result is CHECKED. `#updateSessionStatus` returns false when the write
7849
+ // matched no row or the DB errored — and reporting revival ok on a row that still says
7850
+ // `interrupted` leaves a live, talking session where REVIVAL-BOUND-1's sweep can seal or abandon
7851
+ // it. Failing here means tearing the node back down rather than running in that split state.
7852
+ if (!this.#updateSessionStatus(agentName, sessionId, "active")) {
7853
+ this.#activeNodes.delete(key);
7854
+ try {
7855
+ await node.stop();
7856
+ }
7857
+ catch { /* best-effort: the status write already failed and is logged with its cause */ }
7858
+ return {
7859
+ ok: false,
7860
+ reason: "session_status_write_failed",
7861
+ guidance: "The session node was rebuilt but its status could not be written, so it was torn back " +
7862
+ "down rather than left live under an interrupted row. The daemon logged the cause.",
7863
+ };
7864
+ }
7865
+ const peerId = node.getPeerId();
7866
+ this.#logger.info("session.revived", {
7867
+ agentName,
7868
+ sessionId,
7869
+ peerId,
7870
+ // The whole claim of this line, in the log: the id did not change, so the counterparty's
7871
+ // stored dial target is still correct and they do not need to be told anything.
7872
+ identityPreserved: true,
7873
+ });
7874
+ return { ok: true, peerId };
7875
+ }
7876
+ /**
7877
+ * DOD-M12B-SESSION-SEED-1 — the DEMAND edge: a send on an interrupted session revives it.
7878
+ *
7879
+ * This is the only production caller of `reviveSessionNode`, and it is deliberately the send path
7880
+ * rather than a timer. The `REDIAL-1` discipline and Andre's tenet say the same thing from two
7881
+ * directions: nothing may re-open on its own, because a background rebuilder would hold a dialable
7882
+ * endpoint open for a session nobody is using — the *"open connection a malicious agent can farm
7883
+ * for"*. The operator sending is the demand; there is no other trigger.
7884
+ *
7885
+ * A no-op for the normal case. An `active` session with a live node returns immediately without
7886
+ * touching it — this sits on the hot path of every send, and replacing a healthy node would be
7887
+ * churn that changes the peer id for no reason.
7888
+ */
7889
+ async reviveIfNeededForSend(agentName, sessionId) {
7890
+ const record = this.getSessionRecord(agentName, sessionId);
7891
+ if (!record)
7892
+ return { ok: false, reason: "session_not_found" };
7893
+ // The overwhelmingly common case: nothing to do, and no node was disturbed to find that out.
7894
+ if (record.status === "active" && this.#activeNodes.has(this.#k(agentName, sessionId)))
7895
+ return { ok: true };
7896
+ const revived = await this.reviveSessionNode(agentName, sessionId);
7897
+ if (!revived.ok) {
7898
+ this.#logger.info("session.revive.declined", {
7899
+ agentName,
7900
+ sessionId,
7901
+ previousStatus: record.status,
7902
+ trigger: "send",
7903
+ reason: revived.reason,
7904
+ });
7905
+ return revived;
7906
+ }
7907
+ this.#logger.info("session.revived.on_demand", {
7908
+ agentName,
7909
+ sessionId,
7910
+ previousStatus: record.status,
7911
+ trigger: "send",
7912
+ });
7913
+ return { ok: true };
7914
+ }
7915
+ /**
7916
+ * DOD-M12B-SESSION-SEED-1 (case B) — the INBOUND half of the demand edge.
7917
+ *
7918
+ * `reviveIfNeededForSend` covers the operator waking first. Case B's triggers are symmetric — a
7919
+ * wifi hop, a relay restart, a directory node cycling — so half the time the COUNTERPARTY wakes
7920
+ * first. They send; we have no node yet, because revival is demand-driven and we have demanded
7921
+ * nothing. Their content parks at the relay, which is the backstop working as designed.
7922
+ *
7923
+ * Then the operator comes back and READS, and until now that told them nothing: the receive
7924
+ * handler reads the transcript and never gates on status, so it happily reports what is already
7925
+ * stored while messages sit parked, waiting for a node that will not exist until the operator
7926
+ * happens to SEND. An operator who only reads was stuck forever with a surface that looked fine.
7927
+ *
7928
+ * **WHY A READ MAY TRIGGER THIS AND AN INBOUND DIAL MAY NOT.** Andre's tenet is about what a
7929
+ * REMOTE party can cause: *"an open connection that a malicious agent can farm for."* Reviving
7930
+ * because a peer dialled us would hand that lever straight to the peer — a stranger could keep our
7931
+ * endpoints open indefinitely by poking dead sessions. A read is the OPERATOR asking, on their own
7932
+ * machine, for their own session: the same class of demand as a send, and the class the tenet
7933
+ * allows. That distinction is the whole reason this is a separate entry point rather than a
7934
+ * revival triggered from the inbound handler.
7935
+ */
7936
+ async reviveIfNeededForRead(agentName, sessionId) {
7937
+ const record = this.getSessionRecord(agentName, sessionId);
7938
+ if (!record)
7939
+ return { ok: false, reason: "session_not_found" };
7940
+ if (record.status === "active" && this.#activeNodes.has(this.#k(agentName, sessionId)))
7941
+ return { ok: true };
7942
+ // Reading the transcript of an ended session is normal and must keep working — the CALLER does
7943
+ // not treat this refusal as an error, it just reads what is stored. What must not happen is the
7944
+ // read bringing the session back: the receipt is issued and the identity is gone.
7945
+ const revived = await this.reviveSessionNode(agentName, sessionId);
7946
+ if (!revived.ok) {
7947
+ // review MEDIUM-4: the absence of a success line was the only signal that a session could not
7948
+ // come back. `session_identity_lost` is the one an operator most needs, and it was generated
7949
+ // and destroyed one stack frame later with nothing written down.
7950
+ this.#logger.info("session.revive.declined", {
7951
+ agentName,
7952
+ sessionId,
7953
+ previousStatus: record.status,
7954
+ trigger: "read",
7955
+ reason: revived.reason,
7956
+ });
7957
+ return revived;
7958
+ }
7959
+ this.#logger.info("session.revived.on_demand", {
7960
+ agentName,
7961
+ sessionId,
7962
+ previousStatus: record.status,
7963
+ trigger: "read",
7964
+ });
7965
+ // Fetch what is waiting NOW. Review MEDIUM-5 corrected the claim this used to make: the drain
7966
+ // runs off the AGENT's standing receiver, not the session node, and the 5-minute backstop would
7967
+ // have delivered this content anyway. So this is an accelerator, not a rescue — worth having,
7968
+ // and worth describing accurately. (The send path deliberately does not fire one: the same
7969
+ // backstop covers it, at a cost of at most one interval.)
7970
+ this.#fireParkedDrain(agentName, "session_revived");
7971
+ return { ok: true };
7972
+ }
7973
+ /** DOD-M12B-SESSION-SEED-1 test seam: drop a seed WITHOUT zeroing or a status change — what a
7974
+ * process restart does to it. The refusal that follows is the one an operator most needs named. */
7975
+ forgetSessionSeedForTest(agentName, sessionId) {
7976
+ this.#sessionSeeds.delete(this.#k(agentName, sessionId));
7977
+ }
7978
+ /** DOD-M12B-SESSION-SEED-1 test seam: does this session still hold a revivable identity? */
7979
+ hasSessionSeedForTest(agentName, sessionId) {
7980
+ return this.#sessionSeeds.has(this.#k(agentName, sessionId));
7981
+ }
7982
+ getStandingReceiverSeedForTest(agentName) {
7983
+ return this.#standingReceivers.get(agentName)?.seed;
7984
+ }
7358
7985
  async ensureStandingReceiverForAgent(agentName) {
7359
7986
  this.#agentsWantingReceiver.add(agentName);
7360
7987
  this.#startReservationWatchdog();
@@ -7385,7 +8012,11 @@ export class SessionNodeManager {
7385
8012
  this.#standingReceiverRemoving.add(agentName);
7386
8013
  return;
7387
8014
  }
8015
+ const removed = this.#standingReceivers.get(agentName);
7388
8016
  this.#standingReceivers.delete(agentName);
8017
+ // DOD-M12B-SESSION-SEED-1 (review F8): the operator took this agent offline — its advertised
8018
+ // identity is not needed any more, and the tenet's rule is that nothing unneeded stays live.
8019
+ removed?.seed.fill(0);
7389
8020
  // Best-effort teardown, but NOT silent: a standing receiver that failed to stop keeps a libp2p
7390
8021
  // node live on the network. For a removal/retire (a revocation-class action) that must be visible,
7391
8022
  // so the caller and the operator can see the leak rather than trust a false "torn down". autoNat is
@@ -7522,6 +8153,26 @@ export class SessionNodeManager {
7522
8153
  interruptedBy) {
7523
8154
  if (!this.#db)
7524
8155
  return false;
8156
+ /**
8157
+ * DOD-M12B-SESSION-SEED-1 (review F2) — the identity dies on terminal INTENT, not on a
8158
+ * successful UPDATE.
8159
+ *
8160
+ * The first build destroyed the seed only after the write landed. `#requireAgentId` THROWS for
8161
+ * a retired agent, so every terminal write for a revoked agent's sessions fell into the catch
8162
+ * and kept its transport identity for the life of the process — an identity whose agent has
8163
+ * just been revoked in the directory, held with nothing reporting it, and REVIVAL-BOUND-1's
8164
+ * sweep excludes retired agents so nothing else closed it either. The same held for a
8165
+ * `session.status.write.missed` and for any DB error.
8166
+ *
8167
+ * Coupling a security teardown to a database write is backwards: the write can fail, and the
8168
+ * failure is exactly when we least want a live key lying around. So it runs FIRST and
8169
+ * unconditionally, and if the write then fails the session is one we can no longer revive —
8170
+ * which is the safe direction, and is reported loudly below rather than inferred from the
8171
+ * absence of a debug line.
8172
+ */
8173
+ if (status === "sealed" || status === "abandoned") {
8174
+ this.#destroySessionSeed(agentName, sessionId);
8175
+ }
7525
8176
  // THE TERMINAL GUARD LIVES HERE, not in one wrapper, because there are three writers of
7526
8177
  // "sealed": markSealed, destroySessionNode, and retireSession on the witnessed-submit path.
7527
8178
  // Guarding only the wrapper asserts the invariant in a test while two other paths still break
@@ -7557,10 +8208,22 @@ export class SessionNodeManager {
7557
8208
  const now = Date.now();
7558
8209
  try {
7559
8210
  const res = this.#db
7560
- .prepare(interruptedBy === undefined
7561
- ? "UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?"
7562
- : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
7563
- .run(status, now, this.#requireAgentId(agentName), sessionId);
8211
+ .prepare(
8212
+ // DOD-M12B-REVIVAL-BOUND-1: this is the FOURTH writer of `status = 'interrupted'`, and
8213
+ // until now the only one that wrote no `interrupted_at`. That is where Entry 41's two
8214
+ // timestamp-less rows came from, and a row with no timestamp has no revival bound that
8215
+ // can be evaluated. `COALESCE` matches the three sibling producers: the FIRST
8216
+ // interruption is the clock, so re-entering the status cannot push the deadline out.
8217
+ status === "interrupted"
8218
+ ? (interruptedBy === undefined
8219
+ ? "UPDATE sessions SET status = ?, updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?"
8220
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
8221
+ : (interruptedBy === undefined
8222
+ ? "UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?"
8223
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?"))
8224
+ .run(...(status === "interrupted"
8225
+ ? [status, now, new Date(now).toISOString(), this.#requireAgentId(agentName), sessionId]
8226
+ : [status, now, this.#requireAgentId(agentName), sessionId]));
7564
8227
  // "Did not throw" is NOT "landed". An UPDATE whose WHERE matches no row — a wrong agent_id, a
7565
8228
  // session_id with no row — succeeds silently and changes nothing. Reporting that as a written
7566
8229
  // status flip is what let a disposition hook delete a live session's content, so the row count
@@ -7571,7 +8234,11 @@ export class SessionNodeManager {
7571
8234
  sessionId,
7572
8235
  status,
7573
8236
  agentName,
7574
- impact: "no session row matched — the status was NOT changed and no disposition was run",
8237
+ impact: (status === "sealed" || status === "abandoned")
8238
+ ? "no session row matched — the status was NOT changed and no disposition was run, AND "
8239
+ + "this session's transport identity has already been destroyed, so it can no longer "
8240
+ + "be revived even though its row still says it is open"
8241
+ : "no session row matched — the status was NOT changed and no disposition was run",
7575
8242
  });
7576
8243
  return false;
7577
8244
  }