@cello-protocol/daemon 0.0.44 → 0.0.46

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,9 @@
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";
73
+ import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
74
+ import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
72
75
  import { randomUUID, createHash } from "node:crypto";
73
76
  import * as lp from "it-length-prefixed";
74
77
  import { decode, Encoder } from "cbor-x";
@@ -83,15 +86,18 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
83
86
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
84
87
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
85
88
  const CBOR_ENC = new Encoder({ tagUint8Array: false });
86
- // M8C-ABUSE-1: persistence bounds the non-M9 remainder (per-message cap + outbound rate are
87
- // M9's, not rebuilt here). "Whitelisted senders bounded only by disk" (DoD) these bounds apply
88
- // ONLY to sessions/senders that are NOT a known contact (CONTACT-1's whitelist).
89
- /** Anti-drip-feed: cumulative RECEIVED bytes per session, from a non-contact sender. */
90
- export const ABUSE_MAX_SESSION_RECEIVED_BYTES = 25 * 1024 * 1024; // 25 MB
91
- /** Anti-drip-feed via many sessions: active sessions a single unknown counterparty may hold open
92
- * with one agent at once. */
93
- export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = 3;
94
- /** Anti-swarm: total active sessions from ALL unknown (non-contact) counterparties combined, per agent. */
89
+ // M8C-ABUSE-1 + DOD-TIER-2: persistence bounds. Formerly a binary "known contacts exempt entirely,
90
+ // everyone else 3 sessions / 25 MB"; now TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-
91
+ // migration). The two consts below are kept for back-compat but DERIVE from the grid's UNKNOWN row —
92
+ // the grid is the single source (DOD-TIER-2 AC4), so these can never drift from it.
93
+ /** Anti-drip-feed: cumulative RECEIVED bytes per session at the UNKNOWN tier (= the grid's UNKNOWN
94
+ * byte cap). Higher tiers get more (DEFAULT_TIER_BOUNDS); no tier is unbounded (INV-TIER-BOUND). */
95
+ export const ABUSE_MAX_SESSION_RECEIVED_BYTES = DEFAULT_TIER_BOUNDS[TIER.UNKNOWN].maxBytesPerSession; // 25 MB
96
+ /** Anti-drip-feed via many sessions: active sessions an UNKNOWN counterparty may hold open at once
97
+ * (= the grid's UNKNOWN per-sender cap). */
98
+ export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = DEFAULT_TIER_BOUNDS[TIER.UNKNOWN].maxSessionsPerSender; // 3
99
+ /** Anti-swarm: total active sessions from ALL UNKNOWN-tier counterparties combined, per agent. A
100
+ * scalar across the whole unknown pool — not per-tier — so it stays a standalone const. */
95
101
  export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
96
102
  // ─── SessionNodeManager ───────────────────────────────────────────────────────
97
103
  export class SessionNodeManager {
@@ -332,7 +338,7 @@ export class SessionNodeManager {
332
338
  this.#db.exec(`
333
339
  CREATE TABLE IF NOT EXISTS sessions (
334
340
  session_id TEXT NOT NULL,
335
- agent_name TEXT NOT NULL,
341
+ agent_id TEXT NOT NULL,
336
342
  counterparty_pubkey TEXT NOT NULL,
337
343
  status TEXT NOT NULL,
338
344
  created_at INTEGER NOT NULL,
@@ -340,7 +346,9 @@ export class SessionNodeManager {
340
346
  -- DOD-LOOP-1: composite key so two of the operator's agents can hold both ends of the
341
347
  -- SAME session_id on ONE daemon (the loopback case). A bare session_id PK would reject
342
348
  -- the second end's row.
343
- PRIMARY KEY (agent_name, session_id)
349
+ -- DOD-AGENT-ID-JOINKEY-1: keyed on the STABLE agent_id, never the mutable, reuse-freed
350
+ -- agent_name. The display name lives on the agents table and is joined in for reads.
351
+ PRIMARY KEY (agent_id, session_id)
344
352
  )
345
353
  `);
346
354
  // M7-SESSION-001: idempotent schema extension — add message_count and interrupted_at
@@ -386,7 +394,7 @@ export class SessionNodeManager {
386
394
  // Merkle root so the achieved commitment is never discarded.
387
395
  this.#db.exec(`
388
396
  CREATE TABLE IF NOT EXISTS seal_interrupted_artifacts (
389
- agent_name TEXT NOT NULL,
397
+ agent_id TEXT NOT NULL,
390
398
  session_id TEXT NOT NULL,
391
399
  role TEXT NOT NULL,
392
400
  own_leaf TEXT NOT NULL,
@@ -395,7 +403,7 @@ export class SessionNodeManager {
395
403
  nonce TEXT NOT NULL,
396
404
  created_at INTEGER NOT NULL,
397
405
  -- DOD-LOOP-1: composite key (per-agent end of a loopback session).
398
- PRIMARY KEY (agent_name, session_id)
406
+ PRIMARY KEY (agent_id, session_id)
399
407
  )
400
408
  `);
401
409
  // DAEMON-004 (AC-007 / SI-001): the daemon-owned per-session Merkle tree,
@@ -405,14 +413,14 @@ export class SessionNodeManager {
405
413
  // by session_id ORDER BY leaf_index is the only read pattern.
406
414
  this.#db.exec(`
407
415
  CREATE TABLE IF NOT EXISTS session_tree_leaves (
408
- agent_name TEXT NOT NULL,
416
+ agent_id TEXT NOT NULL,
409
417
  session_id TEXT NOT NULL,
410
418
  leaf_index INTEGER NOT NULL,
411
419
  leaf_kind TEXT NOT NULL,
412
420
  leaf_hash_hex TEXT NOT NULL,
413
421
  created_at INTEGER NOT NULL,
414
422
  -- 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)
423
+ PRIMARY KEY (agent_id, session_id, leaf_index)
416
424
  )
417
425
  `);
418
426
  // DOD-LOG-1 (PERSIST-LOG-001) / PERSIST-002 (AC-010): the durable, ENCRYPTED-at-rest readable
@@ -422,13 +430,13 @@ export class SessionNodeManager {
422
430
  // provided by whole-DB SQLCipher, not a per-column cipher (relay/directory never see it — INV-3).
423
431
  this.#db.exec(`
424
432
  CREATE TABLE IF NOT EXISTS transcript (
425
- agent_name TEXT NOT NULL,
433
+ agent_id TEXT NOT NULL,
426
434
  session_id TEXT NOT NULL,
427
435
  sequence INTEGER NOT NULL,
428
436
  direction TEXT NOT NULL, -- 'sent' | 'received'
429
437
  blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
430
438
  created_at INTEGER NOT NULL,
431
- PRIMARY KEY (agent_name, session_id, sequence, direction)
439
+ PRIMARY KEY (agent_id, session_id, sequence, direction)
432
440
  )
433
441
  `);
434
442
  // M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
@@ -438,10 +446,10 @@ export class SessionNodeManager {
438
446
  // across daemon restarts, not just within one process (INV-PUSHPULL). Additive table.
439
447
  this.#db.exec(`
440
448
  CREATE TABLE IF NOT EXISTS message_watermarks (
441
- agent_name TEXT NOT NULL,
449
+ agent_id TEXT NOT NULL,
442
450
  session_id TEXT NOT NULL,
443
451
  last_delivered_seq INTEGER NOT NULL,
444
- PRIMARY KEY (agent_name, session_id)
452
+ PRIMARY KEY (agent_id, session_id)
445
453
  )
446
454
  `);
447
455
  // M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
@@ -450,10 +458,10 @@ export class SessionNodeManager {
450
458
  // re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
451
459
  this.#db.exec(`
452
460
  CREATE TABLE IF NOT EXISTS contacts (
453
- agent_name TEXT NOT NULL,
461
+ agent_id TEXT NOT NULL,
454
462
  pubkey TEXT NOT NULL,
455
463
  added_at INTEGER NOT NULL,
456
- PRIMARY KEY (agent_name, pubkey)
464
+ PRIMARY KEY (agent_id, pubkey)
457
465
  )
458
466
  `);
459
467
  // MONIKER-3 AC1: the receiver's own pet name for a pubkey — the top tier of whoLabel.
@@ -463,6 +471,20 @@ export class SessionNodeManager {
463
471
  if (!contactCols.some((c) => c.name === "moniker")) {
464
472
  this.#db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
465
473
  }
474
+ // DOD-AGENT-ID-JOINKEY-1: finish REMOVE-001. Re-key the seven child tables from the mutable,
475
+ // reuse-freed `agent_name` to the stable `agent_id`, in ONE transaction. Runs AFTER every
476
+ // CREATE/ALTER above, so an existing table has its full historical column set before it is
477
+ // rebuilt, and BEFORE any read below touches it. A no-op once the tables carry `agent_id`.
478
+ //
479
+ // `retry_queue` (the seventh) is created later, by RetryQueue's constructor. On an existing
480
+ // database it already exists here and is re-keyed in the same transaction; on a fresh one it is
481
+ // absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
482
+ migrateSessionTablesToAgentId(this.#db, this.#logger);
483
+ // DOD-TIER-1 (address-book Step 1): give `contacts` its tier metadata (tier / provenance /
484
+ // last_offered_moniker / away_message). Pure ADD COLUMN, no rebuild — so it runs AFTER the
485
+ // agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
486
+ // read below. Idempotent, no column DEFAULT, grandfathers existing contacts to WHITELISTED once.
487
+ migrateContactsAddTierMetadata(this.#db, this.#logger);
466
488
  // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
467
489
  // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
468
490
  // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
@@ -475,12 +497,48 @@ export class SessionNodeManager {
475
497
  allowlisted_chat_id TEXT NOT NULL,
476
498
  updated_at INTEGER NOT NULL
477
499
  )
500
+ `);
501
+ // DOD-RENAME-1 (Option C): pending rename notices — one per (agent, contact). A notice is queued
502
+ // when a peer the operator has PERSONALLY NAMED offers a self-declared name that differs from the
503
+ // last one seen; it surfaces through cello_check_notifications (NOT a real-time push) and clears
504
+ // when the operator adopts a name (cello_contact_set_moniker) or removes the contact. Keyed on
505
+ // agent_id (the stable key); the offered name is charset-validated at the wire boundary but still
506
+ // operator-untrusted, so surfaces render it as a quoted CLAIM.
507
+ this.#db.exec(`
508
+ CREATE TABLE IF NOT EXISTS contact_rename_notices (
509
+ agent_id TEXT NOT NULL,
510
+ pubkey TEXT NOT NULL,
511
+ offered_name TEXT NOT NULL,
512
+ noticed_at INTEGER NOT NULL,
513
+ PRIMARY KEY (agent_id, pubkey)
514
+ )
515
+ `);
516
+ // DOD-SETTINGS-1: a daemon-side per-agent settings store for REACHABILITY POLICY (the tier bounds
517
+ // overrides and the per-tier/agent away messages). A generic key-value table on the stable
518
+ // agent_id, in the same SQLCipher DB. Deliberately NOT M9-CFG-001's gateway config store: this is
519
+ // daemon reachability policy, not gateway SCREENING config, and the M9 store is unwired + plaintext.
520
+ // reconcile with DOD-CONFIG-1 later; this is daemon reachability policy, not gateway config.
521
+ this.#db.exec(`
522
+ CREATE TABLE IF NOT EXISTS agent_settings (
523
+ agent_id TEXT NOT NULL,
524
+ key TEXT NOT NULL,
525
+ value TEXT NOT NULL,
526
+ updated_at INTEGER NOT NULL,
527
+ PRIMARY KEY (agent_id, key)
528
+ )
478
529
  `);
479
530
  // Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
480
531
  // Any 'active' row in a freshly-started daemon is a remnant of a prior
481
532
  // killed process. Batch-update to 'interrupted' before IPC opens.
533
+ // DOD-AGENT-ID-JOINKEY-1: this sweep spans EVERY agent, so it cannot resolve one name up front.
534
+ // It scopes its UPDATE by the row's own agent_id and LEFT JOINs `agents` only to LOG a human
535
+ // name. LEFT, not INNER: an inner join would silently skip a session whose agent row is missing,
536
+ // leaving it 'active' forever — a stuck row hidden by the query that was meant to find it. An
537
+ // orphan is instead marked interrupted like any other AND reported loudly.
482
538
  const activeRows = this.#db
483
- .prepare("SELECT * FROM sessions WHERE status = 'active'")
539
+ .prepare(`SELECT s.session_id, s.agent_id, a.agent_name
540
+ FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
541
+ WHERE s.status = 'active'`)
484
542
  .all();
485
543
  if (activeRows.length > 0) {
486
544
  const now = Date.now();
@@ -488,8 +546,15 @@ export class SessionNodeManager {
488
546
  for (const row of activeRows) {
489
547
  try {
490
548
  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);
549
+ .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?")
550
+ .run(now, interruptedAt, row.agent_id, row.session_id);
551
+ if (row.agent_name === null) {
552
+ this.#logger.error("session.agent.orphaned", {
553
+ sessionId: row.session_id,
554
+ agentId: row.agent_id,
555
+ impact: "session row references an agent_id with no agents row",
556
+ });
557
+ }
493
558
  this.#logger.warn("session.interrupted.detected", {
494
559
  sessionId: row.session_id,
495
560
  agentName: row.agent_name,
@@ -557,11 +622,12 @@ export class SessionNodeManager {
557
622
  if (!this.#db)
558
623
  return;
559
624
  try {
625
+ const agentId = this.#requireAgentId(agentName);
560
626
  const blob = Buffer.from(plaintext);
561
627
  this.#db
562
- .prepare(`INSERT OR IGNORE INTO transcript (agent_name, session_id, sequence, direction, blob, created_at)
628
+ .prepare(`INSERT OR IGNORE INTO transcript (agent_id, session_id, sequence, direction, blob, created_at)
563
629
  VALUES (?, ?, ?, ?, ?, ?)`)
564
- .run(agentName, sessionId, sequence, direction, blob, Date.now());
630
+ .run(agentId, sessionId, sequence, direction, blob, Date.now());
565
631
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
566
632
  }
567
633
  catch (err) {
@@ -590,8 +656,8 @@ export class SessionNodeManager {
590
656
  return { messages: [], undecryptable: 0 };
591
657
  const rows = this.#db
592
658
  .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);
659
+ WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
660
+ .all(this.#requireAgentId(agentName), sessionId);
595
661
  const messages = [];
596
662
  // PERSIST-002 (AC-010): the blob is plaintext (whole-DB SQLCipher at rest), so there is no
597
663
  // per-row decrypt step that can fail — `undecryptable` stays 0 and is kept only for callers that
@@ -614,8 +680,8 @@ export class SessionNodeManager {
614
680
  if (!this.#db)
615
681
  return -1;
616
682
  const row = this.#db
617
- .prepare("SELECT last_delivered_seq FROM message_watermarks WHERE agent_name = ? AND session_id = ?")
618
- .get(agentName, sessionId);
683
+ .prepare("SELECT last_delivered_seq FROM message_watermarks WHERE agent_id = ? AND session_id = ?")
684
+ .get(this.#requireAgentId(agentName), sessionId);
619
685
  return row ? row.last_delivered_seq : -1;
620
686
  }
621
687
  /** Advance the read watermark (delivery marks read). MONOTONIC — never lowers, so a replayed or
@@ -624,11 +690,11 @@ export class SessionNodeManager {
624
690
  if (!this.#db)
625
691
  return;
626
692
  this.#db
627
- .prepare(`INSERT INTO message_watermarks (agent_name, session_id, last_delivered_seq)
693
+ .prepare(`INSERT INTO message_watermarks (agent_id, session_id, last_delivered_seq)
628
694
  VALUES (?, ?, ?)
629
- ON CONFLICT(agent_name, session_id)
695
+ ON CONFLICT(agent_id, session_id)
630
696
  DO UPDATE SET last_delivered_seq = MAX(last_delivered_seq, excluded.last_delivered_seq)`)
631
- .run(agentName, sessionId, seq);
697
+ .run(this.#requireAgentId(agentName), sessionId, seq);
632
698
  this.#logger.info("message.watermark.advanced", { agentName, sessionId, sequence: seq });
633
699
  }
634
700
  /** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
@@ -643,29 +709,98 @@ export class SessionNodeManager {
643
709
  MAX(t.sequence) AS last_seq
644
710
  FROM transcript t
645
711
  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 = ?
712
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
713
+ WHERE t.agent_id = ?
648
714
  AND t.direction = 'received'
649
715
  AND t.sequence > COALESCE(w.last_delivered_seq, -1)
650
716
  GROUP BY t.session_id
651
717
  HAVING unread_count > 0
652
718
  ORDER BY t.session_id ASC`)
653
- .all(agentName);
719
+ .all(this.#requireAgentId(agentName));
654
720
  return rows;
655
721
  }
656
722
  /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
657
723
  isContact(agentName, pubkey) {
658
724
  if (!this.#db)
659
725
  return false;
660
- const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_name = ? AND pubkey = ?").get(agentName, pubkey);
726
+ const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
661
727
  return row !== undefined;
662
728
  }
729
+ /** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
730
+ * absent contact row (undefined), a NULL `tier`, or a corrupt out-of-range value all resolve to
731
+ * UNKNOWN via `normalizeTier`, so the return is always in 0..4 and guards the JS `null >= 0`/`0 ||
732
+ * 1`/`grid[99]` traps. It is a SECURITY read (Step 2 gates inbound bounds on it), so it FAILS
733
+ * CLOSED, never open: an uninitialized DB throws (same contract as addContact) rather than
734
+ * silently returning UNKNOWN and admitting a BLOCKED sender; an unresolvable/retired agent name
735
+ * throws via #requireAgentId. Both are invariant violations a caller must surface, not swallow. */
736
+ getTier(agentName, pubkey) {
737
+ // Fail CLOSED: a read that decides whether to admit a sender must not degrade to "unclassified"
738
+ // when it cannot reach the ACL — that would admit a blocked contact. Throw as addContact does.
739
+ if (!this.#db)
740
+ throw new Error(`getTier('${agentName}'): database not initialized`);
741
+ const row = this.#db
742
+ .prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
743
+ .get(this.#requireAgentId(agentName), pubkey);
744
+ if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
745
+ // A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
746
+ // tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
747
+ this.#logger.warn("contact.tier.corrupt", { agentName, pubkey, storedTier: row.tier });
748
+ }
749
+ return normalizeTier(row?.tier);
750
+ }
751
+ /** DOD-TIER-4: the DISPLAY/relationship check — is this counterparty a genuine contact (KNOWN or
752
+ * above)? Replaces the old binary `isContact` for behaviour that keyed on "we have a relationship"
753
+ * (e.g. the away-response wording). An UNKNOWN-tier contact (a mere row) is NOT known. */
754
+ isKnown(agentName, pubkey) {
755
+ return this.getTier(agentName, pubkey) >= TIER.KNOWN;
756
+ }
757
+ /** DOD-TIER-4: the POLICY gate — may an inbound session from this counterparty be auto-accepted
758
+ * when the operator is unattended (WHITELISTED or VIP)? The behavioural consumer is the offline
759
+ * relay mailbox (LEAVEMSG-1), out of scope for this unit; defined here as the seam. Being merely
760
+ * KNOWN is NOT enough to auto-accept — whitelisting is the deliberate `cello_contact_set_tier` act. */
761
+ isAutoAccept(agentName, pubkey) {
762
+ return this.getTier(agentName, pubkey) >= TIER.WHITELISTED;
763
+ }
764
+ /** DOD-TIER-BOUNDS-SETTINGS: the effective bound for (agent, tier, field) — a per-agent SETTINGS
765
+ * override if one is set and valid, else the hardcoded grid default (DEFAULT_TIER_BOUNDS). With no
766
+ * settings this is byte-identical to Step 2 (the daemon runs on defaults alone). A stored value
767
+ * that is somehow non-positive/non-finite (should be impossible — validated at SET time) falls back
768
+ * to the grid default rather than removing the bound (INV-TIER-BOUND, defensive). BLOCKED is never
769
+ * settable — it always returns the fixed grid value (0). */
770
+ resolveTierBound(agentName, tier, field) {
771
+ const gridDefault = field === "max_sessions"
772
+ ? tierBoundsFor(tier).maxSessionsPerSender
773
+ : tierBoundsFor(tier).maxBytesPerSession;
774
+ const name = settableTierName(tier);
775
+ if (name === null)
776
+ return gridDefault; // BLOCKED or out-of-range — fixed, not overridable
777
+ const raw = this.getSetting(agentName, boundSettingKey(name, field));
778
+ if (raw === null)
779
+ return gridDefault; // unset → default
780
+ const parsed = Number(raw);
781
+ if (!Number.isFinite(parsed) || parsed <= 0) {
782
+ // Should be impossible (validated at SET time) → a config-integrity failure. Surface it: this
783
+ // reverts a possibly-TIGHTENED bound to the looser default, so a silent revert would hide a real
784
+ // problem. Still fail SAFE (grid default, never unbounded — INV-TIER-BOUND).
785
+ this.#logger.warn("settings.bound.corrupt", { agentName, tier, field, raw });
786
+ return gridDefault;
787
+ }
788
+ return parsed;
789
+ }
663
790
  /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
664
791
  * no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
665
792
  * optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
666
793
  * THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
667
- * backstop (same contract as DbIdentityStore.setMoniker). */
668
- addContact(agentName, pubkey, moniker) {
794
+ * backstop (same contract as DbIdentityStore.setMoniker).
795
+ *
796
+ * DOD-TIER-1/4: a NEW row is stamped `tier` (never NULL) and an optional `provenance`
797
+ * ('accepted' | 'initiated' | null). The `tier` defaults to the least-privilege UNKNOWN floor —
798
+ * a caller GRANTS trust by passing a higher tier explicitly. Every production creation path is a
799
+ * deliberate operator action and passes KNOWN (initiate, engage/reply, explicit cello_contact_add
800
+ * — DEC-AB-1). INSERT OR IGNORE means an EXISTING contact is untouched — tier and provenance pin
801
+ * at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
802
+ * the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
803
+ addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
669
804
  if (!pubkey)
670
805
  return;
671
806
  // Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
@@ -675,13 +810,21 @@ export class SessionNodeManager {
675
810
  if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
676
811
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
677
812
  }
813
+ // DOD-TIER-4 (review F3): the stored tier must be a known 0..4 constant — a can-never-be-stored
814
+ // backstop mirroring the moniker validation above. All callers pass a TIER constant; this catches
815
+ // a future caller (or a bad refactor) that would otherwise persist a corrupt tier the read side
816
+ // must then defensively normalize.
817
+ if (!isKnownTierValue(tier)) {
818
+ throw new Error(`invalid contact tier for agent '${agentName}': ${tier} (must be 0..4)`);
819
+ }
820
+ const agentId = this.#requireAgentId(agentName);
678
821
  this.#db
679
- .prepare("INSERT OR IGNORE INTO contacts (agent_name, pubkey, added_at) VALUES (?, ?, ?)")
680
- .run(agentName, pubkey, Date.now());
822
+ .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
823
+ .run(agentId, pubkey, Date.now(), tier, provenance ?? null);
681
824
  if (moniker !== undefined && moniker !== null) {
682
825
  this.#db
683
- .prepare("UPDATE contacts SET moniker = ? WHERE agent_name = ? AND pubkey = ?")
684
- .run(moniker, agentName, pubkey);
826
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
827
+ .run(moniker, agentId, pubkey);
685
828
  }
686
829
  }
687
830
  /** MONIKER-3 AC3: rename (string) or clear (null) an EXISTING contact's pet name. Returns false
@@ -696,15 +839,130 @@ export class SessionNodeManager {
696
839
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
697
840
  }
698
841
  const res = this.#db
699
- .prepare("UPDATE contacts SET moniker = ? WHERE agent_name = ? AND pubkey = ?")
700
- .run(moniker, agentName, pubkey);
842
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
843
+ .run(moniker, this.#requireAgentId(agentName), pubkey);
844
+ // DOD-RENAME-1: setting the local pet name IS the operator acting on a rename — resolve any
845
+ // pending notice for this contact (whether they adopted the offered name or chose their own).
846
+ if (res.changes > 0)
847
+ this.clearRenameNotice(agentName, pubkey);
848
+ return res.changes > 0;
849
+ }
850
+ /** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
851
+ * when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
852
+ setContactAwayMessage(agentName, pubkey, message) {
853
+ if (!this.#db)
854
+ throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
855
+ const res = this.#db
856
+ .prepare("UPDATE contacts SET away_message = ? WHERE agent_id = ? AND pubkey = ?")
857
+ .run(message, this.#requireAgentId(agentName), pubkey);
701
858
  return res.changes > 0;
702
859
  }
860
+ /** DOD-AWAY-TIER-1: resolve the most-specific CUSTOM away text for a counterparty, most-specific
861
+ * first: per-contact `away_message` → per-tier away setting → agent default away setting. Returns
862
+ * null when none is configured, so the CALLER applies the system default (code) — making the full
863
+ * four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
864
+ * the caller like any content (SI — it does not bypass the gateway). */
865
+ resolveAwayMessage(agentName, pubkey) {
866
+ if (!this.#db)
867
+ return null;
868
+ const agentId = this.#requireAgentId(agentName);
869
+ const row = this.#db
870
+ .prepare("SELECT away_message FROM contacts WHERE agent_id = ? AND pubkey = ?")
871
+ .get(agentId, pubkey);
872
+ if (row?.away_message != null) {
873
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "contact" }); // obs AC
874
+ return row.away_message; // 1. per-contact
875
+ }
876
+ const tierName = settableTierName(this.getTier(agentName, pubkey));
877
+ if (tierName !== null) {
878
+ const tierAway = this.getSetting(agentName, awayTierSettingKey(tierName));
879
+ if (tierAway !== null) {
880
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "tier" });
881
+ return tierAway; // 2. per-tier
882
+ }
883
+ }
884
+ const agentDefault = this.getSetting(agentName, AWAY_DEFAULT_KEY);
885
+ // 3. agent default, else null → caller applies the system default (code). Level logged HERE.
886
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: agentDefault !== null ? "agent_default" : "system" });
887
+ return agentDefault;
888
+ }
889
+ /** DOD-CONTACT-VIEW-1: set an EXISTING contact's reachability tier. Returns false when no such
890
+ * contact — fail-loud at the caller, never a silent no-op success (same contract as
891
+ * setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
892
+ * stores whatever it is handed (the handler is the validation boundary). */
893
+ setContactTier(agentName, pubkey, tier) {
894
+ if (!this.#db)
895
+ throw new Error(`setContactTier('${agentName}'): database not initialized`);
896
+ const res = this.#db
897
+ .prepare("UPDATE contacts SET tier = ? WHERE agent_id = ? AND pubkey = ?")
898
+ .run(tier, this.#requireAgentId(agentName), pubkey);
899
+ return res.changes > 0;
900
+ }
901
+ /** DOD-RENAME-1 (Option C): record a self-declared name a peer offered, at the moment the offer is
902
+ * SEEN. The stored local pet name (contacts.moniker) is SACROSANCT — this only ever touches
903
+ * last_offered_moniker and the notice queue, never the moniker (AC2). A rename NOTICE is queued
904
+ * only when the peer is a contact the operator has PERSONALLY NAMED (moniker non-null), a name was
905
+ * seen BEFORE (last_offered_moniker non-null), and the new offer DIFFERS (AC3). The first-ever
906
+ * offer just records the baseline (no notice); a repeat of the same name is idempotent (AC4).
907
+ * Called only when a moniker WAS offered (caller-guarded), so silence never clears the baseline
908
+ * (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
909
+ * detection works only for peers who INITIATE to you — a property, not a bug. */
910
+ recordOfferedMoniker(agentName, pubkey, offered) {
911
+ // Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
912
+ // (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
913
+ if (!this.#db)
914
+ throw new Error(`recordOfferedMoniker('${agentName}'): database not initialized`);
915
+ const agentId = this.#requireAgentId(agentName);
916
+ const row = this.#db
917
+ .prepare("SELECT last_offered_moniker, moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
918
+ .get(agentId, pubkey);
919
+ if (!row)
920
+ return; // not a contact — no row to hold a baseline or a notice
921
+ if (offered === row.last_offered_moniker)
922
+ return; // idempotent — same name already seen (AC4)
923
+ // A genuine change from a previously-seen name, for a contact the operator has named → notice.
924
+ if (row.last_offered_moniker !== null && row.moniker !== null) {
925
+ this.#db
926
+ .prepare("INSERT OR REPLACE INTO contact_rename_notices (agent_id, pubkey, offered_name, noticed_at) VALUES (?, ?, ?, ?)")
927
+ .run(agentId, pubkey, offered, Date.now());
928
+ // Observability: log the FACT, never the attacker-chosen name (same rule as moniker.rejected).
929
+ this.#logger.info("contact.rename.noticed", { agentName, pubkey });
930
+ }
931
+ this.#db
932
+ .prepare("UPDATE contacts SET last_offered_moniker = ? WHERE agent_id = ? AND pubkey = ?")
933
+ .run(offered, agentId, pubkey);
934
+ }
935
+ /** DOD-RENAME-1: pending rename notices for an agent, oldest first (surfaced in
936
+ * cello_check_notifications — an INBOX pull, never a real-time push). */
937
+ getRenameNotices(agentName) {
938
+ if (!this.#db)
939
+ return [];
940
+ // JOIN the local pet name so the notice can NAME the contact (AC3) — a notice only ever fires for
941
+ // a personally-named contact, so moniker is expected non-null (LEFT JOIN is defensive).
942
+ return this.#db
943
+ .prepare(`SELECT n.pubkey, n.offered_name, n.noticed_at, c.moniker
944
+ FROM contact_rename_notices n
945
+ LEFT JOIN contacts c ON c.agent_id = n.agent_id AND c.pubkey = n.pubkey
946
+ WHERE n.agent_id = ? ORDER BY n.noticed_at ASC`)
947
+ .all(this.#requireAgentId(agentName));
948
+ }
949
+ /** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
950
+ * contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
951
+ clearRenameNotice(agentName, pubkey) {
952
+ if (!this.#db)
953
+ throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
954
+ this.#db
955
+ .prepare("DELETE FROM contact_rename_notices WHERE agent_id = ? AND pubkey = ?")
956
+ .run(this.#requireAgentId(agentName), pubkey);
957
+ }
703
958
  /** M8C-CONTACT-1: known stays known until explicitly removed. */
704
959
  removeContact(agentName, pubkey) {
705
960
  if (!this.#db)
706
961
  return false;
707
- const res = this.#db.prepare("DELETE FROM contacts WHERE agent_name = ? AND pubkey = ?").run(agentName, pubkey);
962
+ const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
963
+ // DOD-RENAME-1: a removed contact has no pending rename to resolve.
964
+ if (res.changes > 0)
965
+ this.clearRenameNotice(agentName, pubkey);
708
966
  return res.changes > 0;
709
967
  }
710
968
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
@@ -717,26 +975,36 @@ export class SessionNodeManager {
717
975
  return null;
718
976
  }
719
977
  const row = this.#db
720
- .prepare("SELECT moniker FROM contacts WHERE agent_name = ? AND pubkey = ?")
721
- .get(agentName, pubkey);
978
+ .prepare("SELECT moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
979
+ .get(this.#requireAgentId(agentName), pubkey);
722
980
  return row?.moniker ?? null;
723
981
  }
724
- /** M8C-CONTACT-1: list an agent's known contacts, oldest-added first. MONIKER-3 AC3: includes
725
- * the pet name (null when none set). */
982
+ /** M8C-CONTACT-1 + DOD-CONTACT-VIEW-1: list an agent's contacts, oldest-added first, each with its
983
+ * pet name (MONIKER-3), tier + provenance (the address-book metadata), and a READ-side LEFT JOIN
984
+ * against `sessions` for how many SEALED sessions were shared and when they last spoke (MAX
985
+ * updated_at). No new stored data — a pure read. A contact with no sessions shows 0 / null (never),
986
+ * not an error. The JOIN is scoped by agent_id so one agent's sessions never bleed into another's. */
726
987
  listContacts(agentName) {
727
988
  if (!this.#db)
728
989
  return [];
729
990
  return this.#db
730
- .prepare("SELECT pubkey, added_at, moniker FROM contacts WHERE agent_name = ? ORDER BY added_at ASC")
731
- .all(agentName);
991
+ .prepare(`SELECT c.pubkey, c.added_at, c.moniker, c.tier, c.provenance,
992
+ COUNT(CASE WHEN s.status = 'sealed' THEN 1 END) AS sealed_count,
993
+ MAX(s.updated_at) AS last_spoke
994
+ FROM contacts c
995
+ LEFT JOIN sessions s ON s.agent_id = c.agent_id AND s.counterparty_pubkey = c.pubkey
996
+ WHERE c.agent_id = ?
997
+ GROUP BY c.pubkey, c.added_at, c.moniker, c.tier, c.provenance
998
+ ORDER BY c.added_at ASC`)
999
+ .all(this.#requireAgentId(agentName));
732
1000
  }
733
1001
  /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
734
1002
  #getReceivedBytesTotal(agentName, sessionId) {
735
1003
  if (!this.#db)
736
1004
  return 0;
737
1005
  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);
1006
+ .prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
1007
+ .get(this.#requireAgentId(agentName), sessionId);
740
1008
  return row.total;
741
1009
  }
742
1010
  /** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
@@ -763,36 +1031,53 @@ export class SessionNodeManager {
763
1031
  if (!this.#db)
764
1032
  return 0;
765
1033
  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);
1034
+ .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
1035
+ .get(this.#requireAgentId(agentName), counterpartyPubkey);
768
1036
  return row.n;
769
1037
  }
770
- /** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with counterparties that are
771
- * NOT a known contact — the global cap counts across ALL unknown senders combined. Same
1038
+ /** M8C-ABUSE-1 (anti-swarm) + DOD-TIER-2: non-terminal sessions this agent holds with UNKNOWN-tier
1039
+ * counterparties — the global cap counts across the whole stranger pool. A sender is exempt from
1040
+ * THIS pool iff it is a KNOWN+ contact (tier >= KNOWN); a bare stranger (no row → UNKNOWN) or an
1041
+ * explicitly UNKNOWN-tier contact both count. Keying on `tier >= KNOWN` (bounded to <= VIP so a
1042
+ * corrupt high value cannot grant pool-exemption) replaces the old row-existence proxy, which
1043
+ * would have let a merely-recorded UNKNOWN contact escape the anti-swarm cap. Same
772
1044
  * 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
773
1045
  countActiveSessionsFromUnknownSenders(agentName) {
774
1046
  if (!this.#db)
775
1047
  return 0;
776
1048
  const row = this.#db
777
1049
  .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);
1050
+ WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
1051
+ AND NOT EXISTS (
1052
+ SELECT 1 FROM contacts c
1053
+ WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
1054
+ AND c.tier >= ${TIER.KNOWN} AND c.tier <= ${TIER.VIP}
1055
+ )`)
1056
+ .get(this.#requireAgentId(agentName));
781
1057
  return row.n;
782
1058
  }
783
- /** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the acceptance bounds?
784
- * Known contacts are exempt entirely ("bounded only by disk" DoD). Checked BEFORE accepting
785
- * a fresh inbound session (the counts reflect sessions already active, not yet counting this one). */
1059
+ /** M8C-ABUSE-1 + DOD-TIER-2/3: is a NEW inbound session from this counterparty within the
1060
+ * acceptance bounds? The per-sender cap is now the sender's TIER cap (DEFAULT_TIER_BOUNDS), not a
1061
+ * flat "3 for strangers, unbounded for contacts". This is where DOD-TIER-3 falls out for free: a
1062
+ * BLOCKED sender's cap is 0, so `perSender (>= 0) >= 0` refuses it through the SAME reason and the
1063
+ * SAME path an over-cap UNKNOWN takes — no separate blocked branch, no distinguishing oracle. The
1064
+ * global anti-swarm cap then applies ONLY to UNKNOWN-tier senders (KNOWN+ are trusted, not part of
1065
+ * the stranger pool; BLOCKED never reaches it). Checked BEFORE accepting a fresh inbound session
1066
+ * (counts reflect sessions already active, not yet counting this one). */
786
1067
  checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
787
- if (this.isContact(agentName, counterpartyPubkey))
788
- return { ok: true };
1068
+ const tier = this.getTier(agentName, counterpartyPubkey);
1069
+ const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
789
1070
  const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
790
- if (perSender >= ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER) {
1071
+ if (perSender >= perSenderCap) {
791
1072
  return { ok: false, reason: "abuse_bound_sessions_per_sender" };
792
1073
  }
793
- const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
794
- if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
795
- return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
1074
+ // The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
1075
+ // a BLOCKED sender was already refused above (cap 0).
1076
+ if (tier === TIER.UNKNOWN) {
1077
+ const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
1078
+ if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
1079
+ return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
1080
+ }
796
1081
  }
797
1082
  return { ok: true };
798
1083
  }
@@ -814,6 +1099,42 @@ export class SessionNodeManager {
814
1099
  ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
815
1100
  .run(botToken, allowlistedChatId, Date.now());
816
1101
  }
1102
+ /** DOD-SETTINGS-1: read a per-agent setting, or null if unset. The get-with-default is the CALLER's
1103
+ * job (an unset key falls back to the hardcoded grid/system default — the daemon runs correctly on
1104
+ * defaults alone, AC3). Returns null on a missing DB (settings are always optional). */
1105
+ getSetting(agentName, key) {
1106
+ if (!this.#db)
1107
+ return null;
1108
+ const row = this.#db
1109
+ .prepare("SELECT value FROM agent_settings WHERE agent_id = ? AND key = ?")
1110
+ .get(this.#requireAgentId(agentName), key);
1111
+ return row?.value ?? null;
1112
+ }
1113
+ /** DOD-SETTINGS-1: write a per-agent setting (upsert). Key VALIDATION is the handler's boundary
1114
+ * (isValidSettingKey); value validation for typed settings (finite bounds, etc.) belongs to the
1115
+ * specific consumer. Throws on a missing DB — a write that silently no-ops would be a lie. */
1116
+ setSetting(agentName, key, value) {
1117
+ if (!this.#db)
1118
+ throw new Error(`setSetting('${agentName}'): database not initialized`);
1119
+ // Store-level backstop (review F2): the handler validates the key, but the dual-layer convention
1120
+ // (cf. MONIKER-1) means an unknown key can NEVER be stored — an internal caller that hand-typed a
1121
+ // key instead of using the builders would otherwise persist a setting that never takes effect.
1122
+ if (!isValidSettingKey(key))
1123
+ throw new Error(`invalid_key: '${key}' is not a known setting`);
1124
+ this.#db
1125
+ .prepare(`INSERT INTO agent_settings (agent_id, key, value, updated_at) VALUES (?, ?, ?, ?)
1126
+ ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
1127
+ .run(this.#requireAgentId(agentName), key, value, Date.now());
1128
+ }
1129
+ /** DOD-SETTINGS-1: all explicitly-set settings for an agent (the ones that OVERRIDE a default),
1130
+ * key-sorted. Unset keys are absent — the operator sees only what they changed. */
1131
+ getAllSettings(agentName) {
1132
+ if (!this.#db)
1133
+ return [];
1134
+ return this.#db
1135
+ .prepare("SELECT key, value FROM agent_settings WHERE agent_id = ? ORDER BY key ASC")
1136
+ .all(this.#requireAgentId(agentName));
1137
+ }
817
1138
  /** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
818
1139
  getStandingReceiverReady(agentName) {
819
1140
  if (agentName !== undefined)
@@ -906,6 +1227,66 @@ export class SessionNodeManager {
906
1227
  #k(agentName, sessionId) {
907
1228
  return `${agentName}\x1f${sessionId}`;
908
1229
  }
1230
+ /**
1231
+ * DOD-AGENT-ID-JOINKEY-1: resolve an agent's NAME to its STABLE agent_id. This is the ONE place a
1232
+ * name becomes a key, and it is the boundary between the two worlds:
1233
+ *
1234
+ * - ABOVE it, addressing by name is correct. The operator says `cello_use_agent { name }`, and
1235
+ * the in-memory maps (#k, standing receivers, keyProviders) key by name safely, because
1236
+ * name-addressing only ever resolves ACTIVE agents and the `agents_active_name` partial unique
1237
+ * index makes active names unique.
1238
+ * - BELOW it, only `agent_id` may touch SQL. `agent_name` is a mutable display attribute that a
1239
+ * retire frees for reuse; a table joined on it hands a new keypair the dead identity's rows.
1240
+ *
1241
+ * It resolves ONLY non-retired agents — a retired identity is gone from the runtime (`list` omits
1242
+ * it, `start` returns agent_not_found), so no live surface may act as one.
1243
+ *
1244
+ * It THROWS on an unresolvable name rather than returning null. A null would flow into a
1245
+ * `WHERE agent_id IS NULL` that quietly matches nothing: reads would return empty and writes would
1246
+ * vanish, and the daemon would look healthy while losing an agent's data. Every caller has already
1247
+ * resolved the agent before it gets here, so an unresolvable name is a bug in the caller, not a
1248
+ * condition to absorb.
1249
+ */
1250
+ #requireAgentId(agentName) {
1251
+ if (!this.#db)
1252
+ throw new Error("agent_id_unresolved: database is not open");
1253
+ const row = this.#db
1254
+ .prepare("SELECT agent_id FROM agents WHERE agent_name = ? AND state != 'retired'")
1255
+ .get(agentName);
1256
+ if (!row) {
1257
+ this.#logger.error("session.agent_id.unresolved", { agentName });
1258
+ throw new Error(`agent_id_unresolved: no active agent named '${agentName}'. The session tables are keyed by the ` +
1259
+ `stable agent_id (DOD-AGENT-ID-JOINKEY-1); scoping a query by an unresolvable name would ` +
1260
+ `silently match nothing.`);
1261
+ }
1262
+ return row.agent_id;
1263
+ }
1264
+ /**
1265
+ * DOD-AGENT-ID-JOINKEY-1: the public form of the name→stable-id resolver, for components that own
1266
+ * agent-scoped tables of their own (RetryQueue) and must be handed the STABLE key, never a name.
1267
+ * The daemon resolves ONCE, at its own boundary, exactly as this class does internally. Throws on
1268
+ * an unresolvable name — see #requireAgentId for why null is not an option.
1269
+ */
1270
+ resolveAgentId(agentName) {
1271
+ return this.#requireAgentId(agentName);
1272
+ }
1273
+ /**
1274
+ * Reverse lookup: the display name of a stable agent_id, or null if no such agent.
1275
+ *
1276
+ * Deliberately INCLUDES retired agents. Its caller (the startup awaiting-content re-park) holds an
1277
+ * agent_id read off a durable row and needs a name to find that agent's standing receiver. A
1278
+ * retired agent resolves to its name and then has no standing receiver, so the park fails cleanly
1279
+ * and loudly — which is correct. Filtering retired agents out here would instead make the row
1280
+ * unattributable and the failure mute.
1281
+ */
1282
+ agentNameForId(agentId) {
1283
+ if (!this.#db)
1284
+ return null;
1285
+ const row = this.#db
1286
+ .prepare("SELECT agent_name FROM agents WHERE agent_id = ?")
1287
+ .get(agentId);
1288
+ return row?.agent_name ?? null;
1289
+ }
909
1290
  /**
910
1291
  * Create a new outbound session node.
911
1292
  * Called during cello_initiate_session.
@@ -1152,8 +1533,8 @@ export class SessionNodeManager {
1152
1533
  // before the in-memory entry exists) can deposit un-acked content to the same relay.
1153
1534
  try {
1154
1535
  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);
1536
+ ?.prepare("UPDATE sessions SET relay_peer_id = ?, relay_addrs = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
1537
+ .run(relay.relayPeerId, JSON.stringify(relay.relayAddrs), Date.now(), this.#requireAgentId(agentName), sessionId);
1157
1538
  }
1158
1539
  catch (err) {
1159
1540
  this.#logger.warn("session.relay.endpoint.persist.failed", {
@@ -1595,8 +1976,22 @@ export class SessionNodeManager {
1595
1976
  getSessionsByStatus(status) {
1596
1977
  if (!this.#db)
1597
1978
  return [];
1979
+ // Spans EVERY agent (cello_status is daemon-wide), so no single name can be resolved up front —
1980
+ // the display name is joined in from `agents`, its one source of truth. `agent_name` is no
1981
+ // longer a `sessions` column, so without this join buildActiveSessions/buildInterruptedSessions
1982
+ // read `row.agent_name` as undefined.
1983
+ //
1984
+ // DOD-AGENT-ID-JOINKEY-1 (reviewer Finding 1): INNER JOIN with `state != 'retired'`, NOT a bare
1985
+ // LEFT JOIN. This is the LIVE-status + reaper surface, and a retired agent is gone from the
1986
+ // runtime — its leftover session rows (kept for accountability, never re-statused) are not
1987
+ // resumable and must not appear here. If they did, the half-open reaper would resolve their
1988
+ // RETIRED name via #requireAgentId, which throws, taking down cello_status for the whole daemon.
1989
+ // Excluding them also guarantees a non-null agent_name on every returned row. The full historical
1990
+ // archive (getAllSessions) keeps its LEFT JOIN and still shows retired/orphaned rows.
1598
1991
  return this.#db
1599
- .prepare("SELECT * FROM sessions WHERE status = ?")
1992
+ .prepare(`SELECT s.*, a.agent_name AS agent_name
1993
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
1994
+ WHERE s.status = ? AND a.state != 'retired'`)
1600
1995
  .all(status);
1601
1996
  }
1602
1997
  /**
@@ -1610,9 +2005,13 @@ export class SessionNodeManager {
1610
2005
  getSessionsForAgent(agentName) {
1611
2006
  if (!this.#db)
1612
2007
  return [];
1613
- return this.#db
1614
- .prepare("SELECT * FROM sessions WHERE agent_name = ? ORDER BY updated_at DESC")
1615
- .all(agentName);
2008
+ // Scoped by the STABLE id. `agent_name` is not a column of `sessions` any more, so it is stamped
2009
+ // back on for display and it is exactly the name we just resolved the id FROM, so no join is
2010
+ // needed and no stale copy can exist.
2011
+ const rows = this.#db
2012
+ .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
2013
+ .all(this.#requireAgentId(agentName));
2014
+ return rows.map((r) => ({ ...r, agent_name: agentName }));
1616
2015
  }
1617
2016
  /**
1618
2017
  * Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
@@ -1622,8 +2021,13 @@ export class SessionNodeManager {
1622
2021
  getAllSessions() {
1623
2022
  if (!this.#db)
1624
2023
  return [];
2024
+ // Spans EVERY agent, so no single name can be resolved up front: the display name is joined in
2025
+ // from `agents`, its one source of truth. LEFT JOIN, not INNER — a session whose agent row is
2026
+ // missing must still be listed (an invisible session is worse than an unnamed one).
1625
2027
  return this.#db
1626
- .prepare("SELECT * FROM sessions ORDER BY updated_at DESC")
2028
+ .prepare(`SELECT s.*, a.agent_name AS agent_name
2029
+ FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
2030
+ ORDER BY s.updated_at DESC`)
1627
2031
  .all();
1628
2032
  }
1629
2033
  /**
@@ -1639,8 +2043,8 @@ export class SessionNodeManager {
1639
2043
  if (!this.#db)
1640
2044
  return;
1641
2045
  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);
2046
+ .prepare("UPDATE sessions SET seal_legibility = ?, sealed_root_hex = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
2047
+ .run(legibilityJson, sealedRootHex, Date.now(), this.#requireAgentId(agentName), sessionId);
1644
2048
  }
1645
2049
  /**
1646
2050
  * M8B FINDING-6 (cascade-2): persist a seal certificate for a session that may have NO local
@@ -1658,9 +2062,9 @@ export class SessionNodeManager {
1658
2062
  const now = Date.now();
1659
2063
  this.#db
1660
2064
  .prepare(`INSERT OR IGNORE INTO sessions
1661
- (session_id, agent_name, counterparty_pubkey, status, created_at, updated_at)
2065
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
1662
2066
  VALUES (?, ?, ?, ?, ?, ?)`)
1663
- .run(sessionId, agentName, counterpartyPubkeyHex, "sealed", now, now);
2067
+ .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkeyHex, "sealed", now, now);
1664
2068
  this.recordSealCertificate(agentName, sessionId, sealedRootHex, legibilityJson);
1665
2069
  }
1666
2070
  /**
@@ -1673,8 +2077,8 @@ export class SessionNodeManager {
1673
2077
  if (!this.#db)
1674
2078
  return;
1675
2079
  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);
2080
+ .prepare("UPDATE sessions SET counterparty_primary_pubkey = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
2081
+ .run(primaryPubkeyHex, Date.now(), this.#requireAgentId(agentName), sessionId);
1678
2082
  }
1679
2083
  /**
1680
2084
  * M7-SESSION-004 (AC-005/AC-006): read the persisted seal certificate for a session.
@@ -1688,8 +2092,8 @@ export class SessionNodeManager {
1688
2092
  if (!this.#db)
1689
2093
  return null;
1690
2094
  const row = this.#db
1691
- .prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE agent_name = ? AND session_id = ?")
1692
- .get(agentName, sessionId);
2095
+ .prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE agent_id = ? AND session_id = ?")
2096
+ .get(this.#requireAgentId(agentName), sessionId);
1693
2097
  if (!row || !row.seal_legibility || !row.sealed_root_hex)
1694
2098
  return null;
1695
2099
  let legibility;
@@ -1741,8 +2145,8 @@ export class SessionNodeManager {
1741
2145
  // the pre-check above raced (it cannot — DatabaseSync is synchronous), the
1742
2146
  // UPDATE only mutates a row that is still active.
1743
2147
  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);
2148
+ .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_id = ? AND session_id = ? AND status = 'active'")
2149
+ .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
1746
2150
  }
1747
2151
  catch (err) {
1748
2152
  this.#logger.error("session.interrupt.db.write.failed", {
@@ -1826,9 +2230,9 @@ export class SessionNodeManager {
1826
2230
  try {
1827
2231
  this.#db
1828
2232
  .prepare(`INSERT OR REPLACE INTO seal_interrupted_artifacts
1829
- (agent_name, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
2233
+ (agent_id, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
1830
2234
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
1831
- .run(opts.agentName, opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
2235
+ .run(this.#requireAgentId(opts.agentName), opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
1832
2236
  }
1833
2237
  catch (err) {
1834
2238
  this.#logger.error("session.interrupted.db.write.failed", {
@@ -1842,8 +2246,8 @@ export class SessionNodeManager {
1842
2246
  // active-session seal). The guard still refuses to overwrite a terminal
1843
2247
  // 'sealed' row or an already-pending one.
1844
2248
  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);
2249
+ .prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_id = ? AND session_id = ? AND status IN ('active', 'interrupted')")
2250
+ .run(now, this.#requireAgentId(opts.agentName), opts.sessionId);
1847
2251
  return Number(result.changes) > 0;
1848
2252
  }
1849
2253
  /**
@@ -1854,8 +2258,8 @@ export class SessionNodeManager {
1854
2258
  if (!this.#db)
1855
2259
  return null;
1856
2260
  const row = this.#db
1857
- .prepare("SELECT * FROM seal_interrupted_artifacts WHERE agent_name = ? AND session_id = ?")
1858
- .get(agentName, sessionId);
2261
+ .prepare("SELECT * FROM seal_interrupted_artifacts WHERE agent_id = ? AND session_id = ?")
2262
+ .get(this.#requireAgentId(agentName), sessionId);
1859
2263
  if (!row)
1860
2264
  return null;
1861
2265
  return {
@@ -1874,9 +2278,11 @@ export class SessionNodeManager {
1874
2278
  if (!this.#db)
1875
2279
  return null;
1876
2280
  const row = this.#db
1877
- .prepare("SELECT * FROM sessions WHERE agent_name = ? AND session_id = ?")
1878
- .get(agentName, sessionId);
1879
- return row ?? null;
2281
+ .prepare("SELECT * FROM sessions WHERE agent_id = ? AND session_id = ?")
2282
+ .get(this.#requireAgentId(agentName), sessionId);
2283
+ // `agent_name` is display-only and no longer stored on the row; stamp back the name whose
2284
+ // agent_id scoped this lookup (~50 daemon call sites read `record.agent_name`).
2285
+ return row ? { ...row, agent_name: agentName } : null;
1880
2286
  }
1881
2287
  /**
1882
2288
  * MSG-2 startup-flush: the persisted relay endpoint for a session, or null if none was
@@ -1887,8 +2293,8 @@ export class SessionNodeManager {
1887
2293
  if (!this.#db)
1888
2294
  return null;
1889
2295
  const row = this.#db
1890
- .prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE agent_name = ? AND session_id = ?")
1891
- .get(agentName, sessionId);
2296
+ .prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND session_id = ?")
2297
+ .get(this.#requireAgentId(agentName), sessionId);
1892
2298
  if (!row?.relay_peer_id || !row?.relay_addrs)
1893
2299
  return null;
1894
2300
  try {
@@ -1911,8 +2317,8 @@ export class SessionNodeManager {
1911
2317
  if (!this.#db)
1912
2318
  return [];
1913
2319
  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);
2320
+ .prepare("SELECT DISTINCT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND relay_peer_id IS NOT NULL")
2321
+ .all(this.#requireAgentId(agentName));
1916
2322
  const byPeer = new Map();
1917
2323
  for (const row of rows) {
1918
2324
  if (!row.relay_peer_id || !row.relay_addrs)
@@ -1962,9 +2368,9 @@ export class SessionNodeManager {
1962
2368
  try {
1963
2369
  this.#db
1964
2370
  .prepare(`INSERT INTO session_tree_leaves
1965
- (agent_name, session_id, leaf_index, leaf_kind, leaf_hash_hex, created_at)
2371
+ (agent_id, session_id, leaf_index, leaf_kind, leaf_hash_hex, created_at)
1966
2372
  VALUES (?, ?, ?, ?, ?, ?)`)
1967
- .run(agentName, sessionId, leafIndex, kind, leafHashHex, Date.now());
2373
+ .run(this.#requireAgentId(agentName), sessionId, leafIndex, kind, leafHashHex, Date.now());
1968
2374
  // DAEMON-004 (finding #2): keep sessions.message_count synced to the tree
1969
2375
  // size. message_count is the bilateral leafCount the seal flow signs over
1970
2376
  // (handleSealInterruptedFlow / the responder). If it diverged from the
@@ -1972,8 +2378,8 @@ export class SessionNodeManager {
1972
2378
  // truncated transcript and the bilateral leafCount check would mismatch.
1973
2379
  // The tree (leafIndex + 1 leaves) is authoritative; the column tracks it.
1974
2380
  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);
2381
+ .prepare("UPDATE sessions SET message_count = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
2382
+ .run(leafIndex + 1, Date.now(), this.#requireAgentId(agentName), sessionId);
1977
2383
  }
1978
2384
  catch (err) {
1979
2385
  // A persist failure must be visible, not swallowed: the in-memory tree
@@ -2463,10 +2869,15 @@ export class SessionNodeManager {
2463
2869
  // seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
2464
2870
  // content headed for rejection anyway); both gates are independent and either rejects on its
2465
2871
  // own criteria, so ordering between them does not change correctness.
2466
- if (!this.isContact(agentName, senderPubkey)) {
2872
+ {
2873
+ // DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
2874
+ // applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
2875
+ // "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
2876
+ const senderTier = this.getTier(agentName, senderPubkey);
2877
+ const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
2467
2878
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2468
2879
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2469
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
2880
+ if (priorTotal + heldTotal + content.length > cap) {
2470
2881
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2471
2882
  sessionId,
2472
2883
  agentName,
@@ -2474,7 +2885,8 @@ export class SessionNodeManager {
2474
2885
  priorTotal,
2475
2886
  heldTotal,
2476
2887
  incoming: content.length,
2477
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
2888
+ cap,
2889
+ tier: senderTier,
2478
2890
  correlationId,
2479
2891
  });
2480
2892
  return { ok: false, reason: "session_size_limit_exceeded" };
@@ -2579,10 +2991,16 @@ export class SessionNodeManager {
2579
2991
  // the append/hold branch is synchronous, so whichever call resumes first appends/holds before
2580
2992
  // the second's re-check runs, and the second's freshly-recomputed totals correctly include the
2581
2993
  // first's contribution.
2582
- if (!this.isContact(agentName, senderPubkey)) {
2994
+ {
2995
+ // DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
2996
+ // synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
2997
+ // sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
2998
+ // so a sender can never pass one and fail the other.
2999
+ const senderTier = this.getTier(agentName, senderPubkey);
3000
+ const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
2583
3001
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2584
3002
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2585
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
3003
+ if (priorTotal + heldTotal + content.length > cap) {
2586
3004
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2587
3005
  sessionId,
2588
3006
  agentName,
@@ -2590,7 +3008,8 @@ export class SessionNodeManager {
2590
3008
  priorTotal,
2591
3009
  heldTotal,
2592
3010
  incoming: content.length,
2593
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
3011
+ cap,
3012
+ tier: senderTier,
2594
3013
  correlationId,
2595
3014
  recheck: true,
2596
3015
  });
@@ -2803,8 +3222,8 @@ export class SessionNodeManager {
2803
3222
  if (!this.#db)
2804
3223
  return null;
2805
3224
  const row = this.#db
2806
- .prepare("SELECT sealed_root_hex FROM sessions WHERE agent_name = ? AND session_id = ?")
2807
- .get(agentName, sessionId);
3225
+ .prepare("SELECT sealed_root_hex FROM sessions WHERE agent_id = ? AND session_id = ?")
3226
+ .get(this.#requireAgentId(agentName), sessionId);
2808
3227
  return row?.sealed_root_hex ?? null;
2809
3228
  }
2810
3229
  // ─── CELLO-M7-MSG-001: delivery ACK / TTF tracking (send side) ──────────────
@@ -2973,8 +3392,8 @@ export class SessionNodeManager {
2973
3392
  if (!this.#db)
2974
3393
  return SessionTree.empty();
2975
3394
  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);
3395
+ .prepare("SELECT leaf_kind, leaf_hash_hex FROM session_tree_leaves WHERE agent_id = ? AND session_id = ? ORDER BY leaf_index ASC")
3396
+ .all(this.#requireAgentId(agentName), sessionId);
2978
3397
  return SessionTree.fromLeaves(rows.map((r) => ({ kind: r.leaf_kind === "ctrl" ? "ctrl" : "msg", hashHex: r.leaf_hash_hex })));
2979
3398
  }
2980
3399
  /**
@@ -3424,9 +3843,9 @@ export class SessionNodeManager {
3424
3843
  try {
3425
3844
  this.#db
3426
3845
  .prepare(`INSERT INTO sessions
3427
- (session_id, agent_name, counterparty_pubkey, status, created_at, updated_at)
3846
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
3428
3847
  VALUES (?, ?, ?, ?, ?, ?)`)
3429
- .run(sessionId, agentName, counterpartyPubkey, status, now, now);
3848
+ .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now);
3430
3849
  return true;
3431
3850
  }
3432
3851
  catch (err) {
@@ -3448,8 +3867,8 @@ export class SessionNodeManager {
3448
3867
  if (!this.#db)
3449
3868
  return 0;
3450
3869
  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);
3870
+ .prepare("SELECT COUNT(*) AS n FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
3871
+ .get(this.#requireAgentId(agentName), sessionId);
3453
3872
  return row.n;
3454
3873
  }
3455
3874
  /** CC-5/F21: unilaterally mark a session locally-terminal ("abandoned") — retire its live node and
@@ -3471,8 +3890,8 @@ export class SessionNodeManager {
3471
3890
  const now = Date.now();
3472
3891
  try {
3473
3892
  this.#db
3474
- .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_name = ? AND session_id = ?")
3475
- .run(status, now, agentName, sessionId);
3893
+ .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
3894
+ .run(status, now, this.#requireAgentId(agentName), sessionId);
3476
3895
  return true;
3477
3896
  }
3478
3897
  catch (err) {