@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,786 @@
1
+ import { addColumnIfMissing } from "./column-birth.js";
2
+ import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
3
+ import { migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
4
+ import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
5
+ import { foldContactPubkeyCase } from "./contact-pubkey-case.js";
6
+ import { ensureTrustSignalSchema } from "./trust-signal-store.js";
7
+ /**
8
+ * Create every session-store table and apply every additive migration.
9
+ *
10
+ * `loadDivergedFromDb` is a CALLBACK rather than an import: the divergence memo is rehydrated part
11
+ * way through this sequence — after the agent-id migration that gives it rows to read — and the
12
+ * store that owns it lives elsewhere. Passing it keeps the ordering visible here, where the reason
13
+ * for it is, instead of leaving a second caller to remember it.
14
+ */
15
+ export function ensureSessionSchema(db, logger, loadDivergedFromDb) {
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS sessions (
18
+ session_id TEXT NOT NULL,
19
+ agent_id TEXT NOT NULL,
20
+ counterparty_pubkey TEXT NOT NULL,
21
+ status TEXT NOT NULL,
22
+ created_at INTEGER NOT NULL,
23
+ updated_at INTEGER NOT NULL,
24
+ -- DOD-LOOP-1: composite key so two of the operator's agents can hold both ends of the
25
+ -- SAME session_id on ONE daemon (the loopback case). A bare session_id PK would reject
26
+ -- the second end's row.
27
+ -- DOD-AGENT-ID-JOINKEY-1: keyed on the STABLE agent_id, never the mutable, reuse-freed
28
+ -- agent_name. The display name lives on the agents table and is joined in for reads.
29
+ PRIMARY KEY (agent_id, session_id)
30
+ )
31
+ `);
32
+ // M7-SESSION-001: idempotent schema extension — add message_count and interrupted_at
33
+ // columns if they do not exist. ALTER TABLE IF NOT EXISTS COLUMN is not supported by
34
+ // older SQLite; we use a try/catch per column as the idempotent approach.
35
+ for (const ddl of [
36
+ "ALTER TABLE sessions ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0",
37
+ "ALTER TABLE sessions ADD COLUMN interrupted_at TEXT",
38
+ /**
39
+ * Decisions Carried #8 — THE SESSION SALT, persisted.
40
+ *
41
+ * Agreed once at session open from BOTH sides' random contributions, and unchanged for the
42
+ * life of the session. It is NOT a key: it decrypts nothing, and it is what lets this
43
+ * operator's own transcript stay verifiable — the content hash is recomputed from stored
44
+ * plaintext on the receive path and again for any later check, and salted it is underivable
45
+ * without this value.
46
+ *
47
+ * PERSISTED because the alternative is silent corruption. ⚠️ FUTURE TENSE, deliberately
48
+ * (review F10): NOTHING WRITES OR READS THIS COLUMN YET. `DOD-M15-SEALWIRE-1` will add the
49
+ * contribution exchange and the lookup — "does this session already have a salt? yes → use it,
50
+ * no → agree one" — and without the column that lookup would fail after a restart, mint a
51
+ * fresh salt, and split the transcript at the crash: every leaf before it unverifiable, with
52
+ * nothing saying so. The column lands now because it must exist before the code that needs it.
53
+ *
54
+ * NULL for every session opened before this column existed; those keep the unsalted hash.
55
+ */
56
+ "ALTER TABLE sessions ADD COLUMN content_salt BLOB",
57
+ /**
58
+ * DOD-M15-FREEZE-STATUS-1 — carried here for the OTHER LANE (`CELLO_Support`), agreed in
59
+ * session `e3adcaa7…`. Two lanes must not both edit this file (§2e, one file two branches), so
60
+ * the columns land in one migration and every line of behaviour stays on their side. Nothing
61
+ * in this lane reads or writes them.
62
+ *
63
+ * `frozen_at` epoch-ms when `#freezeOnIdentityFailure` fired. NULL = never frozen.
64
+ * `frozen_reason` the `reason` already passed to that method. NULL iff `frozen_at` is NULL.
65
+ *
66
+ * ⚠️ THE WRITE MUST LAND BEFORE `destroySessionNode`, NOT AFTER — FRAME-1 review F1's
67
+ * ordering, and the reason the in-memory `#frozenSessions.add` already sits before the
68
+ * teardown. `destroySessionNode` writes `interrupted`, which is the REVIVABLE status, so a
69
+ * durable mark landing after it lets a read race the teardown and revive the session out from
70
+ * under the freeze — the disk reproducing the bug the memory mark was moved early to fix.
71
+ *
72
+ * Why it earns a slot rather than waiting: `#frozenSessions` is memory-only today, so a
73
+ * restart UN-FREEZES a session that was frozen because a party signed with a key that was not
74
+ * the counterparty's. The next read revives it and re-admits that peer, while the log still
75
+ * says the session will not be revived.
76
+ */
77
+ "ALTER TABLE sessions ADD COLUMN frozen_at INTEGER",
78
+ "ALTER TABLE sessions ADD COLUMN frozen_reason TEXT",
79
+ // MSG-001-3b (MSG-2 startup-flush): persist the session's relay endpoint so the
80
+ // crash-backstop flush can deposit un-acked content after a restart, when the
81
+ // in-memory entry is gone. relay_addrs is a JSON array of multiaddr strings.
82
+ "ALTER TABLE sessions ADD COLUMN relay_peer_id TEXT",
83
+ "ALTER TABLE sessions ADD COLUMN relay_addrs TEXT",
84
+ // M7-SESSION-004 (AC-005): persist the seal certificate's legibility object with the
85
+ // sealed record so it survives a daemon restart and is readable on the cert-read surface
86
+ // (cello_get_sealed_receipt). JSON string with hex-encoded pubkeys; NULL until sealed.
87
+ // Inline idempotent migration (NOT Flyway — this is the client-side SQLite, AC-011).
88
+ /**
89
+ * 033-ACKEMIT — THE SESSION'S GENESIS PREV_ROOT, and it is persisted for ONE reason: a
90
+ * restart.
91
+ *
92
+ * It is a pure function of the two participant keys, the session id and the SESSION
93
+ * TIMESTAMP — and the timestamp arrives on the directory-signed relay assignment and lives
94
+ * nowhere else. A session restored from this table after a daemon restart re-registers with
95
+ * no assignment in hand, so without this column the daemon could not say what the first
96
+ * message of that session acknowledges, and every send on it would be refused rather than
97
+ * signed. Deriving is still preferred where the assignment IS in memory; this is what makes
98
+ * the derivation survive the process.
99
+ *
100
+ * NULL for every session opened before this column existed. Those sessions acknowledge
101
+ * nothing until the counterparty has sent something — they claim position 0 with no hash,
102
+ * which asserts nothing rather than asserting a position they cannot back — and from the
103
+ * first leaf they receive they acknowledge content like any other session.
104
+ */
105
+ "ALTER TABLE sessions ADD COLUMN genesis_prev_root BLOB",
106
+ "ALTER TABLE sessions ADD COLUMN seal_legibility TEXT",
107
+ "ALTER TABLE sessions ADD COLUMN sealed_root_hex TEXT",
108
+ // M7 legibility-TBS-binding (responder verify): the counterparty's FROST primary (group)
109
+ // pubkey, taken from the FROST-signed SessionAssignment's signer_pubkey. The responder uses
110
+ // it to VERIFY the bilateral seal signature locally (the seal is signed by the initiator's
111
+ // primary), not just accept it. NULL when this party initiated (it uses its own primary).
112
+ "ALTER TABLE sessions ADD COLUMN counterparty_primary_pubkey TEXT",
113
+ // DOD-SESSION-NAME-1: the operator's own human-readable label for this session. LOCAL AND
114
+ // COSMETIC — it is never sent to the relay or directory, never in a wire frame, never in the
115
+ // transcript, never in the seal or a Merkle leaf, and the counterparty never sees it. It
116
+ // cannot influence protocol behaviour.
117
+ // NULL MEANS SOMETHING: a session closed through an agent usually carries a name, so an
118
+ // unnamed closed session is a hint it did not close cleanly. Never auto-generate a default —
119
+ // a fabricated name destroys that signal.
120
+ "ALTER TABLE sessions ADD COLUMN session_name TEXT",
121
+ // DOD-SEALED-INBOX-1: local-only housekeeping flag — epoch-ms timestamp set by cello_dismiss.
122
+ // Never propagated, never part of the seal ceremony or hash chain. A dismissed terminal
123
+ // session is excluded from cello_inbox's ended_unread section. Distinct from the read
124
+ // watermark: this records "operator acknowledged via dismiss", not "operator received via
125
+ // cello_receive". NULL = not yet dismissed.
126
+ "ALTER TABLE sessions ADD COLUMN read_at INTEGER",
127
+ // DOD-M12B-ABANDON-NOTIFY-1: epoch-ms when the counterparty told us they force-abandoned.
128
+ // Deliberately NOT a status — the session stays sealable, so the operator can still take a
129
+ // unilateral receipt. It stops this side calling them, nothing more.
130
+ "ALTER TABLE sessions ADD COLUMN counterparty_abandoned_at INTEGER",
131
+ // DOD-CAP-SELF-HEAL-1: WHO caused this session to be interrupted — 'counterparty' when their
132
+ // stream dropped, 'local' when OUR daemon stopped or started. Only theirs counts against the
133
+ // acceptance bound. Without this the bound is all-time rather than concurrent: every restart
134
+ // flips every live session to `interrupted`, nothing ever resolves them, and a pair of agents
135
+ // that has talked three times can never talk again. NULL means "not recorded" and is treated
136
+ // as the counterparty's, because the safe default for an anti-abuse bound is to count it.
137
+ "ALTER TABLE sessions ADD COLUMN interrupted_by TEXT",
138
+ // DOD-M12B-RESTART-SEAL-1: when automatic sealing exhausted this session, and why. Durable
139
+ // because the resolver's attempt budget is in memory — without it a machine that restarts
140
+ // several times a day re-runs the whole budget against a hopeless session on every boot.
141
+ "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_at INTEGER",
142
+ "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_reason TEXT",
143
+ // DOD-M15-DIVERGE-DURABLE-1: epoch-ms when this session's tree and the relay's counter
144
+ // provably parted, so it can never seal bilaterally. NULL = not diverged.
145
+ //
146
+ // DURABLE, and the reason is that the read site cannot tell "not diverged" from "forgotten":
147
+ // both are false and both read READY. `#diverged` was in memory, so a restart turned a
148
+ // session that provably cannot seal into one the gate was happy to close.
149
+ //
150
+ // NOT the trade `frontier-mismatch.ts` makes on purpose. A frontier mismatch is re-detected
151
+ // by the very next close, so losing it costs a recomputation. Divergence is re-detected only
152
+ // by the next send that gets an ack behind the frontier — which on a finished conversation
153
+ // never comes. Losing it costs a WRONG ANSWER.
154
+ "ALTER TABLE sessions ADD COLUMN diverged_at INTEGER",
155
+ ]) {
156
+ try {
157
+ db.exec(ddl);
158
+ }
159
+ catch (err) {
160
+ // Only swallow the idempotent "duplicate column name" case (the column
161
+ // already exists from a prior init). Any other failure — disk full,
162
+ // SQLITE_LOCKED, corruption — must propagate, otherwise the daemon would
163
+ // run without these columns and later silently read undefined.
164
+ const msg = err instanceof Error ? err.message : String(err);
165
+ if (!msg.includes("duplicate column name"))
166
+ throw err;
167
+ }
168
+ }
169
+ // M12-P18: sessions this agent REFUSED (abuse cap etc.). DURABLE and separate from the in-memory
170
+ // refusedSessionRequests inbox list, for one reason: content parked for a refused session arrives
171
+ // AFTER the refusal and often after a restart, and at drain time `counterparty_unknown` cannot
172
+ // tell "content for a session I declined" from "content I might still want". This table is that
173
+ // missing memory. Deleting parked content matched here judges NOTHING about the content — it acts
174
+ // on OUR OWN refusal, so it does not violate the SEC-1 rule that a forgery must not evict itself.
175
+ // Bounded by pruning on write (keep the most recent N per agent); a refused session id is never
176
+ // reused (directory-assigned, unique), so forgetting an old one only means its stale parked
177
+ // content is not proactively swept — the relay TTL backstop still applies.
178
+ db.exec(`
179
+ CREATE TABLE IF NOT EXISTS refused_sessions (
180
+ agent_id TEXT NOT NULL,
181
+ session_id TEXT NOT NULL,
182
+ reason TEXT NOT NULL,
183
+ refused_at INTEGER NOT NULL,
184
+ PRIMARY KEY (agent_id, session_id)
185
+ )
186
+ `);
187
+ // M12-P17: the POST-SEAL ANNEX — verified content that arrived for a session which had already
188
+ // ended. It cannot join the sealed chain (that would change `sealed_root` and invalidate the
189
+ // notarization), and it must not be thrown away: it is a real message, provably sent to this
190
+ // operator, that no one would otherwise ever read.
191
+ //
192
+ // A SEPARATE TABLE is the point, not an implementation detail. Inertness has to be structural:
193
+ // nothing here is joined by `getUnreadSummary`, `getEndedUnread`, any inbox count or any wake
194
+ // path, so this content CANNOT ring a doorbell or reach agent context no matter what a future
195
+ // caller does. If it lived in `transcript` behind a flag, the next reader would key on the row
196
+ // and not the flag — which is exactly how an agent came to obey an instruction out of a sealed
197
+ // conversation.
198
+ //
199
+ // Keyed on (agent_id, content_hash): `session_id` is recorded for display but is NOT part of the
200
+ // key, because the sibling case this design must also serve — content we cannot attribute to a
201
+ // session at all — has no session to key on.
202
+ db.exec(`
203
+ CREATE TABLE IF NOT EXISTS sealed_session_annex (
204
+ agent_id TEXT NOT NULL,
205
+ content_hash TEXT NOT NULL,
206
+ session_id TEXT NOT NULL,
207
+ sender_pubkey TEXT,
208
+ content BLOB NOT NULL,
209
+ arrived_at INTEGER NOT NULL,
210
+ PRIMARY KEY (agent_id, content_hash)
211
+ )
212
+ `);
213
+ // M7-SESSION-001 (H-1): side table holding the verified bilateral
214
+ // SEAL-INTERRUPTED commitment artifacts. A side table (CREATE TABLE IF NOT
215
+ // EXISTS) is inherently idempotent — no ALTER TABLE / duplicate-column
216
+ // handling required. We keep BOTH parties' signed leaves and the agreed
217
+ // Merkle root so the achieved commitment is never discarded.
218
+ db.exec(`
219
+ CREATE TABLE IF NOT EXISTS seal_interrupted_artifacts (
220
+ agent_id TEXT NOT NULL,
221
+ session_id TEXT NOT NULL,
222
+ role TEXT NOT NULL,
223
+ own_leaf TEXT NOT NULL,
224
+ counterparty_leaf TEXT NOT NULL,
225
+ merkle_root TEXT NOT NULL,
226
+ nonce TEXT NOT NULL,
227
+ created_at INTEGER NOT NULL,
228
+ -- DOD-LOOP-1: composite key (per-agent end of a loopback session).
229
+ PRIMARY KEY (agent_id, session_id)
230
+ )
231
+ `);
232
+ // DAEMON-004 (AC-007 / SI-001): the daemon-owned per-session Merkle tree,
233
+ // persisted as an ordered list of leaf hashes. The (session_id, leaf_index)
234
+ // primary key enforces append-order uniqueness; a fresh daemon reconstructs
235
+ // each tree from these rows so the transcript survives a restart. Querying
236
+ // by session_id ORDER BY leaf_index is the only read pattern.
237
+ db.exec(`
238
+ CREATE TABLE IF NOT EXISTS session_tree_leaves (
239
+ agent_id TEXT NOT NULL,
240
+ session_id TEXT NOT NULL,
241
+ leaf_index INTEGER NOT NULL,
242
+ leaf_kind TEXT NOT NULL,
243
+ leaf_hash_hex TEXT NOT NULL,
244
+ created_at INTEGER NOT NULL,
245
+ -- DOD-LOOP-1: composite key so each agent's end has its own append-ordered tree.
246
+ PRIMARY KEY (agent_id, session_id, leaf_index)
247
+ )
248
+ `);
249
+ // DOD-M15-INCLUSION-1: the leaf set the DIRECTORY certified — a different tree from the one
250
+ // above, and the distinction is the whole reason this table exists.
251
+ //
252
+ // `session_tree_leaves` holds this agent's CONTENT leaves. The certified root covers every leaf
253
+ // the relay ordered, CONTROL leaves included, and nothing appends a ctrl leaf to the local tree
254
+ // (`submitSealLeaf` computes its root without mutating it). So an audit path built from
255
+ // `session_tree_leaves` lands on a root no certificate names — it proves this machine agrees
256
+ // with itself, which is worth nothing to the third party a proof is FOR.
257
+ //
258
+ // Rows land only after the Merkle root over them reproduces the FROST-signed `sealed_root`
259
+ // (`certifiedLeafSetFrom`), so what is stored here is the consortium's leaf set and not the
260
+ // directory's word for it. Written once at seal time; read only by the inclusion-proof surface,
261
+ // ORDER BY leaf_index.
262
+ db.exec(`
263
+ CREATE TABLE IF NOT EXISTS session_certified_leaves (
264
+ agent_id TEXT NOT NULL,
265
+ session_id TEXT NOT NULL,
266
+ leaf_index INTEGER NOT NULL,
267
+ content_hash_hex TEXT NOT NULL,
268
+ recorded_at INTEGER NOT NULL,
269
+ -- DOD-LOOP-1: composite key so each agent's end of a loopback session keeps its own set.
270
+ PRIMARY KEY (agent_id, session_id, leaf_index)
271
+ )
272
+ `);
273
+ // WHY a session has no certified leaf set — fallback-finder finding 1, and the reason it is a
274
+ // TABLE rather than a log line.
275
+ //
276
+ // `getCertifiedLeafSet` returns null for four different situations: no seal frame ever carried
277
+ // the leaves, the directory shipped a set that does not reproduce the root it signed, a leaf was
278
+ // malformed, or the write failed. The worst of those — a directory contradicting its own FROST
279
+ // signature — is the strongest misbehaviour signal this client can produce, and it was going to
280
+ // one ERROR line while the operator was told the most benign of the four: "normal for the party
281
+ // that was absent at seal time." A detection whose only consumer is a log is not a control.
282
+ //
283
+ // One row per session, replaced on every attempt, so the state is the LAST thing that happened
284
+ // rather than a history. Read only by the inclusion-proof surface, to name the cause.
285
+ db.exec(`
286
+ CREATE TABLE IF NOT EXISTS session_certified_leaves_state (
287
+ agent_id TEXT NOT NULL,
288
+ session_id TEXT NOT NULL,
289
+ state TEXT NOT NULL,
290
+ detail TEXT,
291
+ recorded_at INTEGER NOT NULL,
292
+ PRIMARY KEY (agent_id, session_id)
293
+ )
294
+ `);
295
+ // DOD-M12B-STRAND-1 — content we RECEIVED and VERIFIED but cannot append yet.
296
+ //
297
+ // Held content used to live only in `#heldContent`, a Map that died with the session node. The
298
+ // teardown path said so itself: "the content is unrecoverable by the time we are here."
299
+ // Measured on one daemon in one morning: 367 held, 8 released, **24 destroyed**. Each
300
+ // destruction is permanent and one-sided — the sender was never acknowledged, so it believes
301
+ // the message is merely pending, while the only copy the receiver will ever see is gone and
302
+ // every later message in that session is stuck behind a gap nothing can fill.
303
+ //
304
+ // `canonical_seq` is the RELAY's position, not a local counter, and it is part of the key: that
305
+ // is what lets a frame come back after a restart and land at its OWN index rather than the next
306
+ // free slot. Appending it anywhere else would change the root the seal signs over.
307
+ //
308
+ // Keyed on agent_id, never agent_name — agent_name is a mutable display label (see the repo
309
+ // guide). `content_blob` is the SCREENED copy that gets delivered; `original_blob` is the peer's
310
+ // raw bytes, which the release path needs because classification reads byte 0 and the screened
311
+ // copy is no longer a CBOR map header for a document frame.
312
+ db.exec(`
313
+ CREATE TABLE IF NOT EXISTS held_content (
314
+ agent_id TEXT NOT NULL,
315
+ session_id TEXT NOT NULL,
316
+ canonical_seq INTEGER NOT NULL,
317
+ content_blob BLOB NOT NULL,
318
+ original_blob BLOB,
319
+ content_hash_hex TEXT NOT NULL,
320
+ screened_out INTEGER NOT NULL DEFAULT 0,
321
+ correlation_id TEXT,
322
+ held_at INTEGER NOT NULL,
323
+ -- DOD-M12B-INDEX-1: 'received' (default) or 'sent'. A held frame of OUR OWN must be
324
+ -- released down the sent path — appended and transcribed as sent — never down the received
325
+ -- path, which would put our words in the counterparty's mouth in the sealed record and hand
326
+ -- them back to our own agent through cello_receive as though they had just arrived.
327
+ origin TEXT NOT NULL DEFAULT 'received',
328
+ -- DOD-M12B-INDEX-1: 'msg' or 'doc'. A held document leaf must come back as a document leaf.
329
+ leaf_kind TEXT NOT NULL DEFAULT 'msg',
330
+ PRIMARY KEY (agent_id, session_id, canonical_seq)
331
+ )
332
+ `);
333
+ // DOD-M12B-INDEX-1: `CREATE TABLE IF NOT EXISTS` is a NO-OP against a table that already
334
+ // exists, so a database created between DOD-M12B-STRAND-1 and this change has `held_content`
335
+ // WITHOUT `origin`. On those every insert throws and every restore throws — holds go back to
336
+ // memory-only, silently at the surface, and that now includes our own sent messages, which
337
+ // nobody else holds a copy of. Loud in the log is not the same as visible.
338
+ try {
339
+ db.exec("ALTER TABLE held_content ADD COLUMN origin TEXT NOT NULL DEFAULT 'received'");
340
+ }
341
+ catch (err) {
342
+ const msg = err instanceof Error ? err.message : String(err);
343
+ if (!/duplicate column name/i.test(msg))
344
+ throw err;
345
+ }
346
+ // DOD-M12B-INDEX-1: and the LEAF KIND. `#releaseHeld` used to append every held frame as "msg",
347
+ // so a document leaf that had to wait for its position came back as a conversation message —
348
+ // the distinction survived the immediate append and was destroyed by the hold, unrecoverably
349
+ // after a restart.
350
+ try {
351
+ db.exec("ALTER TABLE held_content ADD COLUMN leaf_kind TEXT NOT NULL DEFAULT 'msg'");
352
+ }
353
+ catch (err) {
354
+ const msg = err instanceof Error ? err.message : String(err);
355
+ if (!/duplicate column name/i.test(msg))
356
+ throw err;
357
+ }
358
+ // DOD-LOG-1 (PERSIST-LOG-001) / PERSIST-002 (AC-010): the durable, ENCRYPTED-at-rest readable
359
+ // transcript. Each row is keyed by the canonical leaf `sequence`, so it JOINS to
360
+ // session_tree_leaves(leaf_index) — a stored message is provably behind a committed hash-chain
361
+ // leaf, not a loose dump. `blob` holds the readable plaintext bytes; encryption at rest is now
362
+ // provided by whole-DB SQLCipher, not a per-column cipher (relay/directory never see it — INV-3).
363
+ db.exec(`
364
+ CREATE TABLE IF NOT EXISTS transcript (
365
+ agent_id TEXT NOT NULL,
366
+ session_id TEXT NOT NULL,
367
+ sequence INTEGER NOT NULL,
368
+ direction TEXT NOT NULL, -- 'sent' | 'received'
369
+ blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
370
+ created_at INTEGER NOT NULL,
371
+ -- ─── DOD-M15-SEALWIRE-1 bullet 5: the row proves AUTHORSHIP, or says it cannot ──────────
372
+ --
373
+ -- Before this, a row was (message, direction) and attribution came entirely from local
374
+ -- session state: "this arrived on the socket I believed was Bob's". That is fine while the
375
+ -- transcript is only ever read by its owner, and worthless the moment it is shown to anyone
376
+ -- else — which is the whole point of a notarized record.
377
+ --
378
+ -- sender_sig holds one of TWO things, and which one is told by direction:
379
+ -- RECEIVED row -> the sender's signature over their own Structure-1 bytes, carried on the
380
+ -- content frame BESIDE those bytes, stored ONLY after the receiver
381
+ -- verified it against the pubkey inside them (#verifyAuthorshipClaim).
382
+ -- Verified, never claimed.
383
+ -- ⚠️ THIS USED TO READ "the Structure-2 signature ... (#recordFrameOrdering)"
384
+ -- and it named a real place: until DOD-M15-AUTHORSHIP-ABSENT-1 the only
385
+ -- copy of that signature this side ever saw was the one the RELAY had
386
+ -- committed at Structure-2 index 3, so a message with no relay record had
387
+ -- no checkable author at all. Rewritten rather than deleted: an auditor
388
+ -- reading the old sentence goes looking for Structure-2 bytes that, on a
389
+ -- relay-degraded message, do not exist.
390
+ -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
391
+ -- from the submit result. Produced, not verified — there was no
392
+ -- counterparty in the act, so it must NEVER be labelled verified_signature.
393
+ --
394
+ -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
395
+ -- Named here because it is the same shape this column exists to prevent, one level up: a
396
+ -- provable sent row and an unprovable one share a label, so a reader keying on attribution
397
+ -- alone cannot tell them apart.
398
+ -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
399
+ -- self_authored + sender_sig NULL -> we wrote it; no proof was stored for this row
400
+ --
401
+ -- ⚠️ THE NULL CASE USED TO READ "the relay never witnessed it", and DOD-M15-AUTHORSHIP-ABSENT-1
402
+ -- made that false. Every content frame now carries this side's signature over its own
403
+ -- Structure 1 whether or not a relay witnessed the leaf, so an unwitnessed send is provable
404
+ -- too. Rewritten rather than deleted: the old sentence is why a NULL here was read as
405
+ -- ordinary. It is not ordinary now — it means this machine could not sign at all, or the row
406
+ -- came by a path that carries no proof, and both are worth a second look.
407
+ --
408
+ -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
409
+ -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
410
+ -- message with no verified signature, so rows legitimately without one WILL exist. A
411
+ -- nullable signature column and nothing else would rebuild the defect this bullet exists to
412
+ -- fix: a table that IMPLIES every row carries authorship proof, where some carry none and
413
+ -- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
414
+ -- impossible rather than merely discouraged.
415
+ sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
416
+ sender_sig BLOB, -- the VERIFIED sender signature over structure1_cbor (see above); NULL unless verified
417
+ attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
418
+ PRIMARY KEY (agent_id, session_id, sequence, direction)
419
+ )
420
+ `);
421
+ /**
422
+ * DOD-M15-REFUSEDEVIDENCE-1 — the refusal reason on a QUARANTINED row.
423
+ *
424
+ * `direction` takes a third value, `'quarantined'`: a message that was received and REFUSED. It
425
+ * is stored the same way a delivered one is — plaintext blob, sender key, sender signature,
426
+ * attribution — because a hash with no original proves nothing, and the messages worth proving
427
+ * (an injection, a probe, a tampered frame) are exactly the refused ones.
428
+ *
429
+ * ⚠️ THE DIRECTION VALUE IS THE FLAG, AND THAT IS WHY IT IS NOT A BOOLEAN COLUMN. `direction` is
430
+ * in the primary key and every delivery and unread reader already filters it with an equality
431
+ * literal (`findNextReceivedAfter`, `#UNREAD_RECEIVED_WHERE`, `countReceivedMessages`). A row
432
+ * written `'quarantined'` therefore cannot be returned by `WHERE direction = 'received'` — it is
433
+ * excluded BY CONSTRUCTION, with no query edited and none left to remember. A boolean column
434
+ * alone would have been exclusion by EDIT, which rebuilds `DOD-UNREAD-1 D4a`'s phantom-session
435
+ * residue the first time a new query forgets the predicate.
436
+ *
437
+ * `attribution` needs no new value: the expression in `recordTranscriptMessage` is
438
+ * `direction === "sent" ? … : authorship ? "verified_signature" : "local_session_state"`, and
439
+ * `'quarantined'` is not `'sent'` — so a verified frame lands `verified_signature` and an
440
+ * unverified one `local_session_state`, which is the distinction the column exists for.
441
+ */
442
+ // Through `addColumnIfMissing`, not a hand-rolled try/catch — review F7. A bare `ADD COLUMN`
443
+ // wrapped in a duplicate-name test had already been written twice in this codebase, which is
444
+ // why the helper exists; a third copy rethrows correctly but emits no `db.column_birth.failed`,
445
+ // so a failure on a fresh operator's database would name neither the table nor the column. That
446
+ // is exactly the case — the FIRST run on a new machine — the helper was extracted for.
447
+ addColumnIfMissing(db, logger, {
448
+ table: "transcript",
449
+ column: "quarantine_reason",
450
+ sql: "ALTER TABLE transcript ADD COLUMN quarantine_reason TEXT",
451
+ });
452
+ // M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
453
+ // RECEIVED transcript sequence the operator has been shown via cello_receive (delivery marks
454
+ // read — no ack verb). Unread = received transcript rows with sequence > last_delivered_seq.
455
+ // Persisted so a missed doorbell (fire-and-forget push) is reconcilable via cello_check_notifications
456
+ // across daemon restarts, not just within one process (INV-PUSHPULL). Additive table.
457
+ db.exec(`
458
+ CREATE TABLE IF NOT EXISTS message_watermarks (
459
+ agent_id TEXT NOT NULL,
460
+ session_id TEXT NOT NULL,
461
+ last_delivered_seq INTEGER NOT NULL,
462
+ PRIMARY KEY (agent_id, session_id)
463
+ )
464
+ `);
465
+ // M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
466
+ // setting — it belongs alongside message_watermarks/sessions as its own real subsystem, not
467
+ // behind the parked M9-CFG-001 config store. Identity PINS to the pubkey at add time (never
468
+ // re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
469
+ db.exec(`
470
+ CREATE TABLE IF NOT EXISTS contacts (
471
+ agent_id TEXT NOT NULL,
472
+ pubkey TEXT NOT NULL,
473
+ added_at INTEGER NOT NULL,
474
+ PRIMARY KEY (agent_id, pubkey)
475
+ )
476
+ `);
477
+ // MONIKER-3 AC1: the receiver's own pet name for a pubkey — the top tier of whoLabel.
478
+ // SQLite has no ADD COLUMN IF NOT EXISTS, so the ALTER is PRAGMA-guarded to stay
479
+ // idempotent; existing rows → NULL, no data loss.
480
+ // M10B / DOD-END-SURFACE-1 — per-counterparty presentation choice.
481
+ //
482
+ // `default_present` on the signal answers "show this by default"; this answers "show THIS signal
483
+ // to THIS person", which is the finer question an operator actually has: an endorsement that is
484
+ // right for a prospective client is not necessarily right for a competitor. Absent row = no
485
+ // opinion → the signal's own default applies, so this table only ever holds explicit choices.
486
+ //
487
+ // Keys on `agent_id`, never `agent_name` — the name is a mutable display label that is reusable
488
+ // after retirement, so keying on it would silently hand a NEW agent the retired one's
489
+ // disclosure choices. Same key as `contacts`, which this is an extension of.
490
+ db.exec(`
491
+ CREATE TABLE IF NOT EXISTS contact_signal_prefs (
492
+ agent_id TEXT NOT NULL,
493
+ contact_pubkey TEXT NOT NULL,
494
+ signal_hash TEXT NOT NULL,
495
+ present INTEGER NOT NULL,
496
+ set_at INTEGER NOT NULL,
497
+ PRIMARY KEY (agent_id, contact_pubkey, signal_hash)
498
+ )
499
+ `);
500
+ /**
501
+ * DOD-M15-SEALWIRE-1 bullet 5: authorship columns on an EXISTING transcript.
502
+ *
503
+ * BEFORE `migrateSessionTablesToAgentId` — the rebuild copies the intersection of old and new
504
+ * columns, so a column added after it would be dropped on the upgrade boot and re-added empty.
505
+ * These have their second entry in that migration's pinned DDL; `DOD-M15-MIGRATION-GUARD-1`
506
+ * fails the build if the two ever disagree.
507
+ *
508
+ * `addColumnIfMissing` rather than a bare try/catch: it swallows ONLY `duplicate column name`
509
+ * and rethrows anything else, so broken DDL cannot be mistaken for "already applied".
510
+ */
511
+ // Written as three LITERAL statements rather than a loop over a column array. A loop needs its
512
+ // own parser in the guard (as retry_queue does); literals are read by the guard's generic one,
513
+ // so these are replayed automatically and cannot fall outside it.
514
+ addColumnIfMissing(db, logger, {
515
+ table: "transcript", column: "sender_pubkey",
516
+ sql: "ALTER TABLE transcript ADD COLUMN sender_pubkey TEXT",
517
+ });
518
+ addColumnIfMissing(db, logger, {
519
+ table: "transcript", column: "sender_sig",
520
+ sql: "ALTER TABLE transcript ADD COLUMN sender_sig BLOB",
521
+ });
522
+ addColumnIfMissing(db, logger, {
523
+ table: "transcript", column: "attribution",
524
+ sql: "ALTER TABLE transcript ADD COLUMN attribution TEXT NOT NULL DEFAULT 'local_session_state'",
525
+ });
526
+ const contactCols = db.prepare("PRAGMA table_info(contacts)").all();
527
+ if (!contactCols.some((c) => c.name === "moniker")) {
528
+ db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
529
+ }
530
+ // DOD-AGENT-ID-JOINKEY-1: finish REMOVE-001. Re-key the seven child tables from the mutable,
531
+ // reuse-freed `agent_name` to the stable `agent_id`, in ONE transaction. Runs AFTER every
532
+ // CREATE/ALTER above, so an existing table has its full historical column set before it is
533
+ // rebuilt, and BEFORE any read below touches it. A no-op once the tables carry `agent_id`.
534
+ //
535
+ // `retry_queue` (the seventh) is created later, by RetryQueue's constructor. On an existing
536
+ // database it already exists here and is re-keyed in the same transaction; on a fresh one it is
537
+ // absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
538
+ migrateSessionTablesToAgentId(db, logger);
539
+ /**
540
+ * DOD-M15-DIVERGE-DURABLE-1: rehydrate the divergence set from `sessions.diverged_at`.
541
+ *
542
+ * AFTER `migrateSessionTablesToAgentId`, not with the column migrations that create the field.
543
+ * The query joins `sessions.agent_id` to `agents`, and on a database written before REMOVE-001
544
+ * that column does not exist until this migration adds it — placing the load earlier failed
545
+ * with `no such column: s.agent_id` on exactly those legacy databases, which are the ones a
546
+ * restart matters most for.
547
+ */
548
+ loadDivergedFromDb();
549
+ // DOD-TIER-1 (address-book Step 1): give `contacts` its tier metadata (tier / provenance /
550
+ // last_offered_moniker / away_message). Pure ADD COLUMN, no rebuild — so it runs AFTER the
551
+ // agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
552
+ // read below. Idempotent, no column DEFAULT, grandfathers existing contacts to WHITELISTED once.
553
+ migrateContactsAddTierMetadata(db, logger);
554
+ // §1.1: normalize frost_commitments / frost_verifying_shares to ONE CBOR encoding. Registration
555
+ // wrote them with the shared encoder; the refresh path wrote them with cbor-x's bare `encode`,
556
+ // so an agent's share blobs changed format the first time it ran `cello_refresh_shares` and both
557
+ // formats are on disk. Both producers now use encodeCbor; this rewrites what is already stored.
558
+ // Idempotent (a canonical blob re-encodes to itself and is skipped) and per-row fail-safe (an
559
+ // undecodable share is LEFT ALONE, never dropped — losing key material is worse than an old
560
+ // encoding cbor-x still reads).
561
+ migrateCborBlobsToCanonical(db, logger);
562
+ // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
563
+ // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
564
+ // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
565
+ // defaults) and can't legitimately wait for M9. Singleton row (id=1) — "token = daemon
566
+ // setting" (DoD), not per-agent.
567
+ db.exec(`
568
+ CREATE TABLE IF NOT EXISTS telegram_settings (
569
+ id INTEGER PRIMARY KEY CHECK (id = 1),
570
+ bot_token TEXT NOT NULL,
571
+ allowlisted_chat_id TEXT NOT NULL,
572
+ updated_at INTEGER NOT NULL
573
+ )
574
+ `);
575
+ // DOD-RENAME-1 (Option C): pending rename notices — one per (agent, contact). A notice is queued
576
+ // when a peer the operator has PERSONALLY NAMED offers a self-declared name that differs from the
577
+ // last one seen; it surfaces through cello_check_notifications (NOT a real-time push) and clears
578
+ // when the operator adopts a name (cello_contact_set_moniker) or removes the contact. Keyed on
579
+ // agent_id (the stable key); the offered name is charset-validated at the wire boundary but still
580
+ // operator-untrusted, so surfaces render it as a quoted CLAIM.
581
+ db.exec(`
582
+ CREATE TABLE IF NOT EXISTS contact_rename_notices (
583
+ agent_id TEXT NOT NULL,
584
+ pubkey TEXT NOT NULL,
585
+ offered_name TEXT NOT NULL,
586
+ noticed_at INTEGER NOT NULL,
587
+ PRIMARY KEY (agent_id, pubkey)
588
+ )
589
+ `);
590
+ /**
591
+ * A PUBLIC KEY IS BYTES; ITS HEX CASE IS NOT PART OF ITS IDENTITY.
592
+ *
593
+ * ⚠️ **PLACED HERE FOR TWO ORDERING REASONS, and getting either wrong is a crash at boot.** It
594
+ * touches all three contact-keyed tables, so it runs after the LAST of them exists
595
+ * (`contact_rename_notices`, directly above); and the merge it performs on a collision keeps the
596
+ * more restrictive TIER, which it cannot read until `migrateContactsAddTierMetadata` has added
597
+ * that column.
598
+ *
599
+ * Normalizing the accessors alone would be worse than the bug for anyone who already has a
600
+ * mixed-case row: the row becomes UNREACHABLE rather than merely wrong, taking its block, its
601
+ * away message and its pet name with it. Idempotent and silent on a clean database.
602
+ */
603
+ foldContactPubkeyCase(db, logger);
604
+ // DOD-M15-NO-SILENT-REFUSAL-1: refusal notices — one per (agent, session, reason). Written every
605
+ // time an inbound message is refused; read by cello_receive and by the cello_inbox pull. Modelled
606
+ // on contact_rename_notices above and keyed the same way, on agent_id (the stable key) — the map
607
+ // this replaced was keyed on agent_name, a mutable display label, which was its second bug.
608
+ //
609
+ // DURABLE because the case this exists for is NOBODY ATTENDING. A notice held only in memory is
610
+ // lost to a restart and is only ever surfaced to whoever happens to call cello_receive on that
611
+ // exact session, which is a log line with extra steps.
612
+ //
613
+ // `content_refusal_reads` is the part rename notices do not need: they clear on operator action,
614
+ // these are read non-destructively PER CONSUMER. Two MCP windows attending one agent is ordinary,
615
+ // and under a single surfaced flag the first reader consumed the notice and the second was told
616
+ // nothing, permanently.
617
+ db.exec(`
618
+ CREATE TABLE IF NOT EXISTS content_refusal_notices (
619
+ agent_id TEXT NOT NULL,
620
+ session_id TEXT NOT NULL,
621
+ reason TEXT NOT NULL,
622
+ kind TEXT NOT NULL,
623
+ impact TEXT NOT NULL,
624
+ guidance TEXT NOT NULL,
625
+ count INTEGER NOT NULL,
626
+ first_at INTEGER NOT NULL,
627
+ last_at INTEGER NOT NULL,
628
+ PRIMARY KEY (agent_id, session_id, reason)
629
+ )
630
+ `);
631
+ /**
632
+ * DOD-M15-REFUSALTERMINAL-1 — the lifetime refusal count, which `content_refusal_notices` is
633
+ * NOT and never was.
634
+ *
635
+ * `cello_dismiss` DELETEs the notice row (`dismissContentRefusals`), so `notices.count` restarts
636
+ * at 1 after every dismissal. That is correct for the notice — the operator said "I know" and
637
+ * the next announcement should describe what happened since — and it is exactly why the number
638
+ * shown beside it cannot be described as a lifetime figure. Live on 2026-09-04 an inbox reported
639
+ * `times: 58` for a refusal that had fired tens of thousands of times.
640
+ *
641
+ * A separate table rather than a column, because the two have different lifetimes: this one is
642
+ * never deleted by anything an operator does. Same key, so the read is one LEFT JOIN.
643
+ */
644
+ db.exec(`
645
+ CREATE TABLE IF NOT EXISTS content_refusal_totals (
646
+ agent_id TEXT NOT NULL,
647
+ session_id TEXT NOT NULL,
648
+ reason TEXT NOT NULL,
649
+ total INTEGER NOT NULL,
650
+ first_at INTEGER NOT NULL,
651
+ last_at INTEGER NOT NULL,
652
+ -- 1 when this row was SEEDED from an existing notice at upgrade rather than counted from
653
+ -- the first refusal. Its total is then a LOWER BOUND, not a figure, and the drain reports
654
+ -- it under a different field name so a reader cannot mistake one for the other.
655
+ seeded INTEGER NOT NULL DEFAULT 0,
656
+ PRIMARY KEY (agent_id, session_id, reason)
657
+ )
658
+ `);
659
+ /**
660
+ * ⚠️ **THE BACKFILL, and without it this unit ships the original lie with the new name on it.**
661
+ *
662
+ * Review finding 1. A new table is created EMPTY. Every daemon that already has refusal notices
663
+ * — including the one that produced this incident, whose notice sat at 58 — would report
664
+ * `times_since_dismissed: 59` beside a `times_total` of **1**, on the very field the guidance
665
+ * tells an operator to judge severity by. Smaller than the number it exists to dwarf.
666
+ *
667
+ * `count` is the best figure available at upgrade and it is a LOWER BOUND: dismissals before
668
+ * this build deleted history nothing can recover. So the row is marked `seeded` and reported as
669
+ * "at least", never as a total. A lower bound is a true statement; `total = 1` is not.
670
+ *
671
+ * `INSERT OR IGNORE` makes it idempotent and self-healing — it fills only rows that do not
672
+ * exist, so a real counted total is never overwritten by a seeded one, and running it at every
673
+ * boot costs one indexed scan of a table bounded by (sessions × reasons).
674
+ */
675
+ /**
676
+ * ⚠️ **AND `CREATE TABLE IF NOT EXISTS` IS A NO-OP AGAINST A TABLE THAT ALREADY EXISTS** —
677
+ * review F1b, and it is the same hazard `DOD-M12B-INDEX-1` records for `held_content.origin`
678
+ * three hundred lines above.
679
+ *
680
+ * The table shipped one commit earlier WITHOUT `seeded`, and that build ran on a real daemon to
681
+ * take this unit's live measurement. On that machine the `CREATE` does nothing, the backfill
682
+ * below names a column that is not there, and the throw comes out of schema init — **the daemon
683
+ * does not open at all.** The one machine that most needs the backfill is the one it would have
684
+ * bricked.
685
+ */
686
+ try {
687
+ db.exec("ALTER TABLE content_refusal_totals ADD COLUMN seeded INTEGER NOT NULL DEFAULT 0");
688
+ }
689
+ catch (err) {
690
+ const msg = err instanceof Error ? err.message : String(err);
691
+ if (!/duplicate column name/i.test(msg))
692
+ throw err;
693
+ }
694
+ db.exec(`
695
+ INSERT OR IGNORE INTO content_refusal_totals
696
+ (agent_id, session_id, reason, total, first_at, last_at, seeded)
697
+ SELECT agent_id, session_id, reason, count, first_at, last_at, 1
698
+ FROM content_refusal_notices
699
+ `);
700
+ /**
701
+ * ⚠️ **THE INVARIANT: a lifetime total can never be SMALLER than a since-dismissal count.**
702
+ * Caught on the live daemon, not by review — the inbox read
703
+ * `times_since_dismissed: 78, times_total: 12`.
704
+ *
705
+ * `INSERT OR IGNORE` above only fills rows that are ABSENT. A row that already exists but began
706
+ * counting AFTER the notice did — the totals table shipped one commit before `seeded`, so its
707
+ * rows default to 0 and claim to be exact — is left alone, and then presents a partial tally as
708
+ * a lifetime figure. Smaller than the number beside it, which is the tell.
709
+ *
710
+ * `count` resets on dismissal and `total` does not, so in healthy operation `total >= count`
711
+ * always. `count > total` therefore means one thing only: this row's total did not start at the
712
+ * beginning. Repaired to the best floor available and marked `seeded`, because that is what it
713
+ * is. Runs at every boot — it is also the repair for a totals write that failed while the
714
+ * notice's succeeded.
715
+ */
716
+ db.exec(`
717
+ UPDATE content_refusal_totals
718
+ SET total = (SELECT n.count FROM content_refusal_notices n
719
+ WHERE n.agent_id = content_refusal_totals.agent_id
720
+ AND n.session_id = content_refusal_totals.session_id
721
+ AND n.reason = content_refusal_totals.reason),
722
+ seeded = 1
723
+ WHERE EXISTS (SELECT 1 FROM content_refusal_notices n
724
+ WHERE n.agent_id = content_refusal_totals.agent_id
725
+ AND n.session_id = content_refusal_totals.session_id
726
+ AND n.reason = content_refusal_totals.reason
727
+ AND n.count > content_refusal_totals.total)
728
+ `);
729
+ /**
730
+ * DOD-M15-REFUSALTERMINAL-1 — content this agent will never accept, so the daemon stops going
731
+ * to fetch it.
732
+ *
733
+ * **DURABLE BECAUSE THE DEFECT CROSSED RESTARTS.** The 62-hour loop spanned several `cello
734
+ * login` cycles; a marker held in a `Set` on the manager would have passed every test and
735
+ * shipped nothing.
736
+ *
737
+ * NOT the `'quarantined'` transcript row, which is the natural candidate and does not work: it
738
+ * is keyed on the BYTES, and the fetch scheduler is keyed on the content hash the sender
739
+ * committed to. On the two refusals where those provably differ (a tamper, an algorithm we
740
+ * cannot read) the row cannot answer the question this table is asked.
741
+ *
742
+ * Keyed on `agent_id` — the stable key. `agent_name` is a display label.
743
+ */
744
+ db.exec(`
745
+ CREATE TABLE IF NOT EXISTS terminal_content_refusals (
746
+ agent_id TEXT NOT NULL,
747
+ session_id TEXT NOT NULL,
748
+ content_hash TEXT NOT NULL,
749
+ reason TEXT NOT NULL,
750
+ marked_at INTEGER NOT NULL,
751
+ PRIMARY KEY (agent_id, session_id, content_hash)
752
+ )
753
+ `);
754
+ db.exec(`
755
+ CREATE TABLE IF NOT EXISTS content_refusal_reads (
756
+ agent_id TEXT NOT NULL,
757
+ session_id TEXT NOT NULL,
758
+ reason TEXT NOT NULL,
759
+ consumer_id TEXT NOT NULL,
760
+ seen_count INTEGER NOT NULL,
761
+ seen_at INTEGER NOT NULL,
762
+ PRIMARY KEY (agent_id, session_id, reason, consumer_id)
763
+ )
764
+ `);
765
+ // DOD-SETTINGS-1: a daemon-side per-agent settings store for REACHABILITY POLICY (the tier bounds
766
+ // overrides and the per-tier/agent away messages). A generic key-value table on the stable
767
+ // agent_id, in the same SQLCipher DB. Deliberately NOT M9-CFG-001's gateway config store: this is
768
+ // daemon reachability policy, not gateway SCREENING config, and the M9 store is unwired + plaintext.
769
+ // reconcile with DOD-CONFIG-1 later; this is daemon reachability policy, not gateway config.
770
+ db.exec(`
771
+ CREATE TABLE IF NOT EXISTS agent_settings (
772
+ agent_id TEXT NOT NULL,
773
+ key TEXT NOT NULL,
774
+ value TEXT NOT NULL,
775
+ updated_at INTEGER NOT NULL,
776
+ PRIMARY KEY (agent_id, key)
777
+ )
778
+ `);
779
+ // M10 / DOD-STORE-CLIENT-1: the two trust-signal tables (wallet + received). Created HERE and
780
+ // deliberately last: `contact_trust_signals` carries a composite FK to `contacts(agent_id,
781
+ // pubkey)`, so its parent must exist and must already have been through the agent-id re-key
782
+ // above. SQLite resolves an FK's parent at DML time, not DDL time — so getting this order wrong
783
+ // would not fail here, it would fail on the first insert, which is a far worse place to find out.
784
+ ensureTrustSignalSchema(db, logger);
785
+ }
786
+ //# sourceMappingURL=session-schema.js.map