polygram 0.38.2 → 0.38.3

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.
package/lib/db.js CHANGED
@@ -26,6 +26,48 @@ const {
26
26
  const {
27
27
  sanitizeCodexFaultProvenance,
28
28
  } = require('./codex/fault-provenance');
29
+ const {
30
+ sanitizeForDurableWrite,
31
+ sanitizeDurableStructured,
32
+ sanitizeDurableJsonText,
33
+ } = require('./secret-detect');
34
+ const {
35
+ enforceEventDetailSchema,
36
+ } = require('./db/event-detail-schema');
37
+
38
+ // Pre-write secret boundary. Every durable text sink below routes its text
39
+ // through this so a recognized credential never reaches a row, the
40
+ // external-content FTS index that mirrors it, a persisted error, or telemetry.
41
+ // The caller's own value is untouched: the live provider turn always sees the
42
+ // original message. Detection is deterministic shape/keyword matching, so
43
+ // prose that never declares a credential is not covered here — the sweep and
44
+ // the agent-reported redaction remain the defense-in-depth behind it.
45
+ const durableText = (value) => (
46
+ typeof value === 'string' ? sanitizeForDurableWrite(value).text : value
47
+ );
48
+
49
+ // Truncating a masked text can cut a placeholder in half, leaving a fragment
50
+ // like `‹redacted:kv-sec` that reads as ordinary text and no longer survives
51
+ // a second masking pass unchanged. Cut before the placeholder that straddles
52
+ // the limit instead.
53
+ const truncateAfterMask = (text, limit) => {
54
+ if (typeof text !== 'string' || text.length <= limit) return text;
55
+ const cut = text.slice(0, limit);
56
+ const open = cut.lastIndexOf('‹');
57
+ if (open !== -1 && cut.indexOf('›', open) === -1) return cut.slice(0, open);
58
+ return cut;
59
+ };
60
+
61
+ // A JSON-valued column is sanitized as STRUCTURE, not as text: masking a
62
+ // serialized document can splice across its delimiters, and an escaped quote
63
+ // hides a declared value from the detector. Callers usually hand over an
64
+ // object; a string is parsed back so a caller that pre-serialized still gets
65
+ // the structural pass. Text that is not JSON falls back to string masking.
66
+ const durableJson = (value) => {
67
+ if (value == null) return value;
68
+ if (typeof value !== 'string') return JSON.stringify(sanitizeDurableStructured(value));
69
+ return sanitizeDurableJsonText(value).text;
70
+ };
29
71
 
30
72
  // 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
31
73
  // 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
@@ -45,7 +87,7 @@ const {
45
87
  //
46
88
  // 0.15: bumped 13 → 14. Adds migration 014-secret-redactions.sql
47
89
  // (secret_redactions audit table + messages.secret_scanned_at).
48
- const SCHEMA_VERSION = 19;
90
+ const SCHEMA_VERSION = 20;
49
91
 
50
92
  // Sentinel `error` value for outbound rows whose API call may or may not
51
93
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -1421,10 +1463,20 @@ function wrap(db) {
1421
1463
  itemId,
1422
1464
  detailJson,
1423
1465
  })) {
1424
- return { changes: 0, attemptId, kind };
1466
+ const deferred = (
1467
+ kind === 'telegram-delivery-failed'
1468
+ && isCleanRetirementDelivery(attempt)
1469
+ );
1470
+ return {
1471
+ changes: 0,
1472
+ attemptId,
1473
+ kind,
1474
+ ...(deferred ? { deferred: true } : {}),
1475
+ };
1425
1476
  }
1426
1477
  }
1427
1478
  assertCodexCheckpointOwner(generation, kind);
1479
+ let deferred = false;
1428
1480
 
1429
1481
  if (kind === 'request-prepared') {
1430
1482
  requiredCodexString(method, 'request method');
@@ -1728,21 +1780,24 @@ function wrap(db) {
1728
1780
  );
1729
1781
  }
1730
1782
  const deliveryFailed = kind === 'telegram-delivery-failed';
1731
- const linkedState = deliveryFailed
1732
- ? 'failed'
1733
- : attempt.terminal_status === 'interrupted'
1734
- ? 'interrupted'
1735
- : attempt.terminal_status === 'failed'
1736
- ? 'failed'
1737
- : 'settled';
1738
- settleCodexAttemptConsumerOutcome({
1739
- attempt,
1740
- generationId,
1741
- linkedState,
1742
- attemptRecoveryState: 'settled',
1743
- settleQueuedReservation: true,
1744
- ts,
1745
- });
1783
+ deferred = deliveryFailed && isCleanRetirementDelivery(attempt);
1784
+ if (!deferred) {
1785
+ const linkedState = deliveryFailed
1786
+ ? 'failed'
1787
+ : attempt.terminal_status === 'interrupted'
1788
+ ? 'interrupted'
1789
+ : attempt.terminal_status === 'failed'
1790
+ ? 'failed'
1791
+ : 'settled';
1792
+ settleCodexAttemptConsumerOutcome({
1793
+ attempt,
1794
+ generationId,
1795
+ linkedState,
1796
+ attemptRecoveryState: 'settled',
1797
+ settleQueuedReservation: true,
1798
+ ts,
1799
+ });
1800
+ }
1746
1801
  } else if (
1747
1802
  ['active-start-cancelled', 'queued-send-cancelled'].includes(kind)
1748
1803
  ) {
@@ -1902,7 +1957,166 @@ function wrap(db) {
1902
1957
  detailJson,
1903
1958
  ts,
1904
1959
  );
1905
- return { changes: 1, attemptId, kind };
1960
+ return {
1961
+ changes: 1,
1962
+ attemptId,
1963
+ kind,
1964
+ ...(deferred ? { deferred: true } : {}),
1965
+ };
1966
+ })();
1967
+ }
1968
+
1969
+ function isCleanRetirementDelivery(attempt) {
1970
+ if (
1971
+ attempt?.method !== 'turn/start'
1972
+ || attempt.terminal_status !== 'interrupted'
1973
+ || !['terminal-pending', 'clean-pending'].includes(
1974
+ attempt.recovery_state,
1975
+ )
1976
+ ) return false;
1977
+ return db.prepare(`
1978
+ SELECT 1
1979
+ FROM codex_attempt_checkpoints
1980
+ WHERE generation_id = ?
1981
+ AND attempt_id = ?
1982
+ AND kind = 'clean-retirement-requested'
1983
+ AND thread_id = ?
1984
+ AND turn_id = ?
1985
+ LIMIT 1
1986
+ `).get(
1987
+ attempt.generation_id,
1988
+ attempt.attempt_id,
1989
+ attempt.thread_id,
1990
+ attempt.turn_id,
1991
+ ) != null;
1992
+ }
1993
+
1994
+ function prepareCodexCleanRetirement(input) {
1995
+ const allowed = new Set([
1996
+ 'generation_id',
1997
+ 'session_key',
1998
+ 'attempt_id',
1999
+ 'provider_session_id',
2000
+ 'provider_turn_id',
2001
+ 'source_message_id',
2002
+ 'stable_host_id',
2003
+ 'boot_session_id',
2004
+ 'ts',
2005
+ ]);
2006
+ assertExactKeys(
2007
+ input,
2008
+ allowed,
2009
+ 'CODEX_RETIREMENT_PREPARATION_REJECTED',
2010
+ 'clean retirement preparation',
2011
+ );
2012
+ const generationId = requiredCodexString(
2013
+ input?.generation_id,
2014
+ 'generation ID',
2015
+ );
2016
+ const sessionKey = requiredCodexString(input?.session_key, 'session key');
2017
+ const attemptId = requiredCodexString(input?.attempt_id, 'attempt ID');
2018
+ const providerSessionId = requiredCodexString(
2019
+ input?.provider_session_id,
2020
+ 'provider session ID',
2021
+ );
2022
+ const providerTurnId = requiredCodexString(
2023
+ input?.provider_turn_id,
2024
+ 'provider turn ID',
2025
+ );
2026
+ const sourceMessageId = requiredCodexString(
2027
+ input?.source_message_id == null
2028
+ ? input?.source_message_id
2029
+ : String(input.source_message_id),
2030
+ 'source message ID',
2031
+ );
2032
+ const stableHostId = requiredCodexString(
2033
+ input?.stable_host_id,
2034
+ 'stable host identity',
2035
+ );
2036
+ const bootSessionId = requiredCodexString(
2037
+ input?.boot_session_id,
2038
+ 'boot-session identity',
2039
+ );
2040
+ const ts = requiredTimestamp(
2041
+ input?.ts ?? Date.now(),
2042
+ 'clean retirement preparation timestamp',
2043
+ );
2044
+
2045
+ return db.transaction(() => {
2046
+ const generation = assertCodexGenerationIdentity(
2047
+ generationId,
2048
+ stableHostId,
2049
+ bootSessionId,
2050
+ );
2051
+ assertCodexCheckpointOwner(generation, 'clean-retirement-requested');
2052
+ const attempt = requireCodexAttempt(attemptId, generationId);
2053
+ const accepted = hasCodexTurnCheckpoint(
2054
+ generationId,
2055
+ attemptId,
2056
+ 'turn-accepted',
2057
+ providerTurnId,
2058
+ );
2059
+ if (
2060
+ generation.session_key !== sessionKey
2061
+ || attempt.session_key !== sessionKey
2062
+ || attempt.method !== 'turn/start'
2063
+ || attempt.thread_id !== providerSessionId
2064
+ || attempt.turn_id !== providerTurnId
2065
+ || attempt.telegram_source_message_id !== sourceMessageId
2066
+ || attempt.delivery_state !== 'response-observed'
2067
+ || attempt.response_outcome !== 'result'
2068
+ || attempt.recovery_state !== 'active'
2069
+ || attempt.terminal_status != null
2070
+ || !accepted
2071
+ ) {
2072
+ throw codexError(
2073
+ 'Codex clean retirement target is not the exact active accepted turn',
2074
+ 'CODEX_RETIREMENT_PREPARATION_REJECTED',
2075
+ );
2076
+ }
2077
+ const existing = db.prepare(`
2078
+ SELECT thread_id, turn_id
2079
+ FROM codex_attempt_checkpoints
2080
+ WHERE generation_id = ?
2081
+ AND attempt_id = ?
2082
+ AND kind = 'clean-retirement-requested'
2083
+ `).all(generationId, attemptId);
2084
+ if (existing.length > 0) {
2085
+ if (
2086
+ existing.length === 1
2087
+ && existing[0].thread_id === providerSessionId
2088
+ && existing[0].turn_id === providerTurnId
2089
+ ) {
2090
+ return {
2091
+ changes: 0,
2092
+ disposition: 'retirement-requested',
2093
+ generationId,
2094
+ attemptId,
2095
+ };
2096
+ }
2097
+ throw codexError(
2098
+ 'Codex clean retirement target has conflicting ownership',
2099
+ 'CODEX_RETIREMENT_PREPARATION_REJECTED',
2100
+ );
2101
+ }
2102
+ db.prepare(`
2103
+ INSERT INTO codex_attempt_checkpoints (
2104
+ generation_id, attempt_id, kind, thread_id, turn_id,
2105
+ request_id, item_id, detail_json, ts
2106
+ ) VALUES (?, ?, 'clean-retirement-requested', ?, ?, NULL, NULL, NULL, ?)
2107
+ `).run(
2108
+ generationId,
2109
+ attemptId,
2110
+ providerSessionId,
2111
+ providerTurnId,
2112
+ ts,
2113
+ );
2114
+ return {
2115
+ changes: 1,
2116
+ disposition: 'retirement-requested',
2117
+ generationId,
2118
+ attemptId,
2119
+ };
1906
2120
  })();
1907
2121
  }
1908
2122
 
@@ -1930,7 +2144,7 @@ function wrap(db) {
1930
2144
  }
1931
2145
  return db.transaction(() => {
1932
2146
  const result = recordCodexCheckpoint(checkpoint);
1933
- if (!input.retireGeneration) {
2147
+ if (!input.retireGeneration || result.deferred === true) {
1934
2148
  return { ...result, retired: false };
1935
2149
  }
1936
2150
  const generationId = requiredCodexString(
@@ -3788,7 +4002,7 @@ function wrap(db) {
3788
4002
  msg_id: row.msg_id,
3789
4003
  user: row.user || null,
3790
4004
  user_id: row.user_id || null,
3791
- text: row.text || '',
4005
+ text: durableText(row.text || ''),
3792
4006
  reply_to_id: row.reply_to_id || null,
3793
4007
  direction: row.direction || 'in',
3794
4008
  source: row.source || 'polygram',
@@ -3798,7 +4012,7 @@ function wrap(db) {
3798
4012
  effort: row.effort || null,
3799
4013
  turn_id: row.turn_id || null,
3800
4014
  status: row.status || 'received',
3801
- error: row.error || null,
4015
+ error: durableText(row.error) || null,
3802
4016
  cost_usd: row.cost_usd ?? null,
3803
4017
  ts: row.ts || Date.now(),
3804
4018
  });
@@ -3809,7 +4023,7 @@ function wrap(db) {
3809
4023
  chat_id: String(row.chat_id),
3810
4024
  thread_id: row.thread_id ? String(row.thread_id) : null,
3811
4025
  user: row.user || null,
3812
- text: row.text || '',
4026
+ text: durableText(row.text || ''),
3813
4027
  source: row.source || 'polygram',
3814
4028
  bot_name: row.bot_name || null,
3815
4029
  turn_id: row.turn_id || null,
@@ -3825,7 +4039,10 @@ function wrap(db) {
3825
4039
  },
3826
4040
 
3827
4041
  markOutboundFailed(id, err) {
3828
- return markOutboundFailedStmt.run({ id, error: String(err).slice(0, 500) });
4042
+ return markOutboundFailedStmt.run({
4043
+ id,
4044
+ error: truncateAfterMask(durableText(String(err)), 500),
4045
+ });
3829
4046
  },
3830
4047
 
3831
4048
  updateOutboundText({ chat_id, msg_id, text, ts }) {
@@ -3833,7 +4050,7 @@ function wrap(db) {
3833
4050
  return updateOutboundTextStmt.run({
3834
4051
  chat_id: String(chat_id),
3835
4052
  msg_id: Number(msg_id),
3836
- text,
4053
+ text: durableText(text),
3837
4054
  ts: ts || Date.now(),
3838
4055
  });
3839
4056
  },
@@ -3898,6 +4115,7 @@ function wrap(db) {
3898
4115
  },
3899
4116
 
3900
4117
  createCodexGeneration,
4118
+ prepareCodexCleanRetirement,
3901
4119
  recordCodexCheckpoint,
3902
4120
  recordCodexDeliveryCheckpoint,
3903
4121
  getCodexAttempt(attemptId) {
@@ -3963,7 +4181,7 @@ function wrap(db) {
3963
4181
  return setMessageTextStmt.run({
3964
4182
  chat_id: String(chat_id),
3965
4183
  msg_id,
3966
- text: text ?? '',
4184
+ text: durableText(text ?? ''),
3967
4185
  });
3968
4186
  },
3969
4187
 
@@ -3976,16 +4194,28 @@ function wrap(db) {
3976
4194
  return row?.new_chat_id || String(chatId);
3977
4195
  },
3978
4196
 
4197
+ // Telemetry passes two gates before it is persisted: the content-free
4198
+ // field schema decides WHICH fields may exist at all, then the durable
4199
+ // secret boundary masks recognized credentials in what survived. The
4200
+ // schema is the primary guarantee — masking alone would only remove
4201
+ // content a detector recognizes.
3979
4202
  logEvent(kind, { chat_id = null, ...detail } = {}) {
4203
+ const { detail: allowed, dropped, droppedCount } = enforceEventDetailSchema(detail);
4204
+ // The count is always safe to record; the names only when they came
4205
+ // from the schema's own vocabulary (an unknown key can be content).
4206
+ if (droppedCount) allowed.dropped_field_count = droppedCount;
4207
+ if (dropped.length) allowed.dropped_fields = dropped;
3980
4208
  return logEventStmt.run(
3981
4209
  Date.now(),
3982
4210
  chat_id ? String(chat_id) : null,
3983
4211
  kind,
3984
- Object.keys(detail).length ? JSON.stringify(detail) : null,
4212
+ Object.keys(allowed).length
4213
+ ? JSON.stringify(sanitizeDurableStructured(allowed))
4214
+ : null,
3985
4215
  );
3986
4216
  },
3987
4217
 
3988
- // rc.61: find compact-command events from the recent window that
4218
+ // Find compact-command events from the recent window that
3989
4219
  // never produced a matching compact-boundary on the same
3990
4220
  // session_key. These are "interrupted by deploy" cases — polygram
3991
4221
  // pushed /compact into SDK input but the daemon restarted before
@@ -3997,23 +4227,38 @@ function wrap(db) {
3997
4227
  // session_key AND ts > compact_command.ts. If none within
3998
4228
  // search-window, it's orphaned.
3999
4229
  //
4230
+ // The command text itself is message content, so telemetry carries only
4231
+ // the id of the inbound message that holds it. The text is joined back
4232
+ // from that row — already sanitized by the durable-write boundary — and
4233
+ // is null when the row is gone or the event predates the id, which the
4234
+ // caller treats as "surface a retry notice instead of replaying".
4235
+ //
4236
+ // Events written before the hint moved out of telemetry still carry it in
4237
+ // their detail. Those stay recoverable, from the row when one exists and
4238
+ // otherwise from the stored copy, sanitized on the way out so a legacy
4239
+ // row cannot hand a credential back.
4240
+ //
4000
4241
  // @param {object} opts
4001
4242
  // @param {number} opts.olderThanMs - cutoff (only events newer than this are scanned)
4002
- // @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len}>}
4243
+ // @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len, text}>}
4003
4244
  findOrphanedCompactCommands({ olderThanMs = 30 * 60 * 1000 } = {}) {
4004
4245
  const cutoff = Date.now() - olderThanMs;
4005
4246
  const cmds = db.prepare(`
4006
- SELECT id, ts, chat_id,
4007
- json_extract(detail_json, '$.thread_id') AS thread_id,
4008
- json_extract(detail_json, '$.session_key') AS session_key,
4009
- json_extract(detail_json, '$.user') AS user,
4010
- json_extract(detail_json, '$.user_id') AS user_id,
4011
- json_extract(detail_json, '$.text_len') AS text_len,
4012
- json_extract(detail_json, '$.text') AS text
4013
- FROM events
4014
- WHERE kind = 'compact-command'
4015
- AND ts > ?
4016
- ORDER BY ts ASC, id ASC
4247
+ SELECT event.id, event.ts, event.chat_id,
4248
+ json_extract(event.detail_json, '$.thread_id') AS thread_id,
4249
+ json_extract(event.detail_json, '$.session_key') AS session_key,
4250
+ json_extract(event.detail_json, '$.user_id') AS user_id,
4251
+ json_extract(event.detail_json, '$.text_len') AS text_len,
4252
+ source.text AS row_text,
4253
+ json_extract(event.detail_json, '$.text') AS legacy_text
4254
+ FROM events AS event
4255
+ LEFT JOIN messages AS source
4256
+ ON source.chat_id = event.chat_id
4257
+ AND source.msg_id = json_extract(event.detail_json, '$.msg_id')
4258
+ AND source.direction = 'in'
4259
+ WHERE event.kind = 'compact-command'
4260
+ AND event.ts > ?
4261
+ ORDER BY event.ts ASC, event.id ASC
4017
4262
  `).all(cutoff);
4018
4263
  const orphans = [];
4019
4264
  for (const c of cmds) {
@@ -4030,7 +4275,7 @@ function wrap(db) {
4030
4275
  LIMIT 1
4031
4276
  `).get(c.id, c.session_key);
4032
4277
  if (boundary) continue;
4033
- // rc.66: also skip if a previous boot has already handled
4278
+ // Also skip if a previous boot has already handled
4034
4279
  // this orphan (silent replay via compact-replay event, OR
4035
4280
  // surface-fallback via compact-failed-restart event). Both
4036
4281
  // of those record `original_ts` in their detail_json
@@ -4044,7 +4289,15 @@ function wrap(db) {
4044
4289
  LIMIT 1
4045
4290
  `).get(c.ts);
4046
4291
  if (handled) continue;
4047
- orphans.push(c);
4292
+ const { row_text, legacy_text, ...rest } = c;
4293
+ // Both sources are sanitized on the way out. The row is normally
4294
+ // masked at write, but rows written before this boundary existed are
4295
+ // not — and those are exactly the oldest, most likely to be replayed.
4296
+ const recovered = row_text ?? legacy_text;
4297
+ orphans.push({
4298
+ ...rest,
4299
+ text: recovered != null ? durableText(recovered) : null,
4300
+ });
4048
4301
  }
4049
4302
  return orphans;
4050
4303
  },
@@ -4068,7 +4321,8 @@ function wrap(db) {
4068
4321
  num_assistant_messages: row.num_assistant_messages ?? null,
4069
4322
  num_tool_uses: row.num_tool_uses ?? null,
4070
4323
  result_subtype: row.result_subtype || null,
4071
- error: row.error || null,
4324
+ // The turn's failure text can echo the provider's own output.
4325
+ error: durableText(row.error) || null,
4072
4326
  });
4073
4327
  },
4074
4328
 
@@ -4111,7 +4365,12 @@ function wrap(db) {
4111
4365
  chat_id: String(row.chat_id),
4112
4366
  tool_name: String(row.tool_name),
4113
4367
  match_type: row.match_type,
4114
- input_pattern: row.input_pattern,
4368
+ // The pattern is derived from a tool input, so it can carry a
4369
+ // credential into durable storage. Masking it means a rule whose
4370
+ // pattern contained one no longer matches that live input: the call
4371
+ // falls through to a fresh approval prompt rather than silently
4372
+ // auto-allowing or auto-denying it.
4373
+ input_pattern: durableText(row.input_pattern),
4115
4374
  decision: row.decision,
4116
4375
  issued_ts: row.issued_ts || Date.now(),
4117
4376
  issued_by_user_id: row.issued_by_user_id != null
@@ -4558,7 +4817,6 @@ function wrap(db) {
4558
4817
  redactSecretInChat({ chat_id, thread_id = null, secret, now = Date.now(), limit = 200 }) {
4559
4818
  if (typeof secret !== 'string' || secret.length < 3) return { redacted: 0 };
4560
4819
  const PLACEHOLDER = '‹redacted:reported›';
4561
- const sha = require('crypto').createHash('sha256').update(secret).digest('hex');
4562
4820
  const rows = (thread_id != null
4563
4821
  ? db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND thread_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), String(thread_id), limit)
4564
4822
  : db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), limit));
@@ -4568,8 +4826,8 @@ function wrap(db) {
4568
4826
  if (!r.text || !r.text.includes(secret)) continue;
4569
4827
  const newText = r.text.split(secret).join(PLACEHOLDER);
4570
4828
  db.prepare('UPDATE messages SET text = ? WHERE id = ?').run(newText, r.id);
4571
- db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length, sha256, action, ts)
4572
- VALUES (?,?,?,?,?,?,?,?)`).run(String(chat_id), r.msg_id, 'reported', 'reported', secret.length, sha, 'redacted', now);
4829
+ db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length, action, ts)
4830
+ VALUES (?,?,?,?,?,?,?)`).run(String(chat_id), r.msg_id, 'reported', 'reported', secret.length, 'redacted', now);
4573
4831
  redacted += 1;
4574
4832
  }
4575
4833
  });
@@ -4605,7 +4863,8 @@ function wrap(db) {
4605
4863
  file_id,
4606
4864
  file_unique_id || null,
4607
4865
  kind,
4608
- name || null,
4866
+ // A user-supplied filename can carry a declared credential.
4867
+ durableText(name) || null,
4609
4868
  mime_type || null,
4610
4869
  size_bytes ?? null,
4611
4870
  ts || Date.now(),
@@ -4629,13 +4888,13 @@ function wrap(db) {
4629
4888
  SET download_status = 'failed',
4630
4889
  download_error = ?
4631
4890
  WHERE id = ?
4632
- `).run(String(error || 'unknown').slice(0, 500), id);
4891
+ `).run(truncateAfterMask(durableText(String(error || 'unknown')), 500), id);
4633
4892
  },
4634
4893
 
4635
4894
  setAttachmentTranscription(id, text) {
4636
4895
  return db.prepare(`
4637
4896
  UPDATE attachments SET transcription = ? WHERE id = ?
4638
- `).run(text || null, id);
4897
+ `).run(durableJson(text) || null, id);
4639
4898
  },
4640
4899
 
4641
4900
  getAttachmentsByMessage(message_id) {
@@ -69,6 +69,10 @@ function createHandleAbort({
69
69
  isAbortRequest,
70
70
  markSessionAborted,
71
71
  clearAutosteeredReactions,
72
+ // Narrow async hook: retire the question state for a session whose process
73
+ // is being stopped. Kept as a callback so this handler never learns about
74
+ // the question store.
75
+ retireQuestionSession = null,
72
76
  getSessionKey,
73
77
  botName,
74
78
  // Cancel-cheap (spec Finding 5): delay before the second background-shell
@@ -146,6 +150,12 @@ function createHandleAbort({
146
150
  );
147
151
  }
148
152
 
153
+ // Only after the stop is proven, and only ever cleanup: Codex cannot
154
+ // raise an interactive question, so this closes a row a Claude turn left
155
+ // behind on a session that has since moved to Codex. An unverified stop
156
+ // changes nothing, exactly as before.
157
+ if (stopVerified) await retireQuestionSession?.(sessionKey);
158
+
149
159
  logEvent('abort-requested', {
150
160
  chat_id: chatId,
151
161
  user_id: msg.from?.id || null,
@@ -160,7 +170,8 @@ function createHandleAbort({
160
170
  feedback_clear_error_code: feedbackClearErrorCode,
161
171
  stop_verified: stopVerified,
162
172
  stop_error_code: stopErrorCode,
163
- trigger: cleanText.slice(0, 40),
173
+ // The stop phrasing is the user's own message; its size is the signal.
174
+ text_len: cleanText.length,
164
175
  });
165
176
 
166
177
  // 👍 means the complete native stop proof settled, not merely that an
@@ -301,6 +312,9 @@ function createHandleAbort({
301
312
  }
302
313
 
303
314
  clearAutosteeredReactions(sessionKey).catch(() => {});
315
+ // The stopped turn owned any open question; retire its state with the same
316
+ // operation every other retirement path uses.
317
+ await retireQuestionSession?.(sessionKey);
304
318
  logEvent('abort-requested', {
305
319
  chat_id: chatId, user_id: msg.from?.id || null,
306
320
  had_active: hadActive,
@@ -320,9 +334,12 @@ function createHandleAbort({
320
334
  in_flight: busyProbe.inFlight,
321
335
  pending_turns: busyProbe.pendingTurns,
322
336
  captured: busyProbe.captured,
323
- pane_tail: busyProbe.paneTail,
337
+ // The pane tail is live TUI transcript; only whether one was
338
+ // captured is recorded, never its content.
324
339
  } : null,
325
- trigger: cleanText.slice(0, 40),
340
+ // The stop phrasing is the user's own message; its length is enough to
341
+ // tell a bare "stop" from a sentence that happened to contain one.
342
+ text_len: cleanText.length,
326
343
  });
327
344
 
328
345
  // Ack (locked design 2026-06-12, Ivan): a 👍 reaction on the user's stop
@@ -243,7 +243,7 @@ function createApprovals({
243
243
  return;
244
244
  }
245
245
  logEvent('approval-resolved', {
246
- id, status, by: userId, user, bot: botName,
246
+ id, status, by: userId, bot: botName,
247
247
  });
248
248
 
249
249
  // Edit the card to show the decision.
@@ -309,7 +309,7 @@ function createApprovals({
309
309
  rows = approvals.sweepTimedOut();
310
310
  } catch (err) {
311
311
  logger.error?.(`[approvals] sweeper DB error: ${err.message}`);
312
- logEvent('approval-sweep-failed', { error: err.message?.slice(0, 300) });
312
+ logEvent('approval-sweep-failed', { error_class: err.name || 'Error' });
313
313
  return;
314
314
  }
315
315
  for (const row of rows) {
@@ -605,19 +605,19 @@ function createHandleConfigCallback({
605
605
  for (const { scope, value } of cleared) scope[configField] = value;
606
606
  };
607
607
  // The user tapped one card; topics they never touched can change value
608
- // with it. Record which ones and what they held, so a config that moved
609
- // on its own is still explicable afterwards. Logged only once the sweep
610
- // is persisted — an event for a rolled-back sweep is worse than none.
608
+ // with it. Record WHICH ones, by id, so a config that moved on its own
609
+ // is still explicable afterwards — the values they held are
610
+ // configuration content and the ids answer the question without them.
611
+ // Logged only once the sweep is persisted — an event for a rolled-back
612
+ // sweep is worse than none.
611
613
  const logTopicRichTextSweep = (cleared) => {
612
614
  if (!cleared.length) return;
613
615
  dbWrite(() => {
614
616
  db.logEvent('config-topic-override-swept', {
615
617
  chat_id: chatId,
616
618
  field: configField,
617
- topics: cleared.map(({ threadId, value }) => ({
618
- thread_id: threadId,
619
- old_value: value,
620
- })),
619
+ topic_ids: cleared.map(({ threadId }) => String(threadId)),
620
+ topics_cleared: cleared.length,
621
621
  });
622
622
  }, 'log topic-override sweep');
623
623
  };