@cello-protocol/daemon 0.0.194 → 0.0.195

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.
Files changed (103) hide show
  1. package/dist/assignment-verify.d.ts +39 -11
  2. package/dist/assignment-verify.d.ts.map +1 -1
  3. package/dist/assignment-verify.js +133 -17
  4. package/dist/assignment-verify.js.map +1 -1
  5. package/dist/authorship-verification.d.ts +70 -0
  6. package/dist/authorship-verification.d.ts.map +1 -0
  7. package/dist/authorship-verification.js +441 -0
  8. package/dist/authorship-verification.js.map +1 -0
  9. package/dist/db-identity-store.d.ts +1 -0
  10. package/dist/db-identity-store.d.ts.map +1 -1
  11. package/dist/db-identity-store.js +19 -1
  12. package/dist/db-identity-store.js.map +1 -1
  13. package/dist/held-content.d.ts +141 -0
  14. package/dist/held-content.d.ts.map +1 -0
  15. package/dist/held-content.js +388 -0
  16. package/dist/held-content.js.map +1 -0
  17. package/dist/inbound-refusals.d.ts +283 -0
  18. package/dist/inbound-refusals.d.ts.map +1 -0
  19. package/dist/inbound-refusals.js +919 -0
  20. package/dist/inbound-refusals.js.map +1 -0
  21. package/dist/inbound-sessions.d.ts.map +1 -1
  22. package/dist/inbound-sessions.js +65 -27
  23. package/dist/inbound-sessions.js.map +1 -1
  24. package/dist/initiate-session-handler.d.ts.map +1 -1
  25. package/dist/initiate-session-handler.js +63 -0
  26. package/dist/initiate-session-handler.js.map +1 -1
  27. package/dist/outbound-sessions.d.ts.map +1 -1
  28. package/dist/outbound-sessions.js +108 -8
  29. package/dist/outbound-sessions.js.map +1 -1
  30. package/dist/park-recovery.d.ts +205 -0
  31. package/dist/park-recovery.d.ts.map +1 -0
  32. package/dist/park-recovery.js +600 -0
  33. package/dist/park-recovery.js.map +1 -0
  34. package/dist/refusal-notices.d.ts +196 -0
  35. package/dist/refusal-notices.d.ts.map +1 -0
  36. package/dist/refusal-notices.js +496 -0
  37. package/dist/refusal-notices.js.map +1 -0
  38. package/dist/refusal-reasons.d.ts +11 -0
  39. package/dist/refusal-reasons.d.ts.map +1 -1
  40. package/dist/refusal-reasons.js +18 -0
  41. package/dist/refusal-reasons.js.map +1 -1
  42. package/dist/registration-manager.d.ts.map +1 -1
  43. package/dist/registration-manager.js +126 -5
  44. package/dist/registration-manager.js.map +1 -1
  45. package/dist/registration-persistence.d.ts +18 -0
  46. package/dist/registration-persistence.d.ts.map +1 -1
  47. package/dist/registration-persistence.js +6 -0
  48. package/dist/registration-persistence.js.map +1 -1
  49. package/dist/session-assignment-parser.d.ts.map +1 -1
  50. package/dist/session-assignment-parser.js +23 -1
  51. package/dist/session-assignment-parser.js.map +1 -1
  52. package/dist/session-ceremony.d.ts +12 -7
  53. package/dist/session-ceremony.d.ts.map +1 -1
  54. package/dist/session-ceremony.js +12 -7
  55. package/dist/session-ceremony.js.map +1 -1
  56. package/dist/session-ephemerals.d.ts +271 -0
  57. package/dist/session-ephemerals.d.ts.map +1 -0
  58. package/dist/session-ephemerals.js +546 -0
  59. package/dist/session-ephemerals.js.map +1 -0
  60. package/dist/session-leaf-records.d.ts +132 -0
  61. package/dist/session-leaf-records.d.ts.map +1 -0
  62. package/dist/session-leaf-records.js +320 -0
  63. package/dist/session-leaf-records.js.map +1 -0
  64. package/dist/session-liveness.d.ts +135 -0
  65. package/dist/session-liveness.d.ts.map +1 -0
  66. package/dist/session-liveness.js +347 -0
  67. package/dist/session-liveness.js.map +1 -0
  68. package/dist/session-node-manager.d.ts +300 -1670
  69. package/dist/session-node-manager.d.ts.map +1 -1
  70. package/dist/session-node-manager.js +871 -9951
  71. package/dist/session-node-manager.js.map +1 -1
  72. package/dist/session-node-types.d.ts +933 -0
  73. package/dist/session-node-types.d.ts.map +1 -0
  74. package/dist/session-node-types.js +576 -0
  75. package/dist/session-node-types.js.map +1 -0
  76. package/dist/session-queries.d.ts +476 -0
  77. package/dist/session-queries.d.ts.map +1 -0
  78. package/dist/session-queries.js +1006 -0
  79. package/dist/session-queries.js.map +1 -0
  80. package/dist/session-records.d.ts +320 -0
  81. package/dist/session-records.d.ts.map +1 -0
  82. package/dist/session-records.js +792 -0
  83. package/dist/session-records.js.map +1 -0
  84. package/dist/session-salts.d.ts +390 -0
  85. package/dist/session-salts.d.ts.map +1 -0
  86. package/dist/session-salts.js +1457 -0
  87. package/dist/session-salts.js.map +1 -0
  88. package/dist/session-schema.d.ts +30 -0
  89. package/dist/session-schema.d.ts.map +1 -0
  90. package/dist/session-schema.js +786 -0
  91. package/dist/session-schema.js.map +1 -0
  92. package/dist/standing-receivers.d.ts +280 -0
  93. package/dist/standing-receivers.d.ts.map +1 -0
  94. package/dist/standing-receivers.js +1100 -0
  95. package/dist/standing-receivers.js.map +1 -0
  96. package/dist/transport-selector.d.ts +13 -0
  97. package/dist/transport-selector.d.ts.map +1 -1
  98. package/dist/transport-selector.js.map +1 -1
  99. package/dist/witness-alerts.d.ts +40 -0
  100. package/dist/witness-alerts.d.ts.map +1 -0
  101. package/dist/witness-alerts.js +102 -0
  102. package/dist/witness-alerts.js.map +1 -0
  103. package/package.json +5 -5
@@ -0,0 +1,792 @@
1
+ import { normalizeContactPubkey } from "./contact-pubkey-case.js";
2
+ import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor } from "./contacts-tier-migration.js";
3
+ import { boundSettingKey, settableTierName, isValidSettingKey } from "./agent-settings-keys.js";
4
+ import { MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
5
+ import { UNREAD_RECEIVED_WHERE, TERMINAL_STATUSES } from "./session-node-types.js";
6
+ import { quarantineRedaction } from "./quarantine-framing.js";
7
+ export class SessionRecords {
8
+ #ctx;
9
+ constructor(ctx) {
10
+ this.#ctx = ctx;
11
+ }
12
+ /** See `SessionRecordsContext.db` — a getter so the moved queries are unchanged. */
13
+ get #db() {
14
+ return this.#ctx.db();
15
+ }
16
+ #diverged = new Set();
17
+ /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
18
+ isContact(agentName, pubkey) {
19
+ if (!this.#db)
20
+ return false;
21
+ const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#ctx.requireAgentId(agentName), normalizeContactPubkey(pubkey));
22
+ return row !== undefined;
23
+ }
24
+ /** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
25
+ * absent contact row (undefined), a NULL `tier`, or a corrupt out-of-range value all resolve to
26
+ * UNKNOWN via `normalizeTier`, so the return is always in 0..4 and guards the JS `null >= 0`/`0 ||
27
+ * 1`/`grid[99]` traps. It is a SECURITY read (Step 2 gates inbound bounds on it), so it FAILS
28
+ * CLOSED, never open: an uninitialized DB throws (same contract as addContact) rather than
29
+ * silently returning UNKNOWN and admitting a BLOCKED sender; an unresolvable/retired agent name
30
+ * throws via #requireAgentId. Both are invariant violations a caller must surface, not swallow. */
31
+ getTier(agentName, pubkey) {
32
+ // Fail CLOSED: a read that decides whether to admit a sender must not degrade to "unclassified"
33
+ // when it cannot reach the ACL — that would admit a blocked contact. Throw as addContact does.
34
+ if (!this.#db)
35
+ throw new Error(`getTier('${agentName}'): database not initialized`);
36
+ const row = this.#db
37
+ .prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
38
+ .get(this.#ctx.requireAgentId(agentName), normalizeContactPubkey(pubkey));
39
+ if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
40
+ // A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
41
+ // tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
42
+ this.#ctx.logger.warn("contact.tier.corrupt", { agentName, pubkey, storedTier: row.tier });
43
+ }
44
+ return normalizeTier(row?.tier);
45
+ }
46
+ /** DOD-TIER-BOUNDS-SETTINGS: the effective bound for (agent, tier, field) — a per-agent SETTINGS
47
+ * override if one is set and valid, else the hardcoded grid default (DEFAULT_TIER_BOUNDS). With no
48
+ * settings this is byte-identical to Step 2 (the daemon runs on defaults alone). A stored value
49
+ * that is somehow non-positive/non-finite (should be impossible — validated at SET time) falls back
50
+ * to the grid default rather than removing the bound (INV-TIER-BOUND, defensive). BLOCKED is never
51
+ * settable — it always returns the fixed grid value (0). */
52
+ resolveTierBound(agentName, tier, field) {
53
+ const gridDefault = field === "max_sessions"
54
+ ? tierBoundsFor(tier).maxSessionsPerSender
55
+ : tierBoundsFor(tier).maxBytesPerSession;
56
+ const name = settableTierName(tier);
57
+ if (name === null)
58
+ return gridDefault; // BLOCKED or out-of-range — fixed, not overridable
59
+ const raw = this.getSetting(agentName, boundSettingKey(name, field));
60
+ if (raw === null)
61
+ return gridDefault; // unset → default
62
+ const parsed = Number(raw);
63
+ if (!Number.isFinite(parsed) || parsed <= 0) {
64
+ // Should be impossible (validated at SET time) → a config-integrity failure. Surface it: this
65
+ // reverts a possibly-TIGHTENED bound to the looser default, so a silent revert would hide a real
66
+ // problem. Still fail SAFE (grid default, never unbounded — INV-TIER-BOUND).
67
+ this.#ctx.logger.warn("settings.bound.corrupt", { agentName, tier, field, raw });
68
+ return gridDefault;
69
+ }
70
+ return parsed;
71
+ }
72
+ /** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
73
+ * no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
74
+ * optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
75
+ * THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
76
+ * backstop (same contract as DbIdentityStore.setMoniker).
77
+ *
78
+ * DOD-TIER-1/4: a NEW row is stamped `tier` (never NULL) and an optional `provenance`
79
+ * ('accepted' | 'initiated' | null). The `tier` defaults to the least-privilege UNKNOWN floor —
80
+ * a caller GRANTS trust by passing a higher tier explicitly. Every production creation path is a
81
+ * deliberate operator action and passes KNOWN (initiate, engage/reply, explicit cello_contact_add
82
+ * — DEC-AB-1). INSERT OR IGNORE means an EXISTING contact is untouched — tier and provenance pin
83
+ * at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
84
+ * the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
85
+ addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
86
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
87
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
88
+ pubkey = normalizeContactPubkey(pubkey);
89
+ if (!pubkey)
90
+ return;
91
+ // Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
92
+ // the handler log contact.added and report ok:true for a row that never landed.
93
+ if (!this.#db)
94
+ throw new Error(`addContact('${agentName}'): database not initialized`);
95
+ if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
96
+ throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
97
+ }
98
+ // DOD-TIER-4 (review F3): the stored tier must be a known 0..4 constant — a can-never-be-stored
99
+ // backstop mirroring the moniker validation above. All callers pass a TIER constant; this catches
100
+ // a future caller (or a bad refactor) that would otherwise persist a corrupt tier the read side
101
+ // must then defensively normalize.
102
+ if (!isKnownTierValue(tier)) {
103
+ throw new Error(`invalid contact tier for agent '${agentName}': ${tier} (must be 0..4)`);
104
+ }
105
+ const agentId = this.#ctx.requireAgentId(agentName);
106
+ this.#db
107
+ .prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
108
+ .run(agentId, pubkey, Date.now(), tier, provenance ?? null);
109
+ if (moniker !== undefined && moniker !== null) {
110
+ this.#db
111
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
112
+ .run(moniker, agentId, pubkey);
113
+ }
114
+ }
115
+ /** MONIKER-3 AC3: rename (string) or clear (null) an EXISTING contact's pet name. Returns false
116
+ * when no such contact — fail-loud at the caller, never a silent no-op success. Same
117
+ * validate-throw backstop as addContact. */
118
+ setContactMoniker(agentName, pubkey, moniker) {
119
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
120
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
121
+ pubkey = normalizeContactPubkey(pubkey);
122
+ // Review F2: false means exactly "no such contact" — a null DB handle throws instead, so the
123
+ // operator is never sent chasing a nonexistent missing-contact problem.
124
+ if (!this.#db)
125
+ throw new Error(`setContactMoniker('${agentName}'): database not initialized`);
126
+ if (moniker !== null && validateMoniker(moniker) === null) {
127
+ throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
128
+ }
129
+ const res = this.#db
130
+ .prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
131
+ .run(moniker, this.#ctx.requireAgentId(agentName), pubkey);
132
+ // DOD-RENAME-1: setting the local pet name IS the operator acting on a rename — resolve any
133
+ // pending notice for this contact (whether they adopted the offered name or chose their own).
134
+ if (res.changes > 0)
135
+ this.clearRenameNotice(agentName, pubkey);
136
+ return res.changes > 0;
137
+ }
138
+ /**
139
+ * M10B / DOD-END-SURFACE-1 — decide whether ONE signal is presented to ONE counterparty.
140
+ *
141
+ * `present: null` CLEARS the choice, which is not the same as `false`: cleared means "no opinion,
142
+ * use the signal's own default", while false means "specifically not this person". Collapsing
143
+ * them would make an operator unable to undo an omission without knowing what the default was.
144
+ *
145
+ * Deliberately does NOT require an existing contact row, unlike the tier/moniker/away setters. A
146
+ * decision about what to disclose is meaningful before a relationship is established — indeed
147
+ * that is when it matters most — and refusing here would force the operator to add someone as a
148
+ * contact in order to withhold something from them.
149
+ */
150
+ setContactSignalPref(agentName, pubkey, signalHash, present) {
151
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
152
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
153
+ pubkey = normalizeContactPubkey(pubkey);
154
+ if (!this.#db)
155
+ throw new Error(`setContactSignalPref('${agentName}'): database not initialized`);
156
+ const agentId = this.#ctx.requireAgentId(agentName);
157
+ if (present === null) {
158
+ this.#db
159
+ .prepare("DELETE FROM contact_signal_prefs WHERE agent_id = ? AND contact_pubkey = ? AND signal_hash = ?")
160
+ .run(agentId, pubkey, signalHash);
161
+ this.#ctx.logger.info("signal.presentation.pref.cleared", { agentName, pubkey: pubkey.slice(0, 16), signalHash: signalHash.slice(0, 16) });
162
+ return;
163
+ }
164
+ this.#db
165
+ .prepare(`INSERT INTO contact_signal_prefs (agent_id, contact_pubkey, signal_hash, present, set_at)
166
+ VALUES (?, ?, ?, ?, ?)
167
+ ON CONFLICT(agent_id, contact_pubkey, signal_hash) DO UPDATE SET present = excluded.present, set_at = excluded.set_at`)
168
+ .run(agentId, pubkey, signalHash, present ? 1 : 0, Date.now());
169
+ this.#ctx.logger.info("signal.presentation.pref.set", {
170
+ agentName, pubkey: pubkey.slice(0, 16), signalHash: signalHash.slice(0, 16), present,
171
+ });
172
+ }
173
+ /**
174
+ * The explicit per-counterparty choices for this contact: signal hash → present.
175
+ *
176
+ * A signal ABSENT from this map has no choice recorded and falls back to its own
177
+ * `default_present`. Returns an EMPTY map on an uninitialised DB rather than throwing, because
178
+ * this is a preference read on the presentation path and losing preferences must not break a
179
+ * session — but note the direction that failure takes: with no preferences, `default_present`
180
+ * decides, and consent still gates everything upstream in SQL. It can therefore only fall back to
181
+ * the operator's standing default, never to disclosing something consent has not cleared.
182
+ */
183
+ getContactSignalPrefs(agentName, pubkey) {
184
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
185
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
186
+ pubkey = normalizeContactPubkey(pubkey);
187
+ if (!this.#db)
188
+ return new Map();
189
+ const rows = this.#db
190
+ .prepare("SELECT signal_hash, present FROM contact_signal_prefs WHERE agent_id = ? AND contact_pubkey = ?")
191
+ .all(this.#ctx.requireAgentId(agentName), pubkey);
192
+ return new Map(rows.map((r) => [r.signal_hash, r.present !== 0]));
193
+ }
194
+ /** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
195
+ * when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
196
+ setContactAwayMessage(agentName, pubkey, message) {
197
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
198
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
199
+ pubkey = normalizeContactPubkey(pubkey);
200
+ if (!this.#db)
201
+ throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
202
+ const res = this.#db
203
+ .prepare("UPDATE contacts SET away_message = ? WHERE agent_id = ? AND pubkey = ?")
204
+ .run(message, this.#ctx.requireAgentId(agentName), pubkey);
205
+ return res.changes > 0;
206
+ }
207
+ /** DOD-CONTACT-VIEW-1: set an EXISTING contact's reachability tier. Returns false when no such
208
+ * contact — fail-loud at the caller, never a silent no-op success (same contract as
209
+ * setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
210
+ * stores whatever it is handed (the handler is the validation boundary). */
211
+ setContactTier(agentName, pubkey, tier) {
212
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
213
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
214
+ pubkey = normalizeContactPubkey(pubkey);
215
+ if (!this.#db)
216
+ throw new Error(`setContactTier('${agentName}'): database not initialized`);
217
+ const res = this.#db
218
+ .prepare("UPDATE contacts SET tier = ? WHERE agent_id = ? AND pubkey = ?")
219
+ .run(tier, this.#ctx.requireAgentId(agentName), pubkey);
220
+ return res.changes > 0;
221
+ }
222
+ /** DOD-RENAME-1 (Option C): record a self-declared name a peer offered, at the moment the offer is
223
+ * SEEN. The stored local pet name (contacts.moniker) is SACROSANCT — this only ever touches
224
+ * last_offered_moniker and the notice queue, never the moniker (AC2). A rename NOTICE is queued
225
+ * only when the peer is a contact the operator has PERSONALLY NAMED (moniker non-null), a name was
226
+ * seen BEFORE (last_offered_moniker non-null), and the new offer DIFFERS (AC3). The first-ever
227
+ * offer just records the baseline (no notice); a repeat of the same name is idempotent (AC4).
228
+ * Called only when a moniker WAS offered (caller-guarded), so silence never clears the baseline
229
+ * (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
230
+ * detection works only for peers who INITIATE to you — a property, not a bug. */
231
+ recordOfferedMoniker(agentName, pubkey, offered) {
232
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
233
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
234
+ pubkey = normalizeContactPubkey(pubkey);
235
+ // Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
236
+ // (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
237
+ if (!this.#db)
238
+ throw new Error(`recordOfferedMoniker('${agentName}'): database not initialized`);
239
+ const agentId = this.#ctx.requireAgentId(agentName);
240
+ const row = this.#db
241
+ .prepare("SELECT last_offered_moniker, moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
242
+ .get(agentId, pubkey);
243
+ if (!row)
244
+ return; // not a contact — no row to hold a baseline or a notice
245
+ if (offered === row.last_offered_moniker)
246
+ return; // idempotent — same name already seen (AC4)
247
+ // A genuine change from a previously-seen name, for a contact the operator has named → notice.
248
+ if (row.last_offered_moniker !== null && row.moniker !== null) {
249
+ this.#db
250
+ .prepare("INSERT OR REPLACE INTO contact_rename_notices (agent_id, pubkey, offered_name, noticed_at) VALUES (?, ?, ?, ?)")
251
+ .run(agentId, pubkey, offered, Date.now());
252
+ // Observability: log the FACT, never the attacker-chosen name (same rule as moniker.rejected).
253
+ this.#ctx.logger.info("contact.rename.noticed", { agentName, pubkey });
254
+ }
255
+ this.#db
256
+ .prepare("UPDATE contacts SET last_offered_moniker = ? WHERE agent_id = ? AND pubkey = ?")
257
+ .run(offered, agentId, pubkey);
258
+ }
259
+ /** M8C-CONTACT-1: known stays known until explicitly removed. */
260
+ removeContact(agentName, pubkey) {
261
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
262
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
263
+ pubkey = normalizeContactPubkey(pubkey);
264
+ if (!this.#db)
265
+ return false;
266
+ const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#ctx.requireAgentId(agentName), pubkey);
267
+ // DOD-RENAME-1: a removed contact has no pending rename to resolve.
268
+ if (res.changes > 0)
269
+ this.clearRenameNotice(agentName, pubkey);
270
+ /**
271
+ * OUTSIDE the `changes > 0` guard — review N2, and inside it the F2 fix did nothing for the
272
+ * case that matters.
273
+ *
274
+ * The pin is written on every ACCEPTED INBOUND session. A contact row is written only on an
275
+ * outbound initiate, an explicit add, a reply, or a trust-signal presentation — and an inbound
276
+ * requester is deliberately NOT auto-added. So a counterparty you never replied to (away-mode
277
+ * auto-ack is exactly this) has a pin and no contact row.
278
+ *
279
+ * Guarded, `cello_contact_remove` for them returned `{ ok: true, removed: false }`, cleared
280
+ * nothing, and the identity refusal stayed permanent — the original lockout, now wearing an
281
+ * `ok: true`, which is harder to notice than the original.
282
+ */
283
+ const pinsCleared = this.clearPinnedCounterpartyPrimary(agentName, pubkey);
284
+ return res.changes > 0 || pinsCleared > 0;
285
+ }
286
+ /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
287
+ * and tolerant of a not-yet-open DB (a missing label degrades the doorbell, never blocks it). */
288
+ getContactMoniker(agentName, pubkey) {
289
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
290
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
291
+ pubkey = normalizeContactPubkey(pubkey);
292
+ if (!this.#db) {
293
+ // Review F2: the last fully-silent branch in the resolution chain — the label degrades to
294
+ // fingerprint, which is correct, but say so rather than returning null wordlessly.
295
+ this.#ctx.logger.debug("moniker.local.db_unavailable", { agentName, pubkey });
296
+ return null;
297
+ }
298
+ const row = this.#db
299
+ .prepare("SELECT moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
300
+ .get(this.#ctx.requireAgentId(agentName), pubkey);
301
+ return row?.moniker ?? null;
302
+ }
303
+ /** M8C-CONTACT-1 + DOD-CONTACT-VIEW-1: list an agent's contacts, oldest-added first, each with its
304
+ * pet name (MONIKER-3), tier + provenance (the address-book metadata), and a READ-side LEFT JOIN
305
+ * against `sessions` for how many SEALED sessions were shared and when they last spoke (MAX
306
+ * updated_at). No new stored data — a pure read. A contact with no sessions shows 0 / null (never),
307
+ * not an error. The JOIN is scoped by agent_id so one agent's sessions never bleed into another's. */
308
+ listContacts(agentName) {
309
+ if (!this.#db)
310
+ return [];
311
+ return this.#db
312
+ .prepare(`SELECT c.pubkey, c.added_at, c.moniker, c.tier, c.provenance,
313
+ COUNT(CASE WHEN s.status = 'sealed' THEN 1 END) AS sealed_count,
314
+ MAX(s.updated_at) AS last_spoke
315
+ FROM contacts c
316
+ LEFT JOIN sessions s ON s.agent_id = c.agent_id AND s.counterparty_pubkey = c.pubkey
317
+ WHERE c.agent_id = ?
318
+ GROUP BY c.pubkey, c.added_at, c.moniker, c.tier, c.provenance
319
+ ORDER BY c.added_at ASC`)
320
+ .all(this.#ctx.requireAgentId(agentName));
321
+ }
322
+ /** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
323
+ * contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
324
+ clearRenameNotice(agentName, pubkey) {
325
+ // A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
326
+ // query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
327
+ pubkey = normalizeContactPubkey(pubkey);
328
+ if (!this.#db)
329
+ throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
330
+ this.#db
331
+ .prepare("DELETE FROM contact_rename_notices WHERE agent_id = ? AND pubkey = ?")
332
+ .run(this.#ctx.requireAgentId(agentName), pubkey);
333
+ }
334
+ /**
335
+ * Forget the pinned threshold group key for a counterparty, so the next session re-pins.
336
+ *
337
+ * DOD-M15-OFFER-SIGNED-1 review F2 — WITHOUT THIS THE REFUSAL WAS PERMANENT. The identity-change
338
+ * check refuses a counterparty whose group key differs from the one recorded in an earlier
339
+ * session, and its guidance told the operator to confirm out of band and then remove the contact
340
+ * so the new identity is pinned afresh. `removeContact` deleted a row in `contacts`; the pin lives
341
+ * in `sessions.counterparty_primary_pubkey`, and nothing in the daemon ever cleared it.
342
+ *
343
+ * So an operator who did exactly as instructed — called their counterparty, confirmed the
344
+ * re-registration was genuine, removed the contact, retried — got the identical refusal, with no
345
+ * way out short of editing the database. A security control that cannot be reset by the person it
346
+ * protects is a lockout, and the printed remedy made it worse by reading as though it worked.
347
+ *
348
+ * Nulls the column rather than deleting the session rows: those rows are the transcript record,
349
+ * and a re-pin is not a reason to lose them.
350
+ */
351
+ clearPinnedCounterpartyPrimary(agentName, counterpartyPubkeyHex) {
352
+ if (!this.#db)
353
+ return 0;
354
+ const res = this.#db
355
+ .prepare(
356
+ // NO `updated_at` BUMP — review N6. `CAP_COUNTS` counts an interrupted session only while
357
+ // `updated_at` is inside the staleness window, so touching it here reset the clock on every
358
+ // stale session with that counterparty, re-inflating their per-sender cap — while removing
359
+ // the contact simultaneously dropped them to UNKNOWN tier, which LOWERS it. The operator
360
+ // follows the printed remedy and their counterparty's next session is refused for cap,
361
+ // through a reason string deliberately identical to every other refusal. A second lockout
362
+ // that says nothing. Nothing needs the timestamp: every candidate row ends up NULL.
363
+ "UPDATE sessions SET counterparty_primary_pubkey = NULL WHERE agent_id = ? AND counterparty_pubkey = ?")
364
+ .run(this.#ctx.requireAgentId(agentName), counterpartyPubkeyHex);
365
+ return Number(res.changes);
366
+ }
367
+ /** M8C-TGDOOR-1: the daemon-wide Telegram bot settings, or null if never configured. */
368
+ getTelegramSettings() {
369
+ if (!this.#db)
370
+ return null;
371
+ const row = this.#db
372
+ .prepare("SELECT bot_token, allowlisted_chat_id FROM telegram_settings WHERE id = 1")
373
+ .get();
374
+ return row ? { botToken: row.bot_token, allowlistedChatId: row.allowlisted_chat_id } : null;
375
+ }
376
+ /** M8C-TGDOOR-1: persist (or replace) the singleton Telegram settings row. */
377
+ setTelegramSettings(botToken, allowlistedChatId) {
378
+ if (!this.#db)
379
+ return;
380
+ this.#db
381
+ .prepare(`INSERT INTO telegram_settings (id, bot_token, allowlisted_chat_id, updated_at) VALUES (1, ?, ?, ?)
382
+ ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
383
+ .run(botToken, allowlistedChatId, Date.now());
384
+ }
385
+ /** DOD-SETTINGS-1: read a per-agent setting, or null if unset. The get-with-default is the CALLER's
386
+ * job (an unset key falls back to the hardcoded grid/system default — the daemon runs correctly on
387
+ * defaults alone, AC3). Returns null on a missing DB (settings are always optional). */
388
+ getSetting(agentName, key) {
389
+ if (!this.#db)
390
+ return null;
391
+ const row = this.#db
392
+ .prepare("SELECT value FROM agent_settings WHERE agent_id = ? AND key = ?")
393
+ .get(this.#ctx.requireAgentId(agentName), key);
394
+ return row?.value ?? null;
395
+ }
396
+ /**
397
+ * DOD-SETTINGS-1: DELETE a per-agent setting so the built-in default applies again.
398
+ *
399
+ * Deleting is NOT storing "". `getSetting` returns null for both, but the away-text resolver walks
400
+ * per-contact → per-tier → agent-default → system default, and an empty string is a VALUE that
401
+ * wins that walk and blanks the reply. Unsetting is the only way back to the default, and until
402
+ * this existed there was no way back at all: `cello_settings_set` accepted a string, refused an
403
+ * empty one, and told the caller to "pass null to clear" — a null it coerced to undefined and
404
+ * rejected as missing_params. Following that guidance from the CLI set the literal text "null",
405
+ * so an operator trying to remove their away message ended up broadcasting the word "null" to
406
+ * every caller.
407
+ *
408
+ * Returns whether a row was actually removed, so the handler can report what it did rather than
409
+ * claiming a clear it never performed.
410
+ */
411
+ deleteSetting(agentName, key) {
412
+ if (!this.#db)
413
+ throw new Error(`deleteSetting('${agentName}'): database not initialized`);
414
+ // Same dual-layer key check as setSetting — an unknown key here means a caller hand-typed one,
415
+ // and silently reporting "cleared" for a key that never existed would be the same class of lie.
416
+ if (!isValidSettingKey(key))
417
+ throw new Error(`invalid_key: '${key}' is not a known setting`);
418
+ const res = this.#db
419
+ .prepare("DELETE FROM agent_settings WHERE agent_id = ? AND key = ?")
420
+ .run(this.#ctx.requireAgentId(agentName), key);
421
+ return res.changes > 0;
422
+ }
423
+ /** DOD-SETTINGS-1: write a per-agent setting (upsert). Key VALIDATION is the handler's boundary
424
+ * (isValidSettingKey); value validation for typed settings (finite bounds, etc.) belongs to the
425
+ * specific consumer. Throws on a missing DB — a write that silently no-ops would be a lie. */
426
+ setSetting(agentName, key, value) {
427
+ if (!this.#db)
428
+ throw new Error(`setSetting('${agentName}'): database not initialized`);
429
+ // Store-level backstop (review F2): the handler validates the key, but the dual-layer convention
430
+ // (cf. MONIKER-1) means an unknown key can NEVER be stored — an internal caller that hand-typed a
431
+ // key instead of using the builders would otherwise persist a setting that never takes effect.
432
+ if (!isValidSettingKey(key))
433
+ throw new Error(`invalid_key: '${key}' is not a known setting`);
434
+ this.#db
435
+ .prepare(`INSERT INTO agent_settings (agent_id, key, value, updated_at) VALUES (?, ?, ?, ?)
436
+ ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
437
+ .run(this.#ctx.requireAgentId(agentName), key, value, Date.now());
438
+ }
439
+ /** DOD-SETTINGS-1: all explicitly-set settings for an agent (the ones that OVERRIDE a default),
440
+ * key-sorted. Unset keys are absent — the operator sees only what they changed. */
441
+ getAllSettings(agentName) {
442
+ if (!this.#db)
443
+ return [];
444
+ return this.#db
445
+ .prepare("SELECT key, value FROM agent_settings WHERE agent_id = ? ORDER BY key ASC")
446
+ .all(this.#ctx.requireAgentId(agentName));
447
+ }
448
+ /**
449
+ * Review F7: a relay sent a witness alert this build could not read or could not verify.
450
+ *
451
+ * Recorded so a version skew that silently kills the witness layer is visible to the operator
452
+ * instead of living only in a log file. Carries no session and no party by construction.
453
+ */
454
+ recordRelayWitnessUnreadable(agentName, relayPeerId, why) {
455
+ const byRelay = this.#ctx.witnessUnreadable.get(agentName) ?? new Map();
456
+ const prior = byRelay.get(relayPeerId);
457
+ byRelay.set(relayPeerId, { why, count: (prior?.count ?? 0) + 1 });
458
+ this.#ctx.witnessUnreadable.set(agentName, byRelay);
459
+ this.#ctx.logger.error("session.witness.unreadable.recorded", {
460
+ agentName, relayPeerId, why,
461
+ impact: "this agent's witness layer is not working against that relay — no observation it " +
462
+ "sends can be read, and nothing has been concluded about any participant",
463
+ });
464
+ }
465
+ /** Relays whose witness alerts this build could not read, for the agent's inbox. */
466
+ getWitnessUnreadable(agentName) {
467
+ return [...(this.#ctx.witnessUnreadable.get(agentName) ?? new Map()).entries()]
468
+ .map(([relayPeerId, v]) => ({ relayPeerId, why: v.why, count: v.count }));
469
+ }
470
+ /**
471
+ * DOD-LOG-1 / PERSIST-002 (AC-010): append one readable message to the durable transcript, keyed
472
+ * by the canonical leaf `sequence` so it joins to the committed hash chain. The blob is stored as
473
+ * plaintext bytes: the whole DB is SQLCipher-encrypted at rest, so there is no per-column cipher.
474
+ * Idempotent on replay (INSERT OR IGNORE). Never throws into the caller's content path — but it
475
+ * REPORTS: returns false when the row did not land, so a caller for whom the row is a delivery
476
+ * precondition can fail instead of proceeding (review F2). Before Tier 1 the return value would
477
+ * have been pointless, because `cello_receive` served content from the in-memory buffer and the
478
+ * lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
479
+ * row is TOTAL content loss and the caller has to know.
480
+ */
481
+ recordTranscriptMessage(agentName, sessionId, sequence,
482
+ /**
483
+ * DOD-M15-REFUSEDEVIDENCE-1 adds `'quarantined'` — received and REFUSED, kept as evidence and
484
+ * never delivered. It goes through THIS writer rather than a second one so that the attribution
485
+ * rule, the blob handling and the write-failure logging cannot drift between a delivered message
486
+ * and a refused one. One store, one writer.
487
+ */
488
+ direction, plaintext, correlationId,
489
+ /**
490
+ * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof, when there is one.
491
+ *
492
+ * Optional because there legitimately is not always one — the ordering decode can fail SOFT and
493
+ * the message is still ingested via hash-dedup. Optional is NOT the same as unremarked: absence
494
+ * is written into the row as `attribution = 'local_session_state'`, so a reader can tell a row
495
+ * whose author was proven from one whose author was assumed. That distinction is the bullet.
496
+ */
497
+ authorship,
498
+ /** Required on a `'quarantined'` row and meaningless on any other: WHY it was refused. */
499
+ quarantineReason,
500
+ /**
501
+ * DOD-M15-REFUSEDEVIDENCE-1: the sender's key when there is one but no verified signature to go
502
+ * with it. A refused frame often has an identified sender and an unusable proof — a tampered
503
+ * message is still FROM someone — and dropping the key because the signature failed would throw
504
+ * away the half of the attribution that survived.
505
+ */
506
+ senderPubkeyHexOverride) {
507
+ if (!this.#db)
508
+ return false;
509
+ try {
510
+ const agentId = this.#ctx.requireAgentId(agentName);
511
+ const blob = Buffer.from(plaintext);
512
+ this.#db
513
+ .prepare(`INSERT OR IGNORE INTO transcript
514
+ (agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution, quarantine_reason)
515
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
516
+ .run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship
517
+ ? Buffer.from(authorship.senderPubkey).toString("hex")
518
+ : senderPubkeyHexOverride ?? null, authorship ? Buffer.from(authorship.senderSig) : null,
519
+ /**
520
+ * THREE values, not two — caught by CELLO_Coder_1 reviewing the first version, and it was
521
+ * the same defect this column exists to prevent, surviving one layer up in the enum.
522
+ *
523
+ * `local_session_state` covered two OPPOSITE rows: one this agent AUTHORED (provenance
524
+ * fully known, merely not third-party-provable) and one RECEIVED on the soft fallback
525
+ * (provenance unknown — something arrived on a socket and was trusted). A reader shown the
526
+ * transcript later could not separate "he wrote this himself" from "nobody checked".
527
+ * Structurally identical rows with different trustworthiness is exactly what I refused to
528
+ * ship when I rejected a nullable signature column.
529
+ *
530
+ * No plumbing needed: `direction` already carries the answer at write time.
531
+ */
532
+ /**
533
+ * DIRECTION FIRST — DOD-M15-SEALWIRE-1 bullet 5, sent half.
534
+ *
535
+ * This used to read `authorship ? "verified_signature" : …`, which was right while only
536
+ * RECEIVED rows could carry a signature. Now a SENT row carries one too — our own, over
537
+ * the Structure-1 bytes we put on the wire — and labelling that `verified_signature` would
538
+ * be false in the way this column exists to prevent: **we did not verify it, we produced
539
+ * it.** Nobody checked a counterparty's key; there was no counterparty in the act.
540
+ *
541
+ * So the three values keep meaning three different things:
542
+ * `self_authored` — this agent wrote it. Now PROVABLE when a signature is stored.
543
+ * `verified_signature` — someone else wrote it and we checked their key against it.
544
+ * `local_session_state`— someone else wrote it and nobody checked anything.
545
+ */
546
+ direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state", quarantineReason ?? null);
547
+ this.#ctx.logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
548
+ return true;
549
+ }
550
+ catch (err) {
551
+ // M8C-INBOX-1 (reviewer F2): a RECEIVED-row write failure is not cosmetic — since INBOX-1 the
552
+ // transcript is the AUTHORITY for unread (getUnreadSummary).
553
+ //
554
+ // UPDATED for DOD-COATTEND-1 (review F2). This comment used to end "...while cello_receive
555
+ // still delivers it live from the in-memory buffer (masking the loss)", and that mitigation
556
+ // was the whole reason a swallowed write was survivable. Tier 1 DELETED it: delivery reads
557
+ // the transcript now, so a lost received row is not an undercount, it is the message never
558
+ // reaching ANY session while the doorbell rings and the leaf sits in the hash chain. The
559
+ // sentence is corrected rather than kept, because as written it reassured a reader about a
560
+ // safety net that no longer exists. Sent-row failures stay a warning (they only affect the
561
+ // durable readable transcript, not delivery).
562
+ // A QUARANTINED row that fails to write is an ERROR for the same reason a received one is,
563
+ // and a different one: nothing else holds these bytes. The message was refused, so it was
564
+ // never delivered and never acked in a way that brings it back — a failed write here is the
565
+ // evidence gap this unit exists to close, reopened by a disk fault.
566
+ const level = direction === "sent" ? "warn" : "error";
567
+ this.#ctx.logger[level]("transcript.message.record.failed", {
568
+ sessionId, agentName, sequence, direction,
569
+ reason: err instanceof Error ? err.message : String(err),
570
+ correlationId,
571
+ ...(direction === "received" ? { impact: "content_undeliverable_message_lost" } : {}),
572
+ ...(direction === "quarantined" ? { impact: "refused_message_not_retained_no_other_copy_exists" } : {}),
573
+ });
574
+ return false;
575
+ }
576
+ }
577
+ /**
578
+ * DOD-LOG-1: read a session's durable transcript back (after a restart), decrypted and ordered by
579
+ * canonical sequence then direction. A blob that fails to decrypt (tamper/wrong key) is skipped
580
+ * with a loud log rather than crashing the read.
581
+ */
582
+ readTranscript(agentName, sessionId) {
583
+ if (!this.#db)
584
+ return { messages: [], undecryptable: 0 };
585
+ const rows = this.#db
586
+ .prepare(`SELECT sequence, direction, blob, created_at, quarantine_reason FROM transcript
587
+ WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
588
+ .all(this.#ctx.requireAgentId(agentName), sessionId);
589
+ const messages = [];
590
+ // PERSIST-002 (AC-010): the blob is plaintext (whole-DB SQLCipher at rest), so there is no
591
+ // per-row decrypt step that can fail — `undecryptable` stays 0 and is kept only for callers that
592
+ // already read the field.
593
+ for (const r of rows) {
594
+ const blob = r.blob instanceof Uint8Array ? r.blob : new Uint8Array(r.blob);
595
+ /**
596
+ * DOD-M15-REFUSEDEVIDENCE-1 — THE READ IS REDACTED, THE STORAGE IS NOT.
597
+ *
598
+ * The entry stays at its position, because a hole where a message was is the evidence gap
599
+ * this unit exists to close, one level up: the operator must be able to see that something
600
+ * arrived here and was refused. What is withheld is the TEXT, and `text` carries the
601
+ * withholding statement rather than being omitted — every existing renderer of this array
602
+ * prints `text`, so a missing field would print nothing and an unfiltered one would print the
603
+ * payload. The statement is the fail-safe value for both.
604
+ *
605
+ * ⚠️ THREE-WAY, not `!== "sent" ? "received"`. The old expression labelled anything that was
606
+ * not `sent` as `received`, which would have handed a refused message to every reader as a
607
+ * delivered one — with its text.
608
+ */
609
+ const direction = r.direction === "sent" ? "sent" : r.direction === "quarantined" ? "quarantined" : "received";
610
+ if (direction === "quarantined") {
611
+ // No `?? "refused"` default — review F11. See `readQuarantined` for why a generic label for
612
+ // an impossible state is worse than an empty one.
613
+ const reason = r.quarantine_reason;
614
+ const redaction = quarantineRedaction(reason, sessionId, r.sequence);
615
+ messages.push({
616
+ sequence: r.sequence, direction, createdAt: r.created_at,
617
+ text: redaction.text,
618
+ // The key ENDS in `guidance` so `vocabulary.ts` rewrites the verb for a CLI reader — see
619
+ // the note on `quarantineRedaction`.
620
+ withheld_guidance: redaction.guidance,
621
+ refusalReason: reason,
622
+ withheld: true,
623
+ });
624
+ continue;
625
+ }
626
+ messages.push({ sequence: r.sequence, direction, text: new TextDecoder().decode(blob), createdAt: r.created_at });
627
+ }
628
+ return { messages, undecryptable: 0 };
629
+ }
630
+ /** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
631
+ * messages beyond the read watermark, excluding terminal sessions (sealed, abandoned,
632
+ * seal_interrupted_pending) which belong in getEndedUnread instead.
633
+ * Sessions with no sessions row are treated as non-terminal (LEFT JOIN).
634
+ * Content-free (counts + ids + last seq, never message text); a COUNT/MAX query, no decrypt. */
635
+ getUnreadSummary(agentName) {
636
+ if (!this.#db)
637
+ return [];
638
+ const rows = this.#db
639
+ .prepare(`SELECT t.session_id AS session_id,
640
+ COUNT(*) AS unread_count,
641
+ MAX(t.sequence) AS last_seq
642
+ FROM transcript t
643
+ LEFT JOIN message_watermarks w
644
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
645
+ LEFT JOIN sessions s
646
+ ON s.agent_id = t.agent_id AND s.session_id = t.session_id
647
+ WHERE t.agent_id = ?
648
+ AND ${UNREAD_RECEIVED_WHERE}
649
+ AND (s.status IS NULL OR s.status NOT IN ${TERMINAL_STATUSES})
650
+ GROUP BY t.session_id
651
+ HAVING unread_count > 0
652
+ ORDER BY t.session_id ASC`)
653
+ .all(this.#ctx.requireAgentId(agentName));
654
+ return rows;
655
+ }
656
+ /** DOD-SEALED-INBOX-1: terminal sessions with unread received messages that have not been
657
+ * dismissed. These are answering-machine style messages left in an ENDED session — the operator
658
+ * can read them via cello_transcript but cannot advance the watermark via cello_receive.
659
+ * Only returned when read_at IS NULL (not yet dismissed).
660
+ *
661
+ * DOD-SEALED-INBOX-2: named `getEndedUnread`, not `getSealedUnread`, and it SELECTS `s.status`.
662
+ * All four #TERMINAL_STATUSES belong here — that part was always right — but only `sealed` is
663
+ * NOTARIZED. The old name and the caller's hardcoded `session_state: "sealed"` asserted a
664
+ * cryptographic receipt for `abandoned`, `interrupted` and `seal_interrupted_pending` sessions,
665
+ * which have none. Callers must render the row's own status; there is nothing to infer from
666
+ * membership in this list beyond "it ended". */
667
+ getEndedUnread(agentName) {
668
+ if (!this.#db)
669
+ return [];
670
+ const rows = this.#db
671
+ .prepare(
672
+ // M12-P17 (review F2): return the ACTUAL status. `#TERMINAL_STATUSES` spans four states and
673
+ // they are NOT equivalent — an `interrupted` session is not committed, still accepts
674
+ // appends, and may have a counterparty waiting to seal. Stamping "sealed" over all four
675
+ // told an agent that live work was dead history: symptom B inverted.
676
+ `SELECT t.session_id AS session_id,
677
+ COUNT(*) AS unread_count,
678
+ MAX(t.sequence) AS last_seq,
679
+ s.status AS status
680
+ FROM transcript t
681
+ LEFT JOIN message_watermarks w
682
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
683
+ JOIN sessions s
684
+ ON s.agent_id = t.agent_id AND s.session_id = t.session_id
685
+ WHERE t.agent_id = ?
686
+ AND ${UNREAD_RECEIVED_WHERE}
687
+ AND s.status IN ${TERMINAL_STATUSES}
688
+ AND s.read_at IS NULL
689
+ GROUP BY t.session_id
690
+ HAVING unread_count > 0
691
+ ORDER BY t.session_id ASC`)
692
+ .all(this.#ctx.requireAgentId(agentName));
693
+ return rows;
694
+ }
695
+ /**
696
+ * DOD-CURSOR-DURABLE-1: how many RECEIVED messages in THIS session the agent has not read —
697
+ * the durable half of the read-before-write gate. Same predicate as getUnreadSummary (shared
698
+ * constant above), scoped to one session.
699
+ *
700
+ * This is DURABLE and PER-AGENT, where the send gate's other authority (the connection cursor) is
701
+ * in-memory and per-connection. It is what lets a stateless client — the `cello` CLI, one process
702
+ * per command — prove it has read the counterparty, which a dead socket's cursor never can.
703
+ *
704
+ * FAILS CLOSED: an uninitialized DB returns a positive count (treated as "unread"), never 0. A 0
705
+ * here unblocks a send; guessing 0 from a broken DB would silently defeat the gate.
706
+ */
707
+ getUnreadReceivedCount(agentName, sessionId) {
708
+ if (!this.#db)
709
+ return 1; // fail closed — never unblock a send because the DB is unavailable
710
+ const row = this.#db
711
+ .prepare(`SELECT COUNT(*) AS unread_count
712
+ FROM transcript t
713
+ LEFT JOIN message_watermarks w
714
+ ON w.agent_id = t.agent_id AND w.session_id = t.session_id
715
+ WHERE t.agent_id = ?
716
+ AND t.session_id = ?
717
+ AND ${UNREAD_RECEIVED_WHERE}`)
718
+ .get(this.#ctx.requireAgentId(agentName), sessionId);
719
+ // Absent row → "I cannot count", which is NOT "you are caught up". Answer the same way the
720
+ // #db guard above does. Unreachable today (SELECT COUNT(*) with no GROUP BY always yields a
721
+ // row), but a fail-OPEN default inside a fail-CLOSED gate is a defect that only needs the query
722
+ // to change once. The two branches must never disagree about what "unknown" means.
723
+ return row ? row.unread_count : 1;
724
+ }
725
+ /**
726
+ * Rehydrate `#diverged` from `sessions.diverged_at` — `DOD-M15-DIVERGE-DURABLE-1`.
727
+ *
728
+ * The Set stays as the hot read (the seal gate consults it per close), and the column is the
729
+ * truth. Loaded once at boot rather than queried per read so the gate's cost does not change.
730
+ */
731
+ loadDivergedFromDb() {
732
+ if (!this.#db)
733
+ return;
734
+ const rows = this.#db
735
+ .prepare(`SELECT s.session_id AS sid, a.agent_name AS agent
736
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
737
+ WHERE s.diverged_at IS NOT NULL`)
738
+ .all();
739
+ for (const r of rows)
740
+ this.#diverged.add(this.#ctx.sessionKey(r.agent, r.sid));
741
+ if (rows.length > 0) {
742
+ this.#ctx.logger.info("session.diverged.restored", {
743
+ count: rows.length,
744
+ impact: "these sessions provably cannot seal bilaterally and are refused at the seal gate — before " +
745
+ "this was durable, a restart made them read as healthy",
746
+ });
747
+ }
748
+ }
749
+ /**
750
+ * Record that this session's tree and the relay's counter have provably parted.
751
+ *
752
+ * Idempotent, and deliberately does NOT touch `updated_at`: that column drives the inbox's
753
+ * last-spoke ordering, and divergence is not activity.
754
+ */
755
+ markSessionDiverged(agentName, sessionId) {
756
+ this.#diverged.add(this.#ctx.sessionKey(agentName, sessionId));
757
+ if (!this.#db)
758
+ return;
759
+ /**
760
+ * KEYED ON (agent_id, session_id) — review F3, and the loopback case makes it concrete.
761
+ *
762
+ * This was `WHERE session_id = ?` alone. The table's PK is composite for a documented reason
763
+ * (`DOD-LOOP-1`, on the CREATE TABLE above): **two of one operator's agents can hold both ends
764
+ * of the SAME session_id on ONE daemon**, so `sessions` holds two rows. Unkeyed, marking one
765
+ * side diverged marked BOTH, and the clear below wiped BOTH — so side B sealing its half
766
+ * erased side A's divergence, and after a restart A's seal gate read healthy and signed a close
767
+ * that could only be refused. The line's own defect, produced by the line's own clear.
768
+ *
769
+ * Every other per-session UPDATE in this file keys on both columns; these two were the
770
+ * exceptions.
771
+ */
772
+ this.#db
773
+ .prepare("UPDATE sessions SET diverged_at = ? WHERE agent_id = ? AND session_id = ? AND diverged_at IS NULL")
774
+ .run(Date.now(), this.#ctx.requireAgentId(agentName), sessionId);
775
+ }
776
+ /** Whether this session has provably parted from the relay's ordering. */
777
+ isSessionDiverged(agentName, sessionId) {
778
+ return this.#diverged.has(this.#ctx.sessionKey(agentName, sessionId));
779
+ }
780
+ /**
781
+ * Drop the in-memory divergence memo for a session.
782
+ *
783
+ * Called when the session reaches a TERMINAL status: no future close can be refused at that
784
+ * point, so the flag has nothing left to protect. The DURABLE row is cleared by the caller —
785
+ * otherwise a sealed session comes back after a restart still carrying a refusal for a close that
786
+ * can no longer happen.
787
+ */
788
+ clearDivergedMemo(agentName, sessionId) {
789
+ this.#diverged.delete(this.#ctx.sessionKey(agentName, sessionId));
790
+ }
791
+ }
792
+ //# sourceMappingURL=session-records.js.map