@cello-protocol/daemon 0.0.43 → 0.0.45

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.
@@ -69,6 +69,7 @@
69
69
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
70
70
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
71
71
  import { ensureIdentitySchema } from "./db-identity-store.js";
72
+ import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
72
73
  import { randomUUID, createHash } from "node:crypto";
73
74
  import * as lp from "it-length-prefixed";
74
75
  import { decode, Encoder } from "cbor-x";
@@ -332,7 +333,7 @@ export class SessionNodeManager {
332
333
  this.#db.exec(`
333
334
  CREATE TABLE IF NOT EXISTS sessions (
334
335
  session_id TEXT NOT NULL,
335
- agent_name TEXT NOT NULL,
336
+ agent_id TEXT NOT NULL,
336
337
  counterparty_pubkey TEXT NOT NULL,
337
338
  status TEXT NOT NULL,
338
339
  created_at INTEGER NOT NULL,
@@ -340,7 +341,9 @@ export class SessionNodeManager {
340
341
  -- DOD-LOOP-1: composite key so two of the operator's agents can hold both ends of the
341
342
  -- SAME session_id on ONE daemon (the loopback case). A bare session_id PK would reject
342
343
  -- the second end's row.
343
- PRIMARY KEY (agent_name, session_id)
344
+ -- DOD-AGENT-ID-JOINKEY-1: keyed on the STABLE agent_id, never the mutable, reuse-freed
345
+ -- agent_name. The display name lives on the agents table and is joined in for reads.
346
+ PRIMARY KEY (agent_id, session_id)
344
347
  )
345
348
  `);
346
349
  // M7-SESSION-001: idempotent schema extension — add message_count and interrupted_at
@@ -386,7 +389,7 @@ export class SessionNodeManager {
386
389
  // Merkle root so the achieved commitment is never discarded.
387
390
  this.#db.exec(`
388
391
  CREATE TABLE IF NOT EXISTS seal_interrupted_artifacts (
389
- agent_name TEXT NOT NULL,
392
+ agent_id TEXT NOT NULL,
390
393
  session_id TEXT NOT NULL,
391
394
  role TEXT NOT NULL,
392
395
  own_leaf TEXT NOT NULL,
@@ -395,7 +398,7 @@ export class SessionNodeManager {
395
398
  nonce TEXT NOT NULL,
396
399
  created_at INTEGER NOT NULL,
397
400
  -- DOD-LOOP-1: composite key (per-agent end of a loopback session).
398
- PRIMARY KEY (agent_name, session_id)
401
+ PRIMARY KEY (agent_id, session_id)
399
402
  )
400
403
  `);
401
404
  // DAEMON-004 (AC-007 / SI-001): the daemon-owned per-session Merkle tree,
@@ -405,14 +408,14 @@ export class SessionNodeManager {
405
408
  // by session_id ORDER BY leaf_index is the only read pattern.
406
409
  this.#db.exec(`
407
410
  CREATE TABLE IF NOT EXISTS session_tree_leaves (
408
- agent_name TEXT NOT NULL,
411
+ agent_id TEXT NOT NULL,
409
412
  session_id TEXT NOT NULL,
410
413
  leaf_index INTEGER NOT NULL,
411
414
  leaf_kind TEXT NOT NULL,
412
415
  leaf_hash_hex TEXT NOT NULL,
413
416
  created_at INTEGER NOT NULL,
414
417
  -- DOD-LOOP-1: composite key so each agent's end has its own append-ordered tree.
415
- PRIMARY KEY (agent_name, session_id, leaf_index)
418
+ PRIMARY KEY (agent_id, session_id, leaf_index)
416
419
  )
417
420
  `);
418
421
  // DOD-LOG-1 (PERSIST-LOG-001) / PERSIST-002 (AC-010): the durable, ENCRYPTED-at-rest readable
@@ -422,13 +425,13 @@ export class SessionNodeManager {
422
425
  // provided by whole-DB SQLCipher, not a per-column cipher (relay/directory never see it — INV-3).
423
426
  this.#db.exec(`
424
427
  CREATE TABLE IF NOT EXISTS transcript (
425
- agent_name TEXT NOT NULL,
428
+ agent_id TEXT NOT NULL,
426
429
  session_id TEXT NOT NULL,
427
430
  sequence INTEGER NOT NULL,
428
431
  direction TEXT NOT NULL, -- 'sent' | 'received'
429
432
  blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
430
433
  created_at INTEGER NOT NULL,
431
- PRIMARY KEY (agent_name, session_id, sequence, direction)
434
+ PRIMARY KEY (agent_id, session_id, sequence, direction)
432
435
  )
433
436
  `);
434
437
  // M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
@@ -438,10 +441,10 @@ export class SessionNodeManager {
438
441
  // across daemon restarts, not just within one process (INV-PUSHPULL). Additive table.
439
442
  this.#db.exec(`
440
443
  CREATE TABLE IF NOT EXISTS message_watermarks (
441
- agent_name TEXT NOT NULL,
444
+ agent_id TEXT NOT NULL,
442
445
  session_id TEXT NOT NULL,
443
446
  last_delivered_seq INTEGER NOT NULL,
444
- PRIMARY KEY (agent_name, session_id)
447
+ PRIMARY KEY (agent_id, session_id)
445
448
  )
446
449
  `);
447
450
  // M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
@@ -450,10 +453,10 @@ export class SessionNodeManager {
450
453
  // re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
451
454
  this.#db.exec(`
452
455
  CREATE TABLE IF NOT EXISTS contacts (
453
- agent_name TEXT NOT NULL,
456
+ agent_id TEXT NOT NULL,
454
457
  pubkey TEXT NOT NULL,
455
458
  added_at INTEGER NOT NULL,
456
- PRIMARY KEY (agent_name, pubkey)
459
+ PRIMARY KEY (agent_id, pubkey)
457
460
  )
458
461
  `);
459
462
  // MONIKER-3 AC1: the receiver's own pet name for a pubkey — the top tier of whoLabel.
@@ -463,6 +466,15 @@ export class SessionNodeManager {
463
466
  if (!contactCols.some((c) => c.name === "moniker")) {
464
467
  this.#db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
465
468
  }
469
+ // DOD-AGENT-ID-JOINKEY-1: finish REMOVE-001. Re-key the seven child tables from the mutable,
470
+ // reuse-freed `agent_name` to the stable `agent_id`, in ONE transaction. Runs AFTER every
471
+ // CREATE/ALTER above, so an existing table has its full historical column set before it is
472
+ // rebuilt, and BEFORE any read below touches it. A no-op once the tables carry `agent_id`.
473
+ //
474
+ // `retry_queue` (the seventh) is created later, by RetryQueue's constructor. On an existing
475
+ // database it already exists here and is re-keyed in the same transaction; on a fresh one it is
476
+ // absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
477
+ migrateSessionTablesToAgentId(this.#db, this.#logger);
466
478
  // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
467
479
  // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
468
480
  // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
@@ -479,8 +491,15 @@ export class SessionNodeManager {
479
491
  // Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
480
492
  // Any 'active' row in a freshly-started daemon is a remnant of a prior
481
493
  // killed process. Batch-update to 'interrupted' before IPC opens.
494
+ // DOD-AGENT-ID-JOINKEY-1: this sweep spans EVERY agent, so it cannot resolve one name up front.
495
+ // It scopes its UPDATE by the row's own agent_id and LEFT JOINs `agents` only to LOG a human
496
+ // name. LEFT, not INNER: an inner join would silently skip a session whose agent row is missing,
497
+ // leaving it 'active' forever — a stuck row hidden by the query that was meant to find it. An
498
+ // orphan is instead marked interrupted like any other AND reported loudly.
482
499
  const activeRows = this.#db
483
- .prepare("SELECT * FROM sessions WHERE status = 'active'")
500
+ .prepare(`SELECT s.session_id, s.agent_id, a.agent_name
501
+ FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
502
+ WHERE s.status = 'active'`)
484
503
  .all();
485
504
  if (activeRows.length > 0) {
486
505
  const now = Date.now();
@@ -488,8 +507,15 @@ export class SessionNodeManager {
488
507
  for (const row of activeRows) {
489
508
  try {
490
509
  this.#db
491
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_name = ? AND session_id = ?")
492
- .run(now, interruptedAt, row.agent_name, row.session_id);
510
+ .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?")
511
+ .run(now, interruptedAt, row.agent_id, row.session_id);
512
+ if (row.agent_name === null) {
513
+ this.#logger.error("session.agent.orphaned", {
514
+ sessionId: row.session_id,
515
+ agentId: row.agent_id,
516
+ impact: "session row references an agent_id with no agents row",
517
+ });
518
+ }
493
519
  this.#logger.warn("session.interrupted.detected", {
494
520
  sessionId: row.session_id,
495
521
  agentName: row.agent_name,
@@ -557,11 +583,12 @@ export class SessionNodeManager {
557
583
  if (!this.#db)
558
584
  return;
559
585
  try {
586
+ const agentId = this.#requireAgentId(agentName);
560
587
  const blob = Buffer.from(plaintext);
561
588
  this.#db
562
- .prepare(`INSERT OR IGNORE INTO transcript (agent_name, session_id, sequence, direction, blob, created_at)
589
+ .prepare(`INSERT OR IGNORE INTO transcript (agent_id, session_id, sequence, direction, blob, created_at)
563
590
  VALUES (?, ?, ?, ?, ?, ?)`)
564
- .run(agentName, sessionId, sequence, direction, blob, Date.now());
591
+ .run(agentId, sessionId, sequence, direction, blob, Date.now());
565
592
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
566
593
  }
567
594
  catch (err) {
@@ -590,8 +617,8 @@ export class SessionNodeManager {
590
617
  return { messages: [], undecryptable: 0 };
591
618
  const rows = this.#db
592
619
  .prepare(`SELECT sequence, direction, blob, created_at FROM transcript
593
- WHERE agent_name = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
594
- .all(agentName, sessionId);
620
+ WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
621
+ .all(this.#requireAgentId(agentName), sessionId);
595
622
  const messages = [];
596
623
  // PERSIST-002 (AC-010): the blob is plaintext (whole-DB SQLCipher at rest), so there is no
597
624
  // per-row decrypt step that can fail — `undecryptable` stays 0 and is kept only for callers that
@@ -614,8 +641,8 @@ export class SessionNodeManager {
614
641
  if (!this.#db)
615
642
  return -1;
616
643
  const row = this.#db
617
- .prepare("SELECT last_delivered_seq FROM message_watermarks WHERE agent_name = ? AND session_id = ?")
618
- .get(agentName, sessionId);
644
+ .prepare("SELECT last_delivered_seq FROM message_watermarks WHERE agent_id = ? AND session_id = ?")
645
+ .get(this.#requireAgentId(agentName), sessionId);
619
646
  return row ? row.last_delivered_seq : -1;
620
647
  }
621
648
  /** Advance the read watermark (delivery marks read). MONOTONIC — never lowers, so a replayed or
@@ -624,11 +651,11 @@ export class SessionNodeManager {
624
651
  if (!this.#db)
625
652
  return;
626
653
  this.#db
627
- .prepare(`INSERT INTO message_watermarks (agent_name, session_id, last_delivered_seq)
654
+ .prepare(`INSERT INTO message_watermarks (agent_id, session_id, last_delivered_seq)
628
655
  VALUES (?, ?, ?)
629
- ON CONFLICT(agent_name, session_id)
656
+ ON CONFLICT(agent_id, session_id)
630
657
  DO UPDATE SET last_delivered_seq = MAX(last_delivered_seq, excluded.last_delivered_seq)`)
631
- .run(agentName, sessionId, seq);
658
+ .run(this.#requireAgentId(agentName), sessionId, seq);
632
659
  this.#logger.info("message.watermark.advanced", { agentName, sessionId, sequence: seq });
633
660
  }
634
661
  /** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
@@ -643,21 +670,21 @@ export class SessionNodeManager {
643
670
  MAX(t.sequence) AS last_seq
644
671
  FROM transcript t
645
672
  LEFT JOIN message_watermarks w
646
- ON w.agent_name = t.agent_name AND w.session_id = t.session_id
647
- WHERE t.agent_name = ?
673
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
674
+ WHERE t.agent_id = ?
648
675
  AND t.direction = 'received'
649
676
  AND t.sequence > COALESCE(w.last_delivered_seq, -1)
650
677
  GROUP BY t.session_id
651
678
  HAVING unread_count > 0
652
679
  ORDER BY t.session_id ASC`)
653
- .all(agentName);
680
+ .all(this.#requireAgentId(agentName));
654
681
  return rows;
655
682
  }
656
683
  /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
657
684
  isContact(agentName, pubkey) {
658
685
  if (!this.#db)
659
686
  return false;
660
- const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_name = ? AND pubkey = ?").get(agentName, pubkey);
687
+ const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
661
688
  return row !== undefined;
662
689
  }
663
690
  /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
@@ -675,13 +702,14 @@ export class SessionNodeManager {
675
702
  if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
676
703
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
677
704
  }
705
+ const agentId = this.#requireAgentId(agentName);
678
706
  this.#db
679
- .prepare("INSERT OR IGNORE INTO contacts (agent_name, pubkey, added_at) VALUES (?, ?, ?)")
680
- .run(agentName, pubkey, Date.now());
707
+ .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at) VALUES (?, ?, ?)")
708
+ .run(agentId, pubkey, Date.now());
681
709
  if (moniker !== undefined && moniker !== null) {
682
710
  this.#db
683
- .prepare("UPDATE contacts SET moniker = ? WHERE agent_name = ? AND pubkey = ?")
684
- .run(moniker, agentName, pubkey);
711
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
712
+ .run(moniker, agentId, pubkey);
685
713
  }
686
714
  }
687
715
  /** MONIKER-3 AC3: rename (string) or clear (null) an EXISTING contact's pet name. Returns false
@@ -696,15 +724,15 @@ export class SessionNodeManager {
696
724
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
697
725
  }
698
726
  const res = this.#db
699
- .prepare("UPDATE contacts SET moniker = ? WHERE agent_name = ? AND pubkey = ?")
700
- .run(moniker, agentName, pubkey);
727
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
728
+ .run(moniker, this.#requireAgentId(agentName), pubkey);
701
729
  return res.changes > 0;
702
730
  }
703
731
  /** M8C-CONTACT-1: known stays known until explicitly removed. */
704
732
  removeContact(agentName, pubkey) {
705
733
  if (!this.#db)
706
734
  return false;
707
- const res = this.#db.prepare("DELETE FROM contacts WHERE agent_name = ? AND pubkey = ?").run(agentName, pubkey);
735
+ const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
708
736
  return res.changes > 0;
709
737
  }
710
738
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
@@ -717,8 +745,8 @@ export class SessionNodeManager {
717
745
  return null;
718
746
  }
719
747
  const row = this.#db
720
- .prepare("SELECT moniker FROM contacts WHERE agent_name = ? AND pubkey = ?")
721
- .get(agentName, pubkey);
748
+ .prepare("SELECT moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
749
+ .get(this.#requireAgentId(agentName), pubkey);
722
750
  return row?.moniker ?? null;
723
751
  }
724
752
  /** M8C-CONTACT-1: list an agent's known contacts, oldest-added first. MONIKER-3 AC3: includes
@@ -727,16 +755,16 @@ export class SessionNodeManager {
727
755
  if (!this.#db)
728
756
  return [];
729
757
  return this.#db
730
- .prepare("SELECT pubkey, added_at, moniker FROM contacts WHERE agent_name = ? ORDER BY added_at ASC")
731
- .all(agentName);
758
+ .prepare("SELECT pubkey, added_at, moniker FROM contacts WHERE agent_id = ? ORDER BY added_at ASC")
759
+ .all(this.#requireAgentId(agentName));
732
760
  }
733
761
  /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
734
762
  #getReceivedBytesTotal(agentName, sessionId) {
735
763
  if (!this.#db)
736
764
  return 0;
737
765
  const row = this.#db
738
- .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_name = ? AND session_id = ? AND direction = 'received'")
739
- .get(agentName, sessionId);
766
+ .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
767
+ .get(this.#requireAgentId(agentName), sessionId);
740
768
  return row.total;
741
769
  }
742
770
  /** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
@@ -763,8 +791,8 @@ export class SessionNodeManager {
763
791
  if (!this.#db)
764
792
  return 0;
765
793
  const row = this.#db
766
- .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_name = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
767
- .get(agentName, counterpartyPubkey);
794
+ .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
795
+ .get(this.#requireAgentId(agentName), counterpartyPubkey);
768
796
  return row.n;
769
797
  }
770
798
  /** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with counterparties that are
@@ -775,9 +803,9 @@ export class SessionNodeManager {
775
803
  return 0;
776
804
  const row = this.#db
777
805
  .prepare(`SELECT COUNT(*) AS n FROM sessions s
778
- WHERE s.agent_name = ? AND s.status IN ('active', 'interrupted')
779
- AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.agent_name = s.agent_name AND c.pubkey = s.counterparty_pubkey)`)
780
- .get(agentName);
806
+ WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
807
+ AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey)`)
808
+ .get(this.#requireAgentId(agentName));
781
809
  return row.n;
782
810
  }
783
811
  /** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the acceptance bounds?
@@ -906,6 +934,66 @@ export class SessionNodeManager {
906
934
  #k(agentName, sessionId) {
907
935
  return `${agentName}\x1f${sessionId}`;
908
936
  }
937
+ /**
938
+ * DOD-AGENT-ID-JOINKEY-1: resolve an agent's NAME to its STABLE agent_id. This is the ONE place a
939
+ * name becomes a key, and it is the boundary between the two worlds:
940
+ *
941
+ * - ABOVE it, addressing by name is correct. The operator says `cello_use_agent { name }`, and
942
+ * the in-memory maps (#k, standing receivers, keyProviders) key by name safely, because
943
+ * name-addressing only ever resolves ACTIVE agents and the `agents_active_name` partial unique
944
+ * index makes active names unique.
945
+ * - BELOW it, only `agent_id` may touch SQL. `agent_name` is a mutable display attribute that a
946
+ * retire frees for reuse; a table joined on it hands a new keypair the dead identity's rows.
947
+ *
948
+ * It resolves ONLY non-retired agents — a retired identity is gone from the runtime (`list` omits
949
+ * it, `start` returns agent_not_found), so no live surface may act as one.
950
+ *
951
+ * It THROWS on an unresolvable name rather than returning null. A null would flow into a
952
+ * `WHERE agent_id IS NULL` that quietly matches nothing: reads would return empty and writes would
953
+ * vanish, and the daemon would look healthy while losing an agent's data. Every caller has already
954
+ * resolved the agent before it gets here, so an unresolvable name is a bug in the caller, not a
955
+ * condition to absorb.
956
+ */
957
+ #requireAgentId(agentName) {
958
+ if (!this.#db)
959
+ throw new Error("agent_id_unresolved: database is not open");
960
+ const row = this.#db
961
+ .prepare("SELECT agent_id FROM agents WHERE agent_name = ? AND state != 'retired'")
962
+ .get(agentName);
963
+ if (!row) {
964
+ this.#logger.error("session.agent_id.unresolved", { agentName });
965
+ throw new Error(`agent_id_unresolved: no active agent named '${agentName}'. The session tables are keyed by the ` +
966
+ `stable agent_id (DOD-AGENT-ID-JOINKEY-1); scoping a query by an unresolvable name would ` +
967
+ `silently match nothing.`);
968
+ }
969
+ return row.agent_id;
970
+ }
971
+ /**
972
+ * DOD-AGENT-ID-JOINKEY-1: the public form of the name→stable-id resolver, for components that own
973
+ * agent-scoped tables of their own (RetryQueue) and must be handed the STABLE key, never a name.
974
+ * The daemon resolves ONCE, at its own boundary, exactly as this class does internally. Throws on
975
+ * an unresolvable name — see #requireAgentId for why null is not an option.
976
+ */
977
+ resolveAgentId(agentName) {
978
+ return this.#requireAgentId(agentName);
979
+ }
980
+ /**
981
+ * Reverse lookup: the display name of a stable agent_id, or null if no such agent.
982
+ *
983
+ * Deliberately INCLUDES retired agents. Its caller (the startup awaiting-content re-park) holds an
984
+ * agent_id read off a durable row and needs a name to find that agent's standing receiver. A
985
+ * retired agent resolves to its name and then has no standing receiver, so the park fails cleanly
986
+ * and loudly — which is correct. Filtering retired agents out here would instead make the row
987
+ * unattributable and the failure mute.
988
+ */
989
+ agentNameForId(agentId) {
990
+ if (!this.#db)
991
+ return null;
992
+ const row = this.#db
993
+ .prepare("SELECT agent_name FROM agents WHERE agent_id = ?")
994
+ .get(agentId);
995
+ return row?.agent_name ?? null;
996
+ }
909
997
  /**
910
998
  * Create a new outbound session node.
911
999
  * Called during cello_initiate_session.
@@ -1152,8 +1240,8 @@ export class SessionNodeManager {
1152
1240
  // before the in-memory entry exists) can deposit un-acked content to the same relay.
1153
1241
  try {
1154
1242
  this.#db
1155
- ?.prepare("UPDATE sessions SET relay_peer_id = ?, relay_addrs = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
1156
- .run(relay.relayPeerId, JSON.stringify(relay.relayAddrs), Date.now(), agentName, sessionId);
1243
+ ?.prepare("UPDATE sessions SET relay_peer_id = ?, relay_addrs = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
1244
+ .run(relay.relayPeerId, JSON.stringify(relay.relayAddrs), Date.now(), this.#requireAgentId(agentName), sessionId);
1157
1245
  }
1158
1246
  catch (err) {
1159
1247
  this.#logger.warn("session.relay.endpoint.persist.failed", {
@@ -1595,8 +1683,22 @@ export class SessionNodeManager {
1595
1683
  getSessionsByStatus(status) {
1596
1684
  if (!this.#db)
1597
1685
  return [];
1686
+ // Spans EVERY agent (cello_status is daemon-wide), so no single name can be resolved up front —
1687
+ // the display name is joined in from `agents`, its one source of truth. `agent_name` is no
1688
+ // longer a `sessions` column, so without this join buildActiveSessions/buildInterruptedSessions
1689
+ // read `row.agent_name` as undefined.
1690
+ //
1691
+ // DOD-AGENT-ID-JOINKEY-1 (reviewer Finding 1): INNER JOIN with `state != 'retired'`, NOT a bare
1692
+ // LEFT JOIN. This is the LIVE-status + reaper surface, and a retired agent is gone from the
1693
+ // runtime — its leftover session rows (kept for accountability, never re-statused) are not
1694
+ // resumable and must not appear here. If they did, the half-open reaper would resolve their
1695
+ // RETIRED name via #requireAgentId, which throws, taking down cello_status for the whole daemon.
1696
+ // Excluding them also guarantees a non-null agent_name on every returned row. The full historical
1697
+ // archive (getAllSessions) keeps its LEFT JOIN and still shows retired/orphaned rows.
1598
1698
  return this.#db
1599
- .prepare("SELECT * FROM sessions WHERE status = ?")
1699
+ .prepare(`SELECT s.*, a.agent_name AS agent_name
1700
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
1701
+ WHERE s.status = ? AND a.state != 'retired'`)
1600
1702
  .all(status);
1601
1703
  }
1602
1704
  /**
@@ -1610,9 +1712,13 @@ export class SessionNodeManager {
1610
1712
  getSessionsForAgent(agentName) {
1611
1713
  if (!this.#db)
1612
1714
  return [];
1613
- return this.#db
1614
- .prepare("SELECT * FROM sessions WHERE agent_name = ? ORDER BY updated_at DESC")
1615
- .all(agentName);
1715
+ // Scoped by the STABLE id. `agent_name` is not a column of `sessions` any more, so it is stamped
1716
+ // back on for display and it is exactly the name we just resolved the id FROM, so no join is
1717
+ // needed and no stale copy can exist.
1718
+ const rows = this.#db
1719
+ .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
1720
+ .all(this.#requireAgentId(agentName));
1721
+ return rows.map((r) => ({ ...r, agent_name: agentName }));
1616
1722
  }
1617
1723
  /**
1618
1724
  * Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
@@ -1622,8 +1728,13 @@ export class SessionNodeManager {
1622
1728
  getAllSessions() {
1623
1729
  if (!this.#db)
1624
1730
  return [];
1731
+ // Spans EVERY agent, so no single name can be resolved up front: the display name is joined in
1732
+ // from `agents`, its one source of truth. LEFT JOIN, not INNER — a session whose agent row is
1733
+ // missing must still be listed (an invisible session is worse than an unnamed one).
1625
1734
  return this.#db
1626
- .prepare("SELECT * FROM sessions ORDER BY updated_at DESC")
1735
+ .prepare(`SELECT s.*, a.agent_name AS agent_name
1736
+ FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
1737
+ ORDER BY s.updated_at DESC`)
1627
1738
  .all();
1628
1739
  }
1629
1740
  /**
@@ -1639,8 +1750,8 @@ export class SessionNodeManager {
1639
1750
  if (!this.#db)
1640
1751
  return;
1641
1752
  this.#db
1642
- .prepare("UPDATE sessions SET seal_legibility = ?, sealed_root_hex = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
1643
- .run(legibilityJson, sealedRootHex, Date.now(), agentName, sessionId);
1753
+ .prepare("UPDATE sessions SET seal_legibility = ?, sealed_root_hex = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
1754
+ .run(legibilityJson, sealedRootHex, Date.now(), this.#requireAgentId(agentName), sessionId);
1644
1755
  }
1645
1756
  /**
1646
1757
  * M8B FINDING-6 (cascade-2): persist a seal certificate for a session that may have NO local
@@ -1658,9 +1769,9 @@ export class SessionNodeManager {
1658
1769
  const now = Date.now();
1659
1770
  this.#db
1660
1771
  .prepare(`INSERT OR IGNORE INTO sessions
1661
- (session_id, agent_name, counterparty_pubkey, status, created_at, updated_at)
1772
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
1662
1773
  VALUES (?, ?, ?, ?, ?, ?)`)
1663
- .run(sessionId, agentName, counterpartyPubkeyHex, "sealed", now, now);
1774
+ .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkeyHex, "sealed", now, now);
1664
1775
  this.recordSealCertificate(agentName, sessionId, sealedRootHex, legibilityJson);
1665
1776
  }
1666
1777
  /**
@@ -1673,8 +1784,8 @@ export class SessionNodeManager {
1673
1784
  if (!this.#db)
1674
1785
  return;
1675
1786
  this.#db
1676
- .prepare("UPDATE sessions SET counterparty_primary_pubkey = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
1677
- .run(primaryPubkeyHex, Date.now(), agentName, sessionId);
1787
+ .prepare("UPDATE sessions SET counterparty_primary_pubkey = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
1788
+ .run(primaryPubkeyHex, Date.now(), this.#requireAgentId(agentName), sessionId);
1678
1789
  }
1679
1790
  /**
1680
1791
  * M7-SESSION-004 (AC-005/AC-006): read the persisted seal certificate for a session.
@@ -1688,8 +1799,8 @@ export class SessionNodeManager {
1688
1799
  if (!this.#db)
1689
1800
  return null;
1690
1801
  const row = this.#db
1691
- .prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE agent_name = ? AND session_id = ?")
1692
- .get(agentName, sessionId);
1802
+ .prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE agent_id = ? AND session_id = ?")
1803
+ .get(this.#requireAgentId(agentName), sessionId);
1693
1804
  if (!row || !row.seal_legibility || !row.sealed_root_hex)
1694
1805
  return null;
1695
1806
  let legibility;
@@ -1741,8 +1852,8 @@ export class SessionNodeManager {
1741
1852
  // the pre-check above raced (it cannot — DatabaseSync is synchronous), the
1742
1853
  // UPDATE only mutates a row that is still active.
1743
1854
  this.#db
1744
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_name = ? AND session_id = ? AND status = 'active'")
1745
- .run(now, authoritativeCount, interruptedAt, agentName, sessionId);
1855
+ .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_id = ? AND session_id = ? AND status = 'active'")
1856
+ .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
1746
1857
  }
1747
1858
  catch (err) {
1748
1859
  this.#logger.error("session.interrupt.db.write.failed", {
@@ -1826,9 +1937,9 @@ export class SessionNodeManager {
1826
1937
  try {
1827
1938
  this.#db
1828
1939
  .prepare(`INSERT OR REPLACE INTO seal_interrupted_artifacts
1829
- (agent_name, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
1940
+ (agent_id, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
1830
1941
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
1831
- .run(opts.agentName, opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
1942
+ .run(this.#requireAgentId(opts.agentName), opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
1832
1943
  }
1833
1944
  catch (err) {
1834
1945
  this.#logger.error("session.interrupted.db.write.failed", {
@@ -1842,8 +1953,8 @@ export class SessionNodeManager {
1842
1953
  // active-session seal). The guard still refuses to overwrite a terminal
1843
1954
  // 'sealed' row or an already-pending one.
1844
1955
  const result = this.#db
1845
- .prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_name = ? AND session_id = ? AND status IN ('active', 'interrupted')")
1846
- .run(now, opts.agentName, opts.sessionId);
1956
+ .prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_id = ? AND session_id = ? AND status IN ('active', 'interrupted')")
1957
+ .run(now, this.#requireAgentId(opts.agentName), opts.sessionId);
1847
1958
  return Number(result.changes) > 0;
1848
1959
  }
1849
1960
  /**
@@ -1854,8 +1965,8 @@ export class SessionNodeManager {
1854
1965
  if (!this.#db)
1855
1966
  return null;
1856
1967
  const row = this.#db
1857
- .prepare("SELECT * FROM seal_interrupted_artifacts WHERE agent_name = ? AND session_id = ?")
1858
- .get(agentName, sessionId);
1968
+ .prepare("SELECT * FROM seal_interrupted_artifacts WHERE agent_id = ? AND session_id = ?")
1969
+ .get(this.#requireAgentId(agentName), sessionId);
1859
1970
  if (!row)
1860
1971
  return null;
1861
1972
  return {
@@ -1874,9 +1985,11 @@ export class SessionNodeManager {
1874
1985
  if (!this.#db)
1875
1986
  return null;
1876
1987
  const row = this.#db
1877
- .prepare("SELECT * FROM sessions WHERE agent_name = ? AND session_id = ?")
1878
- .get(agentName, sessionId);
1879
- return row ?? null;
1988
+ .prepare("SELECT * FROM sessions WHERE agent_id = ? AND session_id = ?")
1989
+ .get(this.#requireAgentId(agentName), sessionId);
1990
+ // `agent_name` is display-only and no longer stored on the row; stamp back the name whose
1991
+ // agent_id scoped this lookup (~50 daemon call sites read `record.agent_name`).
1992
+ return row ? { ...row, agent_name: agentName } : null;
1880
1993
  }
1881
1994
  /**
1882
1995
  * MSG-2 startup-flush: the persisted relay endpoint for a session, or null if none was
@@ -1887,8 +2000,8 @@ export class SessionNodeManager {
1887
2000
  if (!this.#db)
1888
2001
  return null;
1889
2002
  const row = this.#db
1890
- .prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE agent_name = ? AND session_id = ?")
1891
- .get(agentName, sessionId);
2003
+ .prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND session_id = ?")
2004
+ .get(this.#requireAgentId(agentName), sessionId);
1892
2005
  if (!row?.relay_peer_id || !row?.relay_addrs)
1893
2006
  return null;
1894
2007
  try {
@@ -1911,8 +2024,8 @@ export class SessionNodeManager {
1911
2024
  if (!this.#db)
1912
2025
  return [];
1913
2026
  const rows = this.#db
1914
- .prepare("SELECT DISTINCT relay_peer_id, relay_addrs FROM sessions WHERE agent_name = ? AND relay_peer_id IS NOT NULL")
1915
- .all(agentName);
2027
+ .prepare("SELECT DISTINCT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND relay_peer_id IS NOT NULL")
2028
+ .all(this.#requireAgentId(agentName));
1916
2029
  const byPeer = new Map();
1917
2030
  for (const row of rows) {
1918
2031
  if (!row.relay_peer_id || !row.relay_addrs)
@@ -1962,9 +2075,9 @@ export class SessionNodeManager {
1962
2075
  try {
1963
2076
  this.#db
1964
2077
  .prepare(`INSERT INTO session_tree_leaves
1965
- (agent_name, session_id, leaf_index, leaf_kind, leaf_hash_hex, created_at)
2078
+ (agent_id, session_id, leaf_index, leaf_kind, leaf_hash_hex, created_at)
1966
2079
  VALUES (?, ?, ?, ?, ?, ?)`)
1967
- .run(agentName, sessionId, leafIndex, kind, leafHashHex, Date.now());
2080
+ .run(this.#requireAgentId(agentName), sessionId, leafIndex, kind, leafHashHex, Date.now());
1968
2081
  // DAEMON-004 (finding #2): keep sessions.message_count synced to the tree
1969
2082
  // size. message_count is the bilateral leafCount the seal flow signs over
1970
2083
  // (handleSealInterruptedFlow / the responder). If it diverged from the
@@ -1972,8 +2085,8 @@ export class SessionNodeManager {
1972
2085
  // truncated transcript and the bilateral leafCount check would mismatch.
1973
2086
  // The tree (leafIndex + 1 leaves) is authoritative; the column tracks it.
1974
2087
  this.#db
1975
- .prepare("UPDATE sessions SET message_count = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
1976
- .run(leafIndex + 1, Date.now(), agentName, sessionId);
2088
+ .prepare("UPDATE sessions SET message_count = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
2089
+ .run(leafIndex + 1, Date.now(), this.#requireAgentId(agentName), sessionId);
1977
2090
  }
1978
2091
  catch (err) {
1979
2092
  // A persist failure must be visible, not swallowed: the in-memory tree
@@ -2803,8 +2916,8 @@ export class SessionNodeManager {
2803
2916
  if (!this.#db)
2804
2917
  return null;
2805
2918
  const row = this.#db
2806
- .prepare("SELECT sealed_root_hex FROM sessions WHERE agent_name = ? AND session_id = ?")
2807
- .get(agentName, sessionId);
2919
+ .prepare("SELECT sealed_root_hex FROM sessions WHERE agent_id = ? AND session_id = ?")
2920
+ .get(this.#requireAgentId(agentName), sessionId);
2808
2921
  return row?.sealed_root_hex ?? null;
2809
2922
  }
2810
2923
  // ─── CELLO-M7-MSG-001: delivery ACK / TTF tracking (send side) ──────────────
@@ -2973,8 +3086,8 @@ export class SessionNodeManager {
2973
3086
  if (!this.#db)
2974
3087
  return SessionTree.empty();
2975
3088
  const rows = this.#db
2976
- .prepare("SELECT leaf_kind, leaf_hash_hex FROM session_tree_leaves WHERE agent_name = ? AND session_id = ? ORDER BY leaf_index ASC")
2977
- .all(agentName, sessionId);
3089
+ .prepare("SELECT leaf_kind, leaf_hash_hex FROM session_tree_leaves WHERE agent_id = ? AND session_id = ? ORDER BY leaf_index ASC")
3090
+ .all(this.#requireAgentId(agentName), sessionId);
2978
3091
  return SessionTree.fromLeaves(rows.map((r) => ({ kind: r.leaf_kind === "ctrl" ? "ctrl" : "msg", hashHex: r.leaf_hash_hex })));
2979
3092
  }
2980
3093
  /**
@@ -3424,9 +3537,9 @@ export class SessionNodeManager {
3424
3537
  try {
3425
3538
  this.#db
3426
3539
  .prepare(`INSERT INTO sessions
3427
- (session_id, agent_name, counterparty_pubkey, status, created_at, updated_at)
3540
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
3428
3541
  VALUES (?, ?, ?, ?, ?, ?)`)
3429
- .run(sessionId, agentName, counterpartyPubkey, status, now, now);
3542
+ .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now);
3430
3543
  return true;
3431
3544
  }
3432
3545
  catch (err) {
@@ -3448,8 +3561,8 @@ export class SessionNodeManager {
3448
3561
  if (!this.#db)
3449
3562
  return 0;
3450
3563
  const row = this.#db
3451
- .prepare("SELECT COUNT(*) AS n FROM transcript WHERE agent_name = ? AND session_id = ? AND direction = 'received'")
3452
- .get(agentName, sessionId);
3564
+ .prepare("SELECT COUNT(*) AS n FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
3565
+ .get(this.#requireAgentId(agentName), sessionId);
3453
3566
  return row.n;
3454
3567
  }
3455
3568
  /** CC-5/F21: unilaterally mark a session locally-terminal ("abandoned") — retire its live node and
@@ -3471,8 +3584,8 @@ export class SessionNodeManager {
3471
3584
  const now = Date.now();
3472
3585
  try {
3473
3586
  this.#db
3474
- .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
3475
- .run(status, now, agentName, sessionId);
3587
+ .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
3588
+ .run(status, now, this.#requireAgentId(agentName), sessionId);
3476
3589
  return true;
3477
3590
  }
3478
3591
  catch (err) {