@cello-protocol/daemon 0.0.45 → 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.
@@ -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
@@ -687,12 +726,81 @@ export class SessionNodeManager {
687
726
  const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
688
727
  return row !== undefined;
689
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
+ }
690
790
  /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
691
791
  * no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
692
792
  * optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
693
793
  * 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) {
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) {
696
804
  if (!pubkey)
697
805
  return;
698
806
  // Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
@@ -702,10 +810,17 @@ export class SessionNodeManager {
702
810
  if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
703
811
  throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
704
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
+ }
705
820
  const agentId = this.#requireAgentId(agentName);
706
821
  this.#db
707
- .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at) VALUES (?, ?, ?)")
708
- .run(agentId, 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);
709
824
  if (moniker !== undefined && moniker !== null) {
710
825
  this.#db
711
826
  .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
@@ -726,13 +841,128 @@ export class SessionNodeManager {
726
841
  const res = this.#db
727
842
  .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
728
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);
858
+ return res.changes > 0;
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);
729
899
  return res.changes > 0;
730
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
+ }
731
958
  /** M8C-CONTACT-1: known stays known until explicitly removed. */
732
959
  removeContact(agentName, pubkey) {
733
960
  if (!this.#db)
734
961
  return false;
735
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);
736
966
  return res.changes > 0;
737
967
  }
738
968
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
@@ -749,13 +979,23 @@ export class SessionNodeManager {
749
979
  .get(this.#requireAgentId(agentName), pubkey);
750
980
  return row?.moniker ?? null;
751
981
  }
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). */
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. */
754
987
  listContacts(agentName) {
755
988
  if (!this.#db)
756
989
  return [];
757
990
  return this.#db
758
- .prepare("SELECT pubkey, added_at, moniker FROM contacts WHERE agent_id = ? ORDER BY added_at ASC")
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`)
759
999
  .all(this.#requireAgentId(agentName));
760
1000
  }
761
1001
  /** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
@@ -795,8 +1035,12 @@ export class SessionNodeManager {
795
1035
  .get(this.#requireAgentId(agentName), counterpartyPubkey);
796
1036
  return row.n;
797
1037
  }
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
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
800
1044
  * 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
801
1045
  countActiveSessionsFromUnknownSenders(agentName) {
802
1046
  if (!this.#db)
@@ -804,23 +1048,36 @@ export class SessionNodeManager {
804
1048
  const row = this.#db
805
1049
  .prepare(`SELECT COUNT(*) AS n FROM sessions s
806
1050
  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)`)
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
+ )`)
808
1056
  .get(this.#requireAgentId(agentName));
809
1057
  return row.n;
810
1058
  }
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). */
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). */
814
1067
  checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
815
- if (this.isContact(agentName, counterpartyPubkey))
816
- return { ok: true };
1068
+ const tier = this.getTier(agentName, counterpartyPubkey);
1069
+ const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
817
1070
  const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
818
- if (perSender >= ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER) {
1071
+ if (perSender >= perSenderCap) {
819
1072
  return { ok: false, reason: "abuse_bound_sessions_per_sender" };
820
1073
  }
821
- const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
822
- if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
823
- 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
+ }
824
1081
  }
825
1082
  return { ok: true };
826
1083
  }
@@ -842,6 +1099,42 @@ export class SessionNodeManager {
842
1099
  ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
843
1100
  .run(botToken, allowlistedChatId, Date.now());
844
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
+ }
845
1138
  /** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
846
1139
  getStandingReceiverReady(agentName) {
847
1140
  if (agentName !== undefined)
@@ -2576,10 +2869,15 @@ export class SessionNodeManager {
2576
2869
  // seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
2577
2870
  // content headed for rejection anyway); both gates are independent and either rejects on its
2578
2871
  // own criteria, so ordering between them does not change correctness.
2579
- 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");
2580
2878
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2581
2879
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2582
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
2880
+ if (priorTotal + heldTotal + content.length > cap) {
2583
2881
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2584
2882
  sessionId,
2585
2883
  agentName,
@@ -2587,7 +2885,8 @@ export class SessionNodeManager {
2587
2885
  priorTotal,
2588
2886
  heldTotal,
2589
2887
  incoming: content.length,
2590
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
2888
+ cap,
2889
+ tier: senderTier,
2591
2890
  correlationId,
2592
2891
  });
2593
2892
  return { ok: false, reason: "session_size_limit_exceeded" };
@@ -2692,10 +2991,16 @@ export class SessionNodeManager {
2692
2991
  // the append/hold branch is synchronous, so whichever call resumes first appends/holds before
2693
2992
  // the second's re-check runs, and the second's freshly-recomputed totals correctly include the
2694
2993
  // first's contribution.
2695
- 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");
2696
3001
  const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
2697
3002
  const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
2698
- if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
3003
+ if (priorTotal + heldTotal + content.length > cap) {
2699
3004
  this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
2700
3005
  sessionId,
2701
3006
  agentName,
@@ -2703,7 +3008,8 @@ export class SessionNodeManager {
2703
3008
  priorTotal,
2704
3009
  heldTotal,
2705
3010
  incoming: content.length,
2706
- cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
3011
+ cap,
3012
+ tier: senderTier,
2707
3013
  correlationId,
2708
3014
  recheck: true,
2709
3015
  });