@cello-protocol/daemon 0.0.45 → 0.0.47

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.
@@ -70,6 +70,8 @@ import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-
70
70
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
71
71
  import { ensureIdentitySchema } from "./db-identity-store.js";
72
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";
73
75
  import { randomUUID, createHash } from "node:crypto";
74
76
  import * as lp from "it-length-prefixed";
75
77
  import { decode, Encoder } from "cbor-x";
@@ -84,15 +86,18 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
84
86
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
85
87
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
86
88
  const CBOR_ENC = new Encoder({ tagUint8Array: false });
87
- // M8C-ABUSE-1: persistence bounds the non-M9 remainder (per-message cap + outbound rate are
88
- // M9's, not rebuilt here). "Whitelisted senders bounded only by disk" (DoD) these bounds apply
89
- // ONLY to sessions/senders that are NOT a known contact (CONTACT-1's whitelist).
90
- /** Anti-drip-feed: cumulative RECEIVED bytes per session, from a non-contact sender. */
91
- export const ABUSE_MAX_SESSION_RECEIVED_BYTES = 25 * 1024 * 1024; // 25 MB
92
- /** Anti-drip-feed via many sessions: active sessions a single unknown counterparty may hold open
93
- * with one agent at once. */
94
- export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = 3;
95
- /** 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. */
96
101
  export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
97
102
  // ─── SessionNodeManager ───────────────────────────────────────────────────────
98
103
  export class SessionNodeManager {
@@ -475,6 +480,11 @@ export class SessionNodeManager {
475
480
  // database it already exists here and is re-keyed in the same transaction; on a fresh one it is
476
481
  // absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
477
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);
478
488
  // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
479
489
  // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
480
490
  // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
@@ -487,6 +497,35 @@ export class SessionNodeManager {
487
497
  allowlisted_chat_id TEXT NOT NULL,
488
498
  updated_at INTEGER NOT NULL
489
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
+ )
490
529
  `);
491
530
  // Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
492
531
  // Any 'active' row in a freshly-started daemon is a remnant of a prior
@@ -658,6 +697,16 @@ export class SessionNodeManager {
658
697
  .run(this.#requireAgentId(agentName), sessionId, seq);
659
698
  this.#logger.info("message.watermark.advanced", { agentName, sessionId, sequence: seq });
660
699
  }
700
+ /**
701
+ * The ONE definition of "unread" in this daemon: a RECEIVED transcript row whose sequence is
702
+ * beyond the agent's persisted read watermark. A constant, not a copy-pasted string, so the
703
+ * INBOX unread count and the DOD-CURSOR-DURABLE-1 read-before-write gate can never drift into
704
+ * disagreeing about what "unread" means — the gate deciding one thing while the inbox shows
705
+ * another is precisely the bug this shape prevents. Interpolated SQL only (no user input).
706
+ */
707
+ static #UNREAD_RECEIVED_WHERE = `
708
+ t.direction = 'received'
709
+ AND t.sequence > COALESCE(w.last_delivered_seq, -1)`;
661
710
  /** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
662
711
  * messages beyond the read watermark. Content-free (counts + ids + last seq, never message text);
663
712
  * a COUNT/MAX query, no decrypt. Only sessions with unread_count > 0 are returned. */
@@ -672,14 +721,39 @@ export class SessionNodeManager {
672
721
  LEFT JOIN message_watermarks w
673
722
  ON w.agent_id = t.agent_id AND w.session_id = t.session_id
674
723
  WHERE t.agent_id = ?
675
- AND t.direction = 'received'
676
- AND t.sequence > COALESCE(w.last_delivered_seq, -1)
724
+ AND ${SessionNodeManager.#UNREAD_RECEIVED_WHERE}
677
725
  GROUP BY t.session_id
678
726
  HAVING unread_count > 0
679
727
  ORDER BY t.session_id ASC`)
680
728
  .all(this.#requireAgentId(agentName));
681
729
  return rows;
682
730
  }
731
+ /**
732
+ * DOD-CURSOR-DURABLE-1: how many RECEIVED messages in THIS session the agent has not read —
733
+ * the durable half of the read-before-write gate. Same predicate as getUnreadSummary (shared
734
+ * constant above), scoped to one session.
735
+ *
736
+ * This is DURABLE and PER-AGENT, where the send gate's other authority (the connection cursor) is
737
+ * in-memory and per-connection. It is what lets a stateless client — the `cello` CLI, one process
738
+ * per command — prove it has read the counterparty, which a dead socket's cursor never can.
739
+ *
740
+ * FAILS CLOSED: an uninitialized DB returns a positive count (treated as "unread"), never 0. A 0
741
+ * here unblocks a send; guessing 0 from a broken DB would silently defeat the gate.
742
+ */
743
+ getUnreadReceivedCount(agentName, sessionId) {
744
+ if (!this.#db)
745
+ return 1; // fail closed — never unblock a send because the DB is unavailable
746
+ const row = this.#db
747
+ .prepare(`SELECT COUNT(*) AS unread_count
748
+ FROM transcript t
749
+ LEFT JOIN message_watermarks w
750
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
751
+ WHERE t.agent_id = ?
752
+ AND t.session_id = ?
753
+ AND ${SessionNodeManager.#UNREAD_RECEIVED_WHERE}`)
754
+ .get(this.#requireAgentId(agentName), sessionId);
755
+ return row ? row.unread_count : 0;
756
+ }
683
757
  /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
684
758
  isContact(agentName, pubkey) {
685
759
  if (!this.#db)
@@ -687,12 +761,81 @@ export class SessionNodeManager {
687
761
  const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
688
762
  return row !== undefined;
689
763
  }
764
+ /** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
765
+ * absent contact row (undefined), a NULL `tier`, or a corrupt out-of-range value all resolve to
766
+ * UNKNOWN via `normalizeTier`, so the return is always in 0..4 and guards the JS `null >= 0`/`0 ||
767
+ * 1`/`grid[99]` traps. It is a SECURITY read (Step 2 gates inbound bounds on it), so it FAILS
768
+ * CLOSED, never open: an uninitialized DB throws (same contract as addContact) rather than
769
+ * silently returning UNKNOWN and admitting a BLOCKED sender; an unresolvable/retired agent name
770
+ * throws via #requireAgentId. Both are invariant violations a caller must surface, not swallow. */
771
+ getTier(agentName, pubkey) {
772
+ // Fail CLOSED: a read that decides whether to admit a sender must not degrade to "unclassified"
773
+ // when it cannot reach the ACL — that would admit a blocked contact. Throw as addContact does.
774
+ if (!this.#db)
775
+ throw new Error(`getTier('${agentName}'): database not initialized`);
776
+ const row = this.#db
777
+ .prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
778
+ .get(this.#requireAgentId(agentName), pubkey);
779
+ if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
780
+ // A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
781
+ // tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
782
+ this.#logger.warn("contact.tier.corrupt", { agentName, pubkey, storedTier: row.tier });
783
+ }
784
+ return normalizeTier(row?.tier);
785
+ }
786
+ /** DOD-TIER-4: the DISPLAY/relationship check — is this counterparty a genuine contact (KNOWN or
787
+ * above)? Replaces the old binary `isContact` for behaviour that keyed on "we have a relationship"
788
+ * (e.g. the away-response wording). An UNKNOWN-tier contact (a mere row) is NOT known. */
789
+ isKnown(agentName, pubkey) {
790
+ return this.getTier(agentName, pubkey) >= TIER.KNOWN;
791
+ }
792
+ /** DOD-TIER-4: the POLICY gate — may an inbound session from this counterparty be auto-accepted
793
+ * when the operator is unattended (WHITELISTED or VIP)? The behavioural consumer is the offline
794
+ * relay mailbox (LEAVEMSG-1), out of scope for this unit; defined here as the seam. Being merely
795
+ * KNOWN is NOT enough to auto-accept — whitelisting is the deliberate `cello_contact_set_tier` act. */
796
+ isAutoAccept(agentName, pubkey) {
797
+ return this.getTier(agentName, pubkey) >= TIER.WHITELISTED;
798
+ }
799
+ /** DOD-TIER-BOUNDS-SETTINGS: the effective bound for (agent, tier, field) — a per-agent SETTINGS
800
+ * override if one is set and valid, else the hardcoded grid default (DEFAULT_TIER_BOUNDS). With no
801
+ * settings this is byte-identical to Step 2 (the daemon runs on defaults alone). A stored value
802
+ * that is somehow non-positive/non-finite (should be impossible — validated at SET time) falls back
803
+ * to the grid default rather than removing the bound (INV-TIER-BOUND, defensive). BLOCKED is never
804
+ * settable — it always returns the fixed grid value (0). */
805
+ resolveTierBound(agentName, tier, field) {
806
+ const gridDefault = field === "max_sessions"
807
+ ? tierBoundsFor(tier).maxSessionsPerSender
808
+ : tierBoundsFor(tier).maxBytesPerSession;
809
+ const name = settableTierName(tier);
810
+ if (name === null)
811
+ return gridDefault; // BLOCKED or out-of-range — fixed, not overridable
812
+ const raw = this.getSetting(agentName, boundSettingKey(name, field));
813
+ if (raw === null)
814
+ return gridDefault; // unset → default
815
+ const parsed = Number(raw);
816
+ if (!Number.isFinite(parsed) || parsed <= 0) {
817
+ // Should be impossible (validated at SET time) → a config-integrity failure. Surface it: this
818
+ // reverts a possibly-TIGHTENED bound to the looser default, so a silent revert would hide a real
819
+ // problem. Still fail SAFE (grid default, never unbounded — INV-TIER-BOUND).
820
+ this.#logger.warn("settings.bound.corrupt", { agentName, tier, field, raw });
821
+ return gridDefault;
822
+ }
823
+ return parsed;
824
+ }
690
825
  /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
691
826
  * no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
692
827
  * optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
693
828
  * THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
694
- * backstop (same contract as DbIdentityStore.setMoniker). */
695
- addContact(agentName, pubkey, moniker) {
829
+ * backstop (same contract as DbIdentityStore.setMoniker).
830
+ *
831
+ * DOD-TIER-1/4: a NEW row is stamped `tier` (never NULL) and an optional `provenance`
832
+ * ('accepted' | 'initiated' | null). The `tier` defaults to the least-privilege UNKNOWN floor —
833
+ * a caller GRANTS trust by passing a higher tier explicitly. Every production creation path is a
834
+ * deliberate operator action and passes KNOWN (initiate, engage/reply, explicit cello_contact_add
835
+ * — DEC-AB-1). INSERT OR IGNORE means an EXISTING contact is untouched — tier and provenance pin
836
+ * at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
837
+ * the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
838
+ addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
696
839
  if (!pubkey)
697
840
  return;
698
841
  // Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
@@ -702,10 +845,17 @@ export class SessionNodeManager {
702
845
  if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
703
846
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
704
847
  }
848
+ // DOD-TIER-4 (review F3): the stored tier must be a known 0..4 constant — a can-never-be-stored
849
+ // backstop mirroring the moniker validation above. All callers pass a TIER constant; this catches
850
+ // a future caller (or a bad refactor) that would otherwise persist a corrupt tier the read side
851
+ // must then defensively normalize.
852
+ if (!isKnownTierValue(tier)) {
853
+ throw new Error(`invalid contact tier for agent '${agentName}': ${tier} (must be 0..4)`);
854
+ }
705
855
  const agentId = this.#requireAgentId(agentName);
706
856
  this.#db
707
- .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at) VALUES (?, ?, ?)")
708
- .run(agentId, pubkey, Date.now());
857
+ .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
858
+ .run(agentId, pubkey, Date.now(), tier, provenance ?? null);
709
859
  if (moniker !== undefined && moniker !== null) {
710
860
  this.#db
711
861
  .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
@@ -726,13 +876,128 @@ export class SessionNodeManager {
726
876
  const res = this.#db
727
877
  .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
728
878
  .run(moniker, this.#requireAgentId(agentName), pubkey);
879
+ // DOD-RENAME-1: setting the local pet name IS the operator acting on a rename — resolve any
880
+ // pending notice for this contact (whether they adopted the offered name or chose their own).
881
+ if (res.changes > 0)
882
+ this.clearRenameNotice(agentName, pubkey);
883
+ return res.changes > 0;
884
+ }
885
+ /** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
886
+ * when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
887
+ setContactAwayMessage(agentName, pubkey, message) {
888
+ if (!this.#db)
889
+ throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
890
+ const res = this.#db
891
+ .prepare("UPDATE contacts SET away_message = ? WHERE agent_id = ? AND pubkey = ?")
892
+ .run(message, this.#requireAgentId(agentName), pubkey);
893
+ return res.changes > 0;
894
+ }
895
+ /** DOD-AWAY-TIER-1: resolve the most-specific CUSTOM away text for a counterparty, most-specific
896
+ * first: per-contact `away_message` → per-tier away setting → agent default away setting. Returns
897
+ * null when none is configured, so the CALLER applies the system default (code) — making the full
898
+ * four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
899
+ * the caller like any content (SI — it does not bypass the gateway). */
900
+ resolveAwayMessage(agentName, pubkey) {
901
+ if (!this.#db)
902
+ return null;
903
+ const agentId = this.#requireAgentId(agentName);
904
+ const row = this.#db
905
+ .prepare("SELECT away_message FROM contacts WHERE agent_id = ? AND pubkey = ?")
906
+ .get(agentId, pubkey);
907
+ if (row?.away_message != null) {
908
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "contact" }); // obs AC
909
+ return row.away_message; // 1. per-contact
910
+ }
911
+ const tierName = settableTierName(this.getTier(agentName, pubkey));
912
+ if (tierName !== null) {
913
+ const tierAway = this.getSetting(agentName, awayTierSettingKey(tierName));
914
+ if (tierAway !== null) {
915
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "tier" });
916
+ return tierAway; // 2. per-tier
917
+ }
918
+ }
919
+ const agentDefault = this.getSetting(agentName, AWAY_DEFAULT_KEY);
920
+ // 3. agent default, else null → caller applies the system default (code). Level logged HERE.
921
+ this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: agentDefault !== null ? "agent_default" : "system" });
922
+ return agentDefault;
923
+ }
924
+ /** DOD-CONTACT-VIEW-1: set an EXISTING contact's reachability tier. Returns false when no such
925
+ * contact — fail-loud at the caller, never a silent no-op success (same contract as
926
+ * setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
927
+ * stores whatever it is handed (the handler is the validation boundary). */
928
+ setContactTier(agentName, pubkey, tier) {
929
+ if (!this.#db)
930
+ throw new Error(`setContactTier('${agentName}'): database not initialized`);
931
+ const res = this.#db
932
+ .prepare("UPDATE contacts SET tier = ? WHERE agent_id = ? AND pubkey = ?")
933
+ .run(tier, this.#requireAgentId(agentName), pubkey);
729
934
  return res.changes > 0;
730
935
  }
936
+ /** DOD-RENAME-1 (Option C): record a self-declared name a peer offered, at the moment the offer is
937
+ * SEEN. The stored local pet name (contacts.moniker) is SACROSANCT — this only ever touches
938
+ * last_offered_moniker and the notice queue, never the moniker (AC2). A rename NOTICE is queued
939
+ * only when the peer is a contact the operator has PERSONALLY NAMED (moniker non-null), a name was
940
+ * seen BEFORE (last_offered_moniker non-null), and the new offer DIFFERS (AC3). The first-ever
941
+ * offer just records the baseline (no notice); a repeat of the same name is idempotent (AC4).
942
+ * Called only when a moniker WAS offered (caller-guarded), so silence never clears the baseline
943
+ * (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
944
+ * detection works only for peers who INITIATE to you — a property, not a bug. */
945
+ recordOfferedMoniker(agentName, pubkey, offered) {
946
+ // Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
947
+ // (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
948
+ if (!this.#db)
949
+ throw new Error(`recordOfferedMoniker('${agentName}'): database not initialized`);
950
+ const agentId = this.#requireAgentId(agentName);
951
+ const row = this.#db
952
+ .prepare("SELECT last_offered_moniker, moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
953
+ .get(agentId, pubkey);
954
+ if (!row)
955
+ return; // not a contact — no row to hold a baseline or a notice
956
+ if (offered === row.last_offered_moniker)
957
+ return; // idempotent — same name already seen (AC4)
958
+ // A genuine change from a previously-seen name, for a contact the operator has named → notice.
959
+ if (row.last_offered_moniker !== null && row.moniker !== null) {
960
+ this.#db
961
+ .prepare("INSERT OR REPLACE INTO contact_rename_notices (agent_id, pubkey, offered_name, noticed_at) VALUES (?, ?, ?, ?)")
962
+ .run(agentId, pubkey, offered, Date.now());
963
+ // Observability: log the FACT, never the attacker-chosen name (same rule as moniker.rejected).
964
+ this.#logger.info("contact.rename.noticed", { agentName, pubkey });
965
+ }
966
+ this.#db
967
+ .prepare("UPDATE contacts SET last_offered_moniker = ? WHERE agent_id = ? AND pubkey = ?")
968
+ .run(offered, agentId, pubkey);
969
+ }
970
+ /** DOD-RENAME-1: pending rename notices for an agent, oldest first (surfaced in
971
+ * cello_check_notifications — an INBOX pull, never a real-time push). */
972
+ getRenameNotices(agentName) {
973
+ if (!this.#db)
974
+ return [];
975
+ // JOIN the local pet name so the notice can NAME the contact (AC3) — a notice only ever fires for
976
+ // a personally-named contact, so moniker is expected non-null (LEFT JOIN is defensive).
977
+ return this.#db
978
+ .prepare(`SELECT n.pubkey, n.offered_name, n.noticed_at, c.moniker
979
+ FROM contact_rename_notices n
980
+ LEFT JOIN contacts c ON c.agent_id = n.agent_id AND c.pubkey = n.pubkey
981
+ WHERE n.agent_id = ? ORDER BY n.noticed_at ASC`)
982
+ .all(this.#requireAgentId(agentName));
983
+ }
984
+ /** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
985
+ * contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
986
+ clearRenameNotice(agentName, pubkey) {
987
+ if (!this.#db)
988
+ throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
989
+ this.#db
990
+ .prepare("DELETE FROM contact_rename_notices WHERE agent_id = ? AND pubkey = ?")
991
+ .run(this.#requireAgentId(agentName), pubkey);
992
+ }
731
993
  /** M8C-CONTACT-1: known stays known until explicitly removed. */
732
994
  removeContact(agentName, pubkey) {
733
995
  if (!this.#db)
734
996
  return false;
735
997
  const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
998
+ // DOD-RENAME-1: a removed contact has no pending rename to resolve.
999
+ if (res.changes > 0)
1000
+ this.clearRenameNotice(agentName, pubkey);
736
1001
  return res.changes > 0;
737
1002
  }
738
1003
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
@@ -749,13 +1014,23 @@ export class SessionNodeManager {
749
1014
  .get(this.#requireAgentId(agentName), pubkey);
750
1015
  return row?.moniker ?? null;
751
1016
  }
752
- /** M8C-CONTACT-1: list an agent's known contacts, oldest-added first. MONIKER-3 AC3: includes
753
- * the pet name (null when none set). */
1017
+ /** M8C-CONTACT-1 + DOD-CONTACT-VIEW-1: list an agent's contacts, oldest-added first, each with its
1018
+ * pet name (MONIKER-3), tier + provenance (the address-book metadata), and a READ-side LEFT JOIN
1019
+ * against `sessions` for how many SEALED sessions were shared and when they last spoke (MAX
1020
+ * updated_at). No new stored data — a pure read. A contact with no sessions shows 0 / null (never),
1021
+ * not an error. The JOIN is scoped by agent_id so one agent's sessions never bleed into another's. */
754
1022
  listContacts(agentName) {
755
1023
  if (!this.#db)
756
1024
  return [];
757
1025
  return this.#db
758
- .prepare("SELECT pubkey, added_at, moniker FROM contacts WHERE agent_id = ? ORDER BY added_at ASC")
1026
+ .prepare(`SELECT c.pubkey, c.added_at, c.moniker, c.tier, c.provenance,
1027
+ COUNT(CASE WHEN s.status = 'sealed' THEN 1 END) AS sealed_count,
1028
+ MAX(s.updated_at) AS last_spoke
1029
+ FROM contacts c
1030
+ LEFT JOIN sessions s ON s.agent_id = c.agent_id AND s.counterparty_pubkey = c.pubkey
1031
+ WHERE c.agent_id = ?
1032
+ GROUP BY c.pubkey, c.added_at, c.moniker, c.tier, c.provenance
1033
+ ORDER BY c.added_at ASC`)
759
1034
  .all(this.#requireAgentId(agentName));
760
1035
  }
761
1036
  /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
@@ -795,8 +1070,12 @@ export class SessionNodeManager {
795
1070
  .get(this.#requireAgentId(agentName), counterpartyPubkey);
796
1071
  return row.n;
797
1072
  }
798
- /** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with counterparties that are
799
- * NOT a known contact — the global cap counts across ALL unknown senders combined. Same
1073
+ /** M8C-ABUSE-1 (anti-swarm) + DOD-TIER-2: non-terminal sessions this agent holds with UNKNOWN-tier
1074
+ * counterparties — the global cap counts across the whole stranger pool. A sender is exempt from
1075
+ * THIS pool iff it is a KNOWN+ contact (tier >= KNOWN); a bare stranger (no row → UNKNOWN) or an
1076
+ * explicitly UNKNOWN-tier contact both count. Keying on `tier >= KNOWN` (bounded to <= VIP so a
1077
+ * corrupt high value cannot grant pool-exemption) replaces the old row-existence proxy, which
1078
+ * would have let a merely-recorded UNKNOWN contact escape the anti-swarm cap. Same
800
1079
  * 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
801
1080
  countActiveSessionsFromUnknownSenders(agentName) {
802
1081
  if (!this.#db)
@@ -804,23 +1083,36 @@ export class SessionNodeManager {
804
1083
  const row = this.#db
805
1084
  .prepare(`SELECT COUNT(*) AS n FROM sessions s
806
1085
  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)`)
1086
+ AND NOT EXISTS (
1087
+ SELECT 1 FROM contacts c
1088
+ WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
1089
+ AND c.tier >= ${TIER.KNOWN} AND c.tier <= ${TIER.VIP}
1090
+ )`)
808
1091
  .get(this.#requireAgentId(agentName));
809
1092
  return row.n;
810
1093
  }
811
- /** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the acceptance bounds?
812
- * Known contacts are exempt entirely ("bounded only by disk" DoD). Checked BEFORE accepting
813
- * a fresh inbound session (the counts reflect sessions already active, not yet counting this one). */
1094
+ /** M8C-ABUSE-1 + DOD-TIER-2/3: is a NEW inbound session from this counterparty within the
1095
+ * acceptance bounds? The per-sender cap is now the sender's TIER cap (DEFAULT_TIER_BOUNDS), not a
1096
+ * flat "3 for strangers, unbounded for contacts". This is where DOD-TIER-3 falls out for free: a
1097
+ * BLOCKED sender's cap is 0, so `perSender (>= 0) >= 0` refuses it through the SAME reason and the
1098
+ * SAME path an over-cap UNKNOWN takes — no separate blocked branch, no distinguishing oracle. The
1099
+ * global anti-swarm cap then applies ONLY to UNKNOWN-tier senders (KNOWN+ are trusted, not part of
1100
+ * the stranger pool; BLOCKED never reaches it). Checked BEFORE accepting a fresh inbound session
1101
+ * (counts reflect sessions already active, not yet counting this one). */
814
1102
  checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
815
- if (this.isContact(agentName, counterpartyPubkey))
816
- return { ok: true };
1103
+ const tier = this.getTier(agentName, counterpartyPubkey);
1104
+ const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
817
1105
  const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
818
- if (perSender >= ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER) {
1106
+ if (perSender >= perSenderCap) {
819
1107
  return { ok: false, reason: "abuse_bound_sessions_per_sender" };
820
1108
  }
821
- const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
822
- if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
823
- return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
1109
+ // The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
1110
+ // a BLOCKED sender was already refused above (cap 0).
1111
+ if (tier === TIER.UNKNOWN) {
1112
+ const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
1113
+ if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
1114
+ return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
1115
+ }
824
1116
  }
825
1117
  return { ok: true };
826
1118
  }
@@ -842,6 +1134,42 @@ export class SessionNodeManager {
842
1134
  ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
843
1135
  .run(botToken, allowlistedChatId, Date.now());
844
1136
  }
1137
+ /** DOD-SETTINGS-1: read a per-agent setting, or null if unset. The get-with-default is the CALLER's
1138
+ * job (an unset key falls back to the hardcoded grid/system default — the daemon runs correctly on
1139
+ * defaults alone, AC3). Returns null on a missing DB (settings are always optional). */
1140
+ getSetting(agentName, key) {
1141
+ if (!this.#db)
1142
+ return null;
1143
+ const row = this.#db
1144
+ .prepare("SELECT value FROM agent_settings WHERE agent_id = ? AND key = ?")
1145
+ .get(this.#requireAgentId(agentName), key);
1146
+ return row?.value ?? null;
1147
+ }
1148
+ /** DOD-SETTINGS-1: write a per-agent setting (upsert). Key VALIDATION is the handler's boundary
1149
+ * (isValidSettingKey); value validation for typed settings (finite bounds, etc.) belongs to the
1150
+ * specific consumer. Throws on a missing DB — a write that silently no-ops would be a lie. */
1151
+ setSetting(agentName, key, value) {
1152
+ if (!this.#db)
1153
+ throw new Error(`setSetting('${agentName}'): database not initialized`);
1154
+ // Store-level backstop (review F2): the handler validates the key, but the dual-layer convention
1155
+ // (cf. MONIKER-1) means an unknown key can NEVER be stored — an internal caller that hand-typed a
1156
+ // key instead of using the builders would otherwise persist a setting that never takes effect.
1157
+ if (!isValidSettingKey(key))
1158
+ throw new Error(`invalid_key: '${key}' is not a known setting`);
1159
+ this.#db
1160
+ .prepare(`INSERT INTO agent_settings (agent_id, key, value, updated_at) VALUES (?, ?, ?, ?)
1161
+ ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
1162
+ .run(this.#requireAgentId(agentName), key, value, Date.now());
1163
+ }
1164
+ /** DOD-SETTINGS-1: all explicitly-set settings for an agent (the ones that OVERRIDE a default),
1165
+ * key-sorted. Unset keys are absent — the operator sees only what they changed. */
1166
+ getAllSettings(agentName) {
1167
+ if (!this.#db)
1168
+ return [];
1169
+ return this.#db
1170
+ .prepare("SELECT key, value FROM agent_settings WHERE agent_id = ? ORDER BY key ASC")
1171
+ .all(this.#requireAgentId(agentName));
1172
+ }
845
1173
  /** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
846
1174
  getStandingReceiverReady(agentName) {
847
1175
  if (agentName !== undefined)
@@ -2576,10 +2904,15 @@ export class SessionNodeManager {
2576
2904
  // seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
2577
2905
  // content headed for rejection anyway); both gates are independent and either rejects on its
2578
2906
  // own criteria, so ordering between them does not change correctness.
2579
- if (!this.isContact(agentName, senderPubkey)) {
2907
+ {
2908
+ // DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
2909
+ // applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
2910
+ // "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
2911
+ const senderTier = this.getTier(agentName, senderPubkey);
2912
+ const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
2580
2913
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2581
2914
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2582
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
2915
+ if (priorTotal + heldTotal + content.length > cap) {
2583
2916
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2584
2917
  sessionId,
2585
2918
  agentName,
@@ -2587,7 +2920,8 @@ export class SessionNodeManager {
2587
2920
  priorTotal,
2588
2921
  heldTotal,
2589
2922
  incoming: content.length,
2590
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
2923
+ cap,
2924
+ tier: senderTier,
2591
2925
  correlationId,
2592
2926
  });
2593
2927
  return { ok: false, reason: "session_size_limit_exceeded" };
@@ -2692,10 +3026,16 @@ export class SessionNodeManager {
2692
3026
  // the append/hold branch is synchronous, so whichever call resumes first appends/holds before
2693
3027
  // the second's re-check runs, and the second's freshly-recomputed totals correctly include the
2694
3028
  // first's contribution.
2695
- if (!this.isContact(agentName, senderPubkey)) {
3029
+ {
3030
+ // DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
3031
+ // synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
3032
+ // sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
3033
+ // so a sender can never pass one and fail the other.
3034
+ const senderTier = this.getTier(agentName, senderPubkey);
3035
+ const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
2696
3036
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2697
3037
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2698
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
3038
+ if (priorTotal + heldTotal + content.length > cap) {
2699
3039
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2700
3040
  sessionId,
2701
3041
  agentName,
@@ -2703,7 +3043,8 @@ export class SessionNodeManager {
2703
3043
  priorTotal,
2704
3044
  heldTotal,
2705
3045
  incoming: content.length,
2706
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
3046
+ cap,
3047
+ tier: senderTier,
2707
3048
  correlationId,
2708
3049
  recheck: true,
2709
3050
  });