polygram 0.38.1 → 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
@@ -462,6 +504,25 @@ function wrap(db) {
462
504
  ORDER BY id DESC LIMIT 1
463
505
  `);
464
506
 
507
+ const getForegroundCanaryTargetStmt = db.prepare(`
508
+ SELECT message.id AS message_id,
509
+ message.chat_id,
510
+ message.thread_id,
511
+ selection.telegram_message_id,
512
+ message.handler_status,
513
+ selection.session_key,
514
+ selection.provider
515
+ FROM messages AS message
516
+ JOIN inbound_runtime_selections AS selection
517
+ ON selection.bot_name = message.bot_name
518
+ AND selection.telegram_chat_id = message.chat_id
519
+ AND selection.telegram_message_id = ?
520
+ WHERE message.direction = 'in'
521
+ AND message.bot_name = ?
522
+ AND message.chat_id = ?
523
+ AND message.msg_id = ?
524
+ `);
525
+
465
526
  const setMessageTextStmt = db.prepare(`
466
527
  UPDATE messages
467
528
  SET text = @text
@@ -1402,10 +1463,20 @@ function wrap(db) {
1402
1463
  itemId,
1403
1464
  detailJson,
1404
1465
  })) {
1405
- 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
+ };
1406
1476
  }
1407
1477
  }
1408
1478
  assertCodexCheckpointOwner(generation, kind);
1479
+ let deferred = false;
1409
1480
 
1410
1481
  if (kind === 'request-prepared') {
1411
1482
  requiredCodexString(method, 'request method');
@@ -1709,21 +1780,24 @@ function wrap(db) {
1709
1780
  );
1710
1781
  }
1711
1782
  const deliveryFailed = kind === 'telegram-delivery-failed';
1712
- const linkedState = deliveryFailed
1713
- ? 'failed'
1714
- : attempt.terminal_status === 'interrupted'
1715
- ? 'interrupted'
1716
- : attempt.terminal_status === 'failed'
1717
- ? 'failed'
1718
- : 'settled';
1719
- settleCodexAttemptConsumerOutcome({
1720
- attempt,
1721
- generationId,
1722
- linkedState,
1723
- attemptRecoveryState: 'settled',
1724
- settleQueuedReservation: true,
1725
- ts,
1726
- });
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
+ }
1727
1801
  } else if (
1728
1802
  ['active-start-cancelled', 'queued-send-cancelled'].includes(kind)
1729
1803
  ) {
@@ -1883,7 +1957,166 @@ function wrap(db) {
1883
1957
  detailJson,
1884
1958
  ts,
1885
1959
  );
1886
- 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
+ };
1887
2120
  })();
1888
2121
  }
1889
2122
 
@@ -1911,7 +2144,7 @@ function wrap(db) {
1911
2144
  }
1912
2145
  return db.transaction(() => {
1913
2146
  const result = recordCodexCheckpoint(checkpoint);
1914
- if (!input.retireGeneration) {
2147
+ if (!input.retireGeneration || result.deferred === true) {
1915
2148
  return { ...result, retired: false };
1916
2149
  }
1917
2150
  const generationId = requiredCodexString(
@@ -3769,7 +4002,7 @@ function wrap(db) {
3769
4002
  msg_id: row.msg_id,
3770
4003
  user: row.user || null,
3771
4004
  user_id: row.user_id || null,
3772
- text: row.text || '',
4005
+ text: durableText(row.text || ''),
3773
4006
  reply_to_id: row.reply_to_id || null,
3774
4007
  direction: row.direction || 'in',
3775
4008
  source: row.source || 'polygram',
@@ -3779,7 +4012,7 @@ function wrap(db) {
3779
4012
  effort: row.effort || null,
3780
4013
  turn_id: row.turn_id || null,
3781
4014
  status: row.status || 'received',
3782
- error: row.error || null,
4015
+ error: durableText(row.error) || null,
3783
4016
  cost_usd: row.cost_usd ?? null,
3784
4017
  ts: row.ts || Date.now(),
3785
4018
  });
@@ -3790,7 +4023,7 @@ function wrap(db) {
3790
4023
  chat_id: String(row.chat_id),
3791
4024
  thread_id: row.thread_id ? String(row.thread_id) : null,
3792
4025
  user: row.user || null,
3793
- text: row.text || '',
4026
+ text: durableText(row.text || ''),
3794
4027
  source: row.source || 'polygram',
3795
4028
  bot_name: row.bot_name || null,
3796
4029
  turn_id: row.turn_id || null,
@@ -3806,7 +4039,10 @@ function wrap(db) {
3806
4039
  },
3807
4040
 
3808
4041
  markOutboundFailed(id, err) {
3809
- return markOutboundFailedStmt.run({ id, error: String(err).slice(0, 500) });
4042
+ return markOutboundFailedStmt.run({
4043
+ id,
4044
+ error: truncateAfterMask(durableText(String(err)), 500),
4045
+ });
3810
4046
  },
3811
4047
 
3812
4048
  updateOutboundText({ chat_id, msg_id, text, ts }) {
@@ -3814,7 +4050,7 @@ function wrap(db) {
3814
4050
  return updateOutboundTextStmt.run({
3815
4051
  chat_id: String(chat_id),
3816
4052
  msg_id: Number(msg_id),
3817
- text,
4053
+ text: durableText(text),
3818
4054
  ts: ts || Date.now(),
3819
4055
  });
3820
4056
  },
@@ -3879,6 +4115,7 @@ function wrap(db) {
3879
4115
  },
3880
4116
 
3881
4117
  createCodexGeneration,
4118
+ prepareCodexCleanRetirement,
3882
4119
  recordCodexCheckpoint,
3883
4120
  recordCodexDeliveryCheckpoint,
3884
4121
  getCodexAttempt(attemptId) {
@@ -3944,7 +4181,7 @@ function wrap(db) {
3944
4181
  return setMessageTextStmt.run({
3945
4182
  chat_id: String(chat_id),
3946
4183
  msg_id,
3947
- text: text ?? '',
4184
+ text: durableText(text ?? ''),
3948
4185
  });
3949
4186
  },
3950
4187
 
@@ -3957,16 +4194,28 @@ function wrap(db) {
3957
4194
  return row?.new_chat_id || String(chatId);
3958
4195
  },
3959
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.
3960
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;
3961
4208
  return logEventStmt.run(
3962
4209
  Date.now(),
3963
4210
  chat_id ? String(chat_id) : null,
3964
4211
  kind,
3965
- Object.keys(detail).length ? JSON.stringify(detail) : null,
4212
+ Object.keys(allowed).length
4213
+ ? JSON.stringify(sanitizeDurableStructured(allowed))
4214
+ : null,
3966
4215
  );
3967
4216
  },
3968
4217
 
3969
- // rc.61: find compact-command events from the recent window that
4218
+ // Find compact-command events from the recent window that
3970
4219
  // never produced a matching compact-boundary on the same
3971
4220
  // session_key. These are "interrupted by deploy" cases — polygram
3972
4221
  // pushed /compact into SDK input but the daemon restarted before
@@ -3978,23 +4227,38 @@ function wrap(db) {
3978
4227
  // session_key AND ts > compact_command.ts. If none within
3979
4228
  // search-window, it's orphaned.
3980
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
+ //
3981
4241
  // @param {object} opts
3982
4242
  // @param {number} opts.olderThanMs - cutoff (only events newer than this are scanned)
3983
- // @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}>}
3984
4244
  findOrphanedCompactCommands({ olderThanMs = 30 * 60 * 1000 } = {}) {
3985
4245
  const cutoff = Date.now() - olderThanMs;
3986
4246
  const cmds = db.prepare(`
3987
- SELECT id, ts, chat_id,
3988
- json_extract(detail_json, '$.thread_id') AS thread_id,
3989
- json_extract(detail_json, '$.session_key') AS session_key,
3990
- json_extract(detail_json, '$.user') AS user,
3991
- json_extract(detail_json, '$.user_id') AS user_id,
3992
- json_extract(detail_json, '$.text_len') AS text_len,
3993
- json_extract(detail_json, '$.text') AS text
3994
- FROM events
3995
- WHERE kind = 'compact-command'
3996
- AND ts > ?
3997
- 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
3998
4262
  `).all(cutoff);
3999
4263
  const orphans = [];
4000
4264
  for (const c of cmds) {
@@ -4011,7 +4275,7 @@ function wrap(db) {
4011
4275
  LIMIT 1
4012
4276
  `).get(c.id, c.session_key);
4013
4277
  if (boundary) continue;
4014
- // rc.66: also skip if a previous boot has already handled
4278
+ // Also skip if a previous boot has already handled
4015
4279
  // this orphan (silent replay via compact-replay event, OR
4016
4280
  // surface-fallback via compact-failed-restart event). Both
4017
4281
  // of those record `original_ts` in their detail_json
@@ -4025,7 +4289,15 @@ function wrap(db) {
4025
4289
  LIMIT 1
4026
4290
  `).get(c.ts);
4027
4291
  if (handled) continue;
4028
- 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
+ });
4029
4301
  }
4030
4302
  return orphans;
4031
4303
  },
@@ -4049,7 +4321,8 @@ function wrap(db) {
4049
4321
  num_assistant_messages: row.num_assistant_messages ?? null,
4050
4322
  num_tool_uses: row.num_tool_uses ?? null,
4051
4323
  result_subtype: row.result_subtype || null,
4052
- error: row.error || null,
4324
+ // The turn's failure text can echo the provider's own output.
4325
+ error: durableText(row.error) || null,
4053
4326
  });
4054
4327
  },
4055
4328
 
@@ -4092,7 +4365,12 @@ function wrap(db) {
4092
4365
  chat_id: String(row.chat_id),
4093
4366
  tool_name: String(row.tool_name),
4094
4367
  match_type: row.match_type,
4095
- 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),
4096
4374
  decision: row.decision,
4097
4375
  issued_ts: row.issued_ts || Date.now(),
4098
4376
  issued_by_user_id: row.issued_by_user_id != null
@@ -4539,7 +4817,6 @@ function wrap(db) {
4539
4817
  redactSecretInChat({ chat_id, thread_id = null, secret, now = Date.now(), limit = 200 }) {
4540
4818
  if (typeof secret !== 'string' || secret.length < 3) return { redacted: 0 };
4541
4819
  const PLACEHOLDER = '‹redacted:reported›';
4542
- const sha = require('crypto').createHash('sha256').update(secret).digest('hex');
4543
4820
  const rows = (thread_id != null
4544
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)
4545
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));
@@ -4549,8 +4826,8 @@ function wrap(db) {
4549
4826
  if (!r.text || !r.text.includes(secret)) continue;
4550
4827
  const newText = r.text.split(secret).join(PLACEHOLDER);
4551
4828
  db.prepare('UPDATE messages SET text = ? WHERE id = ?').run(newText, r.id);
4552
- db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length, sha256, action, ts)
4553
- 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);
4554
4831
  redacted += 1;
4555
4832
  }
4556
4833
  });
@@ -4586,7 +4863,8 @@ function wrap(db) {
4586
4863
  file_id,
4587
4864
  file_unique_id || null,
4588
4865
  kind,
4589
- name || null,
4866
+ // A user-supplied filename can carry a declared credential.
4867
+ durableText(name) || null,
4590
4868
  mime_type || null,
4591
4869
  size_bytes ?? null,
4592
4870
  ts || Date.now(),
@@ -4610,13 +4888,13 @@ function wrap(db) {
4610
4888
  SET download_status = 'failed',
4611
4889
  download_error = ?
4612
4890
  WHERE id = ?
4613
- `).run(String(error || 'unknown').slice(0, 500), id);
4891
+ `).run(truncateAfterMask(durableText(String(error || 'unknown')), 500), id);
4614
4892
  },
4615
4893
 
4616
4894
  setAttachmentTranscription(id, text) {
4617
4895
  return db.prepare(`
4618
4896
  UPDATE attachments SET transcription = ? WHERE id = ?
4619
- `).run(text || null, id);
4897
+ `).run(durableJson(text) || null, id);
4620
4898
  },
4621
4899
 
4622
4900
  getAttachmentsByMessage(message_id) {
@@ -4698,6 +4976,32 @@ function wrap(db) {
4698
4976
  `).get(messageId);
4699
4977
  },
4700
4978
 
4979
+ getForegroundCanaryTarget({ botName, chatId, telegramMessageId }) {
4980
+ const messageIdText = String(telegramMessageId);
4981
+ const messageId = Number(messageIdText);
4982
+ if (
4983
+ !Number.isSafeInteger(messageId)
4984
+ || messageId <= 0
4985
+ || String(messageId) !== messageIdText
4986
+ ) return null;
4987
+ const row = getForegroundCanaryTargetStmt.get(
4988
+ messageIdText,
4989
+ String(botName),
4990
+ String(chatId),
4991
+ messageId,
4992
+ );
4993
+ if (!row) return null;
4994
+ return {
4995
+ messageId: row.message_id,
4996
+ chatId: String(row.chat_id),
4997
+ threadId: row.thread_id == null ? null : String(row.thread_id),
4998
+ telegramMessageId: row.telegram_message_id,
4999
+ sessionKey: row.session_key,
5000
+ provider: row.provider,
5001
+ handlerStatus: row.handler_status,
5002
+ };
5003
+ },
5004
+
4701
5005
  listFailedAttachments({ since = null, limit = 100 } = {}) {
4702
5006
  const cutoff = since ?? Date.now() - 24 * 60 * 60 * 1000;
4703
5007
  return db.prepare(`
@@ -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) {