@llblab/pi-kit 0.27.6 → 0.28.0

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 (182) hide show
  1. package/BACKLOG.md +2 -5
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  7. package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
  8. package/node_modules/@llblab/pi-telegram/README.md +15 -5
  9. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
  10. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
  20. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
  21. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
  22. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
  23. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
  24. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
  25. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
  26. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
  27. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
  28. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
  29. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
  30. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
  31. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
  32. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
  33. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
  34. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
  35. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
  36. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
  37. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
  38. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
  39. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
  40. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
  41. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
  42. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
  43. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
  44. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
  45. package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
  46. package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
  47. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
  48. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
  49. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
  50. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
  51. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
  52. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
  53. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
  54. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
  55. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
  56. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
  57. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
  58. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
  59. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
  60. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
  61. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
  62. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
  63. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
  64. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
  65. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
  66. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
  67. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
  68. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
  69. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
  70. package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
  71. package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
  72. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
  73. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
  74. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
  75. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
  77. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
  78. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
  79. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
  80. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
  81. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
  82. package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
  83. package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
  84. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
  85. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
  86. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
  87. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
  88. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
  89. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
  90. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
  91. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
  92. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
  93. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
  94. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
  95. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
  96. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
  97. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
  98. package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
  99. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
  100. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
  101. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
  102. package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
  103. package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
  104. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
  105. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
  106. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
  107. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
  108. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
  109. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
  110. package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
  111. package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
  112. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  113. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  114. package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
  115. package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
  116. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  117. package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
  118. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  119. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  120. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
  121. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
  122. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  123. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  124. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
  125. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
  126. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
  127. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
  128. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
  129. package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
  130. package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
  131. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
  132. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
  133. package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
  134. package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
  135. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
  136. package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
  137. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
  138. package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
  139. package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
  140. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
  141. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
  142. package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
  143. package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
  144. package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
  145. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
  146. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
  147. package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
  148. package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
  149. package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
  150. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
  151. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
  152. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
  153. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
  154. package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
  155. package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
  156. package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
  157. package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
  158. package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
  159. package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
  160. package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
  161. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  162. package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
  163. package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
  164. package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
  165. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  166. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
  167. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
  168. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
  169. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
  170. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
  171. package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
  172. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
  173. package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
  174. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
  175. package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
  176. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
  177. package/node_modules/@llblab/pi-telegram/package.json +6 -6
  178. package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
  179. package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
  180. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
  181. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
  182. package/package.json +2 -2
@@ -22,6 +22,7 @@ import {
22
22
  readFileSync,
23
23
  readdirSync,
24
24
  rmdirSync,
25
+ rmSync,
25
26
  statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
@@ -39,6 +40,7 @@ import {
39
40
  type TelegramProcessLiveness,
40
41
  } from "./bus.ts";
41
42
  import {
43
+ decodeTelegramSessionDirectoryName,
42
44
  getTelegramProfilePathSuffix,
43
45
  TELEGRAM_DEFAULT_PROFILE_NAME,
44
46
  } from "./paths.ts";
@@ -47,22 +49,28 @@ import {
47
49
  type TelegramWorkspaceAdmissionLedger,
48
50
  type TelegramWorkspaceAdmissionScope,
49
51
  } from "./workspace-admission.ts";
52
+ import {
53
+ isWireRecord as isRecord,
54
+ hasOnlyWireKeys as hasOnlyKeys,
55
+ isNonNegativeWireInteger as isSafeNonNegativeInteger,
56
+ isNonEmptyWireString as isNonEmptyString,
57
+ } from "./wire.ts";
50
58
 
51
59
  export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
52
60
  export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
53
61
  export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
54
62
  // Receipt and binding identities survive storage-schema upgrades.
55
63
  const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
56
- export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
57
- export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
58
- export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
64
+ const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
65
+ const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
66
+ const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
59
67
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
60
- export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
68
+ const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
61
69
  const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
62
70
  const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
63
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
64
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
65
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
71
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
72
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
73
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
66
74
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
67
75
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
68
76
 
@@ -115,7 +123,78 @@ export function discoverTelegramFollowerJournalPaths(input: {
115
123
  }
116
124
  return { paths: Array.from(paths).sort(), complete };
117
125
  }
118
- export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
126
+
127
+ /** Bounded read-only session-family discovery, not writer closure or deletion authority. */
128
+ export function discoverTelegramSessionJournalPaths(input: {
129
+ directory: string;
130
+ profileName?: string;
131
+ maxDirectoryEntries?: number;
132
+ }): TelegramFollowerJournalDiscovery {
133
+ const maxEntries = input.maxDirectoryEntries ?? 10_000;
134
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) return { paths: [], complete: false };
135
+ const root = join(input.directory, "sessions");
136
+ const suffix = getTelegramProfilePathSuffix(input.profileName);
137
+ const paths = new Set<string>();
138
+ let remaining = maxEntries;
139
+ let complete = true;
140
+ const scan = (directory: string): Dirent[] => {
141
+ const entries: Dirent[] = [];
142
+ const handle = opendirSync(directory);
143
+ try {
144
+ for (;;) {
145
+ const entry = handle.readSync();
146
+ if (!entry) break;
147
+ if (--remaining < 0) { complete = false; break; }
148
+ entries.push(entry);
149
+ }
150
+ } finally { handle.closeSync(); }
151
+ return entries;
152
+ };
153
+ try {
154
+ const stat = lstatSync(root);
155
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { paths: [], complete: false };
156
+ } catch (error) {
157
+ return { paths: [], complete: (error as NodeJS.ErrnoException).code === "ENOENT" };
158
+ }
159
+ try {
160
+ for (const session of scan(root)) {
161
+ if (!complete) break;
162
+ if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
163
+ complete = false; break;
164
+ }
165
+ const directory = join(root, session.name);
166
+ const sessionStat = lstatSync(directory);
167
+ if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) { complete = false; break; }
168
+ for (const entry of scan(directory)) {
169
+ if (!complete) break;
170
+ // The leader polling journal and legacy recovery folders are not recipient custody.
171
+ if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name)) continue;
172
+ const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
173
+ if (!match) { complete = false; break; }
174
+ const stat = lstatSync(join(directory, entry.name));
175
+ if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
176
+ complete = false; break;
177
+ }
178
+ if ((match[2] ?? "") !== suffix) continue;
179
+ paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
180
+ }
181
+ }
182
+ } catch { complete = false; }
183
+ return { paths: Array.from(paths).sort(), complete };
184
+ }
185
+
186
+ /** Transitional discovery covers both retained flat recipients and session-owned families. */
187
+ export function discoverTelegramRecipientJournalPaths(input: {
188
+ directory: string;
189
+ profileName?: string;
190
+ }): TelegramFollowerJournalDiscovery {
191
+ const legacy = discoverTelegramFollowerJournalPaths(input);
192
+ const sessions = discoverTelegramSessionJournalPaths(input);
193
+ return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
194
+ complete: legacy.complete && sessions.complete };
195
+ }
196
+
197
+ const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
119
198
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
120
199
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
121
200
 
@@ -329,7 +408,7 @@ export interface TelegramUpdateJournalLegacyCustodyCandidate {
329
408
  evidenceSha256: string;
330
409
  }
331
410
 
332
- export function listTelegramUpdateJournalLegacyCustodyCandidates(
411
+ function listTelegramUpdateJournalLegacyCustodyCandidates(
333
412
  snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
334
413
  ): TelegramUpdateJournalLegacyCustodyCandidate[] {
335
414
  return snapshot.entries.flatMap(entry => {
@@ -409,12 +488,23 @@ export type TelegramUpdateJournalOperatorDisposition =
409
488
  | TelegramUpdateJournalTerminalOperatorDisposition
410
489
  | TelegramUpdateJournalLegacyCustodyDisposition;
411
490
 
491
+ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
492
+
493
+ /** A source-only choice deadline, never a queue lease or a Thread deletion grant. */
494
+ export interface TelegramUpdateJournalRoutingInput {
495
+ operatorUserId: number;
496
+ publishedAtMs: number;
497
+ expiresAtMs: number;
498
+ phase: "waiting" | "selected";
499
+ }
500
+
412
501
  export interface TelegramUpdateJournalEntry {
413
502
  updateId: number;
414
503
  update: TelegramJournaledUpdate;
415
504
  /** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
416
505
  preApprovalExcluded?: boolean;
417
506
  admittedAtMs: number;
507
+ routingInput?: TelegramUpdateJournalRoutingInput;
418
508
  state: TelegramUpdateJournalEntryState;
419
509
  queueKind?: TelegramUpdateJournalQueueKind;
420
510
  queueReceiptId?: string;
@@ -429,6 +519,19 @@ export interface TelegramUpdateJournalEntry {
429
519
  terminalFailureId?: string;
430
520
  }
431
521
 
522
+ /** Exact parsed journal entry identity, checked inside the source owner's mutation transaction. */
523
+ export interface TelegramUpdateJournalEntryDigest {
524
+ updateId: number;
525
+ sourceSha256: string;
526
+ }
527
+ /** Journal-owned removal ACK bound to an opaque, immutable caller acceptance scope. */
528
+ export interface TelegramUpdateJournalSourceCompletion extends TelegramUpdateJournalEntryDigest {
529
+ completionSha256: string;
530
+ }
531
+ export function createTelegramUpdateJournalEntryDigest(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntryDigest {
532
+ return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
533
+ }
534
+
432
535
  export interface TelegramUpdateJournalFile {
433
536
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
434
537
  revision?: number;
@@ -437,6 +540,7 @@ export interface TelegramUpdateJournalFile {
437
540
  botIdentity: TelegramUpdateJournalBotIdentity;
438
541
  entries: TelegramUpdateJournalEntry[];
439
542
  operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
543
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
440
544
  }
441
545
 
442
546
  export interface TelegramUpdateJournalSnapshot
@@ -455,6 +559,7 @@ export interface TelegramUpdateJournalAppendResult {
455
559
  }
456
560
 
457
561
  export interface TelegramUpdateJournalRemoveResult {
562
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
458
563
  removedUpdateIds: number[];
459
564
  entryCount: number;
460
565
  serializedBytes: number;
@@ -475,6 +580,12 @@ export interface TelegramUpdateJournalQueueResult {
475
580
  serializedBytes: number;
476
581
  }
477
582
 
583
+ export interface TelegramUpdateJournalQueuedReceiptEvidence {
584
+ receipt: TelegramUpdateJournalQueuedCompletion;
585
+ sources: TelegramUpdateJournalEntryDigest[];
586
+ queueOwnerSha256: string;
587
+ }
588
+
478
589
  export interface TelegramUpdateJournalQueuedCompletion {
479
590
  queueKind: TelegramUpdateJournalQueueKind;
480
591
  receiptId: string;
@@ -593,8 +704,69 @@ export interface TelegramUpdateJournalOperatorDispositionResult {
593
704
  serializedBytes: number;
594
705
  }
595
706
 
707
+ export interface TelegramUpdateJournalPendingAbandonmentInput {
708
+ journalBindingKey: string;
709
+ entry: TelegramUpdateJournalEntry;
710
+ operatorAuthorityId: string;
711
+ /** Caller proves owner authorization and excludes live chooser/worker dispatch through publication. */
712
+ isCurrent: () => boolean;
713
+ }
714
+
715
+ export interface TelegramUpdateJournalPendingAbandonmentResult {
716
+ disposition: TelegramUpdateJournalLegacyCustodyDisposition;
717
+ retainedPath: string;
718
+ duplicate: boolean;
719
+ entryCount: number;
720
+ serializedBytes: number;
721
+ }
722
+
723
+ /** Private evidence copy, not execution authority or proof that abandonment committed. */
724
+ export interface TelegramUpdateJournalRetainedInput {
725
+ version: 1;
726
+ kind: "pending-input-retention";
727
+ journalBindingKey: string;
728
+ entry: TelegramUpdateJournalEntry;
729
+ requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
730
+ }
731
+
732
+ export interface TelegramUpdateJournalPendingRetentionEvidence {
733
+ journalBindingKey: string;
734
+ retainedPath: string;
735
+ requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
736
+ }
737
+
738
+ /** Durable proof that an owner abandoned this exact pending source; never delivery or completion proof. */
739
+ export interface TelegramUpdateJournalAbandonedPendingEvidence {
740
+ journalBindingKey: string;
741
+ updateId: number;
742
+ retainedPath: string;
743
+ operatorAuthorityId: string;
744
+ }
745
+
746
+ export interface TelegramRoutingInputAuthority {
747
+ journalBindingKey: string;
748
+ entries: readonly TelegramUpdateJournalEntry[];
749
+ operatorUserId: number;
750
+ isCurrent(): boolean;
751
+ }
752
+
753
+ /** Package-private v1 capability; raw input custody does not expose it. */
754
+ export interface TelegramRoutingInputJournal {
755
+ arm(input: TelegramRoutingInputAuthority & { publishedAtMs: number }): TelegramUpdateJournalEntry[];
756
+ select(input: TelegramRoutingInputAuthority): { issued: boolean; entries: TelegramUpdateJournalEntry[] };
757
+ expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramUpdateJournalPendingAbandonmentResult;
758
+ }
759
+
596
760
  export interface TelegramUpdateJournalStore {
761
+ routingInputs?: TelegramRoutingInputJournal;
597
762
  read(): TelegramUpdateJournalSnapshot;
763
+ /** Read-only: the committed abandonment tombstone plus its matching private retention, or undefined. */
764
+ inspectAbandonedPending(updateId: number): TelegramUpdateJournalAbandonedPendingEvidence | undefined;
765
+ /** Read-only protection evidence for this snapshot entry, never commit/execution authority. */
766
+ inspectPendingRetention(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalPendingRetentionEvidence | undefined;
767
+ abandonPending(
768
+ input: TelegramUpdateJournalPendingAbandonmentInput,
769
+ ): TelegramUpdateJournalPendingAbandonmentResult;
598
770
  appendBatch<TUpdate extends TelegramUpdateJournalInput>(
599
771
  updates: readonly TUpdate[],
600
772
  acceptedThroughUpdateId?: number,
@@ -623,6 +795,11 @@ export interface TelegramUpdateJournalStore {
623
795
  completeQueued(
624
796
  receipts: readonly TelegramUpdateJournalQueuedCompletion[],
625
797
  ): TelegramUpdateJournalRemoveResult;
798
+ /** Strict v1 whole-receipt disposal co-publishes caller-scoped ACKs; never a readiness grant. */
799
+ completeQueuedExact(
800
+ receipts: readonly TelegramUpdateJournalQueuedCompletion[],
801
+ completions: readonly TelegramUpdateJournalSourceCompletion[],
802
+ ): TelegramUpdateJournalRemoveResult;
626
803
  discardQueued(
627
804
  input: TelegramUpdateJournalQueueDiscardInput,
628
805
  ): TelegramUpdateJournalQueueDiscardResult;
@@ -632,6 +809,15 @@ export interface TelegramUpdateJournalStore {
632
809
  removeCompleted(
633
810
  updateIds: readonly number[],
634
811
  ): TelegramUpdateJournalRemoveResult;
812
+ /** Guarded completion; every supplied source must still match, and absence is a conflict, not an ACK. */
813
+ removeCompletedExact(updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
814
+ completions?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult;
815
+ /** Strict read-only observation; neither source absence nor a different scope is completion evidence. */
816
+ inspectSourceCompletion(expected: TelegramUpdateJournalSourceCompletion): TelegramUpdateJournalSourceCompletion | undefined;
817
+ /** Complete strict observation of a retained, unoffered v1 queue receipt; never acquires readiness. */
818
+ inspectQueuedReceipt(expected: TelegramUpdateJournalQueuedCompletion): TelegramUpdateJournalQueuedReceiptEvidence | undefined;
819
+ isQueueReceiptCurrent?: (receipt: Pick<TelegramUpdateJournalQueuedCompletion, "queueKind" | "receiptId" | "sourceUpdateIds"> &
820
+ { journalBindingKey?: string }, owner: TelegramUpdateJournalQueueOwner) => boolean;
635
821
  }
636
822
 
637
823
  export type TelegramUpdateJournalPublicationBoundary =
@@ -642,7 +828,8 @@ export interface TelegramUpdateJournalRecoveryEvent {
642
828
  kind: "repaired" | "reset";
643
829
  path: string;
644
830
  revision?: number;
645
- quarantinePath?: string;
831
+ /** Damaged snapshot/segment paths deleted or atomically replaced; nothing is retained for recovery. */
832
+ deletedPaths?: string[];
646
833
  reason: string;
647
834
  }
648
835
 
@@ -831,6 +1018,7 @@ interface JournalSourceAcquisition {
831
1018
  evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
832
1019
  snapshotRevision: number;
833
1020
  segments: { name: string; bytes: number; work: number; botId?: number }[];
1021
+ retainedInputs?: TelegramUpdateJournalRetentionInspection["retainedInputs"];
834
1022
  }
835
1023
 
836
1024
  export interface TelegramUpdateJournalSegment {
@@ -843,6 +1031,7 @@ export interface TelegramUpdateJournalSegment {
843
1031
  upsertedEntries: TelegramUpdateJournalEntry[];
844
1032
  removedUpdateIds: number[];
845
1033
  operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
1034
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
846
1035
  }
847
1036
 
848
1037
  export interface TelegramUpdateJournalSegmentPublicationResult {
@@ -865,30 +1054,10 @@ function createJournalError(
865
1054
  );
866
1055
  }
867
1056
 
868
- function isRecord(value: unknown): value is Record<string, unknown> {
869
- return !!value && typeof value === "object" && !Array.isArray(value);
870
- }
871
-
872
- function hasOnlyKeys(
873
- value: Record<string, unknown>,
874
- allowedKeys: readonly string[],
875
- ): boolean {
876
- const allowed = new Set(allowedKeys);
877
- return Object.keys(value).every((key) => allowed.has(key));
878
- }
879
-
880
- function isSafeNonNegativeInteger(value: unknown): value is number {
881
- return Number.isSafeInteger(value) && (value as number) >= 0;
882
- }
883
-
884
1057
  function isSafePositiveInteger(value: unknown): value is number {
885
1058
  return Number.isSafeInteger(value) && (value as number) > 0;
886
1059
  }
887
1060
 
888
- function isNonEmptyString(value: unknown): value is string {
889
- return typeof value === "string" && value.length > 0;
890
- }
891
-
892
1061
  function isBoundedString(
893
1062
  value: unknown,
894
1063
  maxLength: number,
@@ -1255,8 +1424,16 @@ function validateJournalOperatorDisposition(
1255
1424
  }
1256
1425
 
1257
1426
  function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
1258
- return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
1259
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
1427
+ return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
1428
+ }
1429
+
1430
+ function journalHasCustodyFields(version: TelegramUpdateJournalFile["version"]): boolean {
1431
+ return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
1432
+ }
1433
+
1434
+ /** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
1435
+ export function isTelegramUpdateJournalLegacyFamilyVersion(version: unknown): boolean {
1436
+ return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
1260
1437
  }
1261
1438
 
1262
1439
  function validateJournalInputClaim(
@@ -1328,6 +1505,7 @@ function validateJournalEntry(
1328
1505
  "updateId",
1329
1506
  "update",
1330
1507
  "admittedAtMs",
1508
+ ...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
1331
1509
  "state",
1332
1510
  "queueKind",
1333
1511
  "queueReceiptId",
@@ -1339,7 +1517,7 @@ function validateJournalEntry(
1339
1517
  "terminalReason",
1340
1518
  "terminalFailureId",
1341
1519
  ...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
1342
- ...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
1520
+ ...(journalHasCustodyFields(version)
1343
1521
  ? ["inputClaim", "inputProvenance"] : []),
1344
1522
  ]) ||
1345
1523
  !isSafeNonNegativeInteger(value.updateId) ||
@@ -1359,6 +1537,20 @@ function validateJournalEntry(
1359
1537
  "contains an entry/update id mismatch",
1360
1538
  );
1361
1539
  }
1540
+ let routingInput: TelegramUpdateJournalRoutingInput | undefined;
1541
+ if (value.routingInput !== undefined) {
1542
+ const routing = value.routingInput;
1543
+ if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
1544
+ !isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
1545
+ routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
1546
+ routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
1547
+ (routing.phase !== "waiting" && routing.phase !== "selected") ||
1548
+ (routing.phase === "waiting" && value.state === "queued")) {
1549
+ throw createJournalError("invalid", path, "contains invalid routing input lifetime");
1550
+ }
1551
+ routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
1552
+ expiresAtMs: routing.expiresAtMs, phase: routing.phase };
1553
+ }
1362
1554
  const queueKind = value.queueKind;
1363
1555
  const queueReceiptId = value.queueReceiptId;
1364
1556
  const hasQueueMetadata =
@@ -1474,6 +1666,7 @@ function validateJournalEntry(
1474
1666
  ...(inputClaim ? { inputClaim } : {}),
1475
1667
  ...(inputProvenance ? { inputProvenance } : {}),
1476
1668
  admittedAtMs: value.admittedAtMs,
1669
+ ...(routingInput ? { routingInput } : {}),
1477
1670
  state: value.state,
1478
1671
  ...(queueKind === "prompt" || queueKind === "control"
1479
1672
  ? { queueKind }
@@ -1522,6 +1715,32 @@ function assertSupportedJournalVersion(
1522
1715
  }
1523
1716
  }
1524
1717
 
1718
+ function validateJournalSourceCompletion(value: unknown, path: string): TelegramUpdateJournalSourceCompletion {
1719
+ if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
1720
+ !isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
1721
+ typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
1722
+ throw createJournalError("invalid", path, "has invalid source completion evidence");
1723
+ }
1724
+ return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
1725
+ }
1726
+
1727
+ function validateJournalSourceCompletions(value: unknown, path: string, version: TelegramUpdateJournalFile["version"],
1728
+ allowEmpty = false): TelegramUpdateJournalSourceCompletion[] {
1729
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
1730
+ throw createJournalError("invalid", path, "has invalid source completion collection");
1731
+ }
1732
+ const completions = value.map(item => validateJournalSourceCompletion(item, path));
1733
+ const scopes = new Set<string>();
1734
+ for (let index = 0; index < completions.length; index++) {
1735
+ const completion = completions[index]!;
1736
+ if ((index > 0 && completions[index - 1]!.updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
1737
+ throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
1738
+ }
1739
+ scopes.add(completion.completionSha256);
1740
+ }
1741
+ return completions;
1742
+ }
1743
+
1525
1744
  function parseJournalFile(
1526
1745
  value: unknown,
1527
1746
  path: string,
@@ -1542,6 +1761,7 @@ function parseJournalFile(
1542
1761
  "botIdentity",
1543
1762
  "entries",
1544
1763
  "operatorDispositions",
1764
+ "sourceCompletions",
1545
1765
  ]) ||
1546
1766
  !Number.isSafeInteger(value.version) ||
1547
1767
  (value.revision !== undefined &&
@@ -1635,6 +1855,14 @@ function parseJournalFile(
1635
1855
  ).map((disposition) =>
1636
1856
  validateJournalOperatorDisposition(disposition, path),
1637
1857
  );
1858
+ const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
1859
+ const botIdentity = validateBotIdentity(value.botIdentity, path);
1860
+ const activeIds = new Set(entries.map(entry => entry.updateId));
1861
+ const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
1862
+ if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
1863
+ value.acceptedThroughUpdateId !== undefined && completion.updateId > (value.acceptedThroughUpdateId as number))) {
1864
+ throw createJournalError("invalid", path, "has contradictory source completion evidence");
1865
+ }
1638
1866
  const dispositionFailureIds = new Set<string>();
1639
1867
  const entriesByUpdateId = new Map(
1640
1868
  entries.map((entry) => [entry.updateId, entry]),
@@ -1669,12 +1897,62 @@ function parseJournalFile(
1669
1897
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1670
1898
  : {}),
1671
1899
  profile: value.profile,
1672
- botIdentity: validateBotIdentity(value.botIdentity, path),
1900
+ botIdentity,
1673
1901
  entries,
1674
1902
  ...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
1903
+ ...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
1675
1904
  };
1676
1905
  }
1677
1906
 
1907
+ function assertJournalRoutingInputContinuity(file: TelegramUpdateJournalFile, entries: readonly TelegramUpdateJournalEntry[], path: string): void {
1908
+ const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
1909
+ for (const entry of entries) {
1910
+ const retained = previous.get(entry.updateId), next = entry.routingInput;
1911
+ if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
1912
+ next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
1913
+ throw createJournalError("invalid", path, "regresses retained routing input lifetime");
1914
+ }
1915
+ }
1916
+ }
1917
+
1918
+ function assertJournalSourceCompletionContinuity(file: TelegramUpdateJournalFile, segment: TelegramUpdateJournalSegment, path: string): void {
1919
+ assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
1920
+ if (segment.sourceCompletions === undefined) return;
1921
+ const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
1922
+ const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
1923
+ if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
1924
+ throw createJournalError("invalid", path, "regresses retained source completion evidence");
1925
+ }
1926
+ const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1927
+ const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
1928
+ const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
1929
+ // Grouped markers share one whole-receipt check rather than rescanning the group per source.
1930
+ const queueGroups = new Map<string, TelegramUpdateJournalEntry[]>(), queueRemovals = new Map<string, boolean>();
1931
+ for (const entry of file.entries) {
1932
+ if (!entry.queueReceiptId) continue;
1933
+ const group = queueGroups.get(entry.queueReceiptId) ?? [];
1934
+ group.push(entry); queueGroups.set(entry.queueReceiptId, group);
1935
+ }
1936
+ const queueWasRemoved = (source: TelegramUpdateJournalEntry): boolean => {
1937
+ const receiptId = source.queueReceiptId;
1938
+ if (!receiptId || !source.queueOwner) return false;
1939
+ if (queueRemovals.has(receiptId)) return queueRemovals.get(receiptId)!;
1940
+ const group = queueGroups.get(receiptId);
1941
+ const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry =>
1942
+ removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
1943
+ entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner!));
1944
+ queueRemovals.set(receiptId, valid); return valid;
1945
+ };
1946
+ for (const completion of segment.sourceCompletions) {
1947
+ if (previous.has(completion.updateId)) continue;
1948
+ const source = entries.get(completion.updateId);
1949
+ if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
1950
+ createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
1951
+ throw createJournalError("invalid", path, "source completion does not match its removal revision");
1952
+ }
1953
+ }
1954
+ }
1955
+
1678
1956
  function parseJournalSegment(
1679
1957
  value: unknown,
1680
1958
  path: string,
@@ -1697,6 +1975,7 @@ function parseJournalSegment(
1697
1975
  "upsertedEntries",
1698
1976
  "removedUpdateIds",
1699
1977
  "operatorDispositions",
1978
+ "sourceCompletions",
1700
1979
  ]) ||
1701
1980
  value.version !== version ||
1702
1981
  !isSafePositiveInteger(value.revision) ||
@@ -1751,6 +2030,7 @@ function parseJournalSegment(
1751
2030
  ).map((disposition) =>
1752
2031
  validateJournalOperatorDisposition(disposition, path),
1753
2032
  );
2033
+ const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
1754
2034
  return {
1755
2035
  version,
1756
2036
  revision: value.revision,
@@ -1759,12 +2039,13 @@ function parseJournalSegment(
1759
2039
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1760
2040
  : {}),
1761
2041
  profile: value.profile,
1762
- botIdentity: validateBotIdentity(value.botIdentity, path),
2042
+ botIdentity: segmentBotIdentity,
1763
2043
  upsertedEntries,
1764
2044
  removedUpdateIds,
1765
2045
  ...(value.operatorDispositions !== undefined
1766
2046
  ? { operatorDispositions }
1767
2047
  : {}),
2048
+ ...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
1768
2049
  };
1769
2050
  }
1770
2051
 
@@ -1807,6 +2088,19 @@ export function inspectTelegramUpdateJournalFamily(input: {
1807
2088
  return acquireTelegramUpdateJournalFamily(input).evidence;
1808
2089
  }
1809
2090
 
2091
+ export interface TelegramUpdateJournalRetentionInspection {
2092
+ evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
2093
+ retainedInputs: { path: string; journalBindingKey: string; failureId: string; updateId: number; state: "committed" | "uncommitted" }[];
2094
+ }
2095
+
2096
+ /** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
2097
+ export function inspectTelegramUpdateJournalRetention(
2098
+ input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
2099
+ ): TelegramUpdateJournalRetentionInspection {
2100
+ const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
2101
+ return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
2102
+ }
2103
+
1810
2104
  /**
1811
2105
  * Read-only source evidence for cooperating writers serialized by the caller through
1812
2106
  * consumption; never readiness, recovery, or permission to publish. Ancestors retain
@@ -1821,7 +2115,7 @@ export function readTelegramUpdateJournalSource(
1821
2115
  input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
1822
2116
  ): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
1823
2117
  const version = input.version;
1824
- if (version !== 1 && version !== 2 && version !== 3) {
2118
+ if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
1825
2119
  throw createJournalError("unsupported-version", input.path, "requires a selected source version");
1826
2120
  }
1827
2121
  return acquireTelegramUpdateJournalFamily({
@@ -1834,6 +2128,7 @@ export function readTelegramUpdateJournalSource(
1834
2128
  function acquireTelegramUpdateJournalFamily(
1835
2129
  input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
1836
2130
  selectedVersion?: TelegramUpdateJournalFile["version"],
2131
+ inspectRetention = false,
1837
2132
  ): JournalSourceAcquisition {
1838
2133
  const { path, limits } = input;
1839
2134
  const acquiredSegments: JournalSourceAcquisition["segments"] = [];
@@ -1878,7 +2173,7 @@ function acquireTelegramUpdateJournalFamily(
1878
2173
  };
1879
2174
  const charge = (value: unknown) => {
1880
2175
  if (!isRecord(value)) return;
1881
- for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
2176
+ for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
1882
2177
  const collection = value[key];
1883
2178
  if (!Array.isArray(collection)) continue;
1884
2179
  if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
@@ -1910,15 +2205,15 @@ function acquireTelegramUpdateJournalFamily(
1910
2205
  } finally { closeSync(fd); }
1911
2206
  };
1912
2207
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
1913
- const enumerate = (): string[] => {
2208
+ const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files): string[] => {
1914
2209
  const names: string[] = [];
1915
- const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
2210
+ const directory = opendirSync(target, { bufferSize: 1 });
1916
2211
  try {
1917
2212
  for (;;) {
1918
2213
  const entry = directory.readSync();
1919
2214
  if (!entry) break;
1920
- if (names.length >= limits.maxFiles - files) capacity();
1921
- if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected segment entry");
2215
+ if (names.length >= limits.maxFiles - usedFiles) capacity();
2216
+ if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected journal evidence entry");
1922
2217
  names.push(entry.name);
1923
2218
  }
1924
2219
  } finally { directory.closeSync(); }
@@ -1976,6 +2271,7 @@ function acquireTelegramUpdateJournalFamily(
1976
2271
  if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
1977
2272
  if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
1978
2273
  segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
2274
+ assertJournalSourceCompletionContinuity(file, segment, target);
1979
2275
  const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1980
2276
  for (const id of segment.removedUpdateIds) entries.delete(id);
1981
2277
  for (const entry of segment.upsertedEntries) {
@@ -1993,15 +2289,63 @@ function acquireTelegramUpdateJournalFamily(
1993
2289
  ...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
1994
2290
  entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
1995
2291
  operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
2292
+ sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
1996
2293
  };
1997
2294
  charge(next);
1998
2295
  file = parseJournalFile(next, target, file.version);
1999
2296
  }
2000
2297
  }
2298
+ const retainedInputs: TelegramUpdateJournalRetentionInspection["retainedInputs"] = [];
2299
+ const retentionDirectory = `${path}.retained`;
2300
+ const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
2301
+ const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
2302
+ const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
2303
+ if (retentionNames.length > limits.maxEntries) capacity();
2304
+ if (retention) {
2305
+ if (!file || file.version !== 1) fail("private retention requires its exact v1 journal snapshot");
2306
+ const current = file!;
2307
+ const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity =>
2308
+ createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
2309
+ const updateIds = new Set<number>();
2310
+ for (const name of retentionNames) {
2311
+ const target = join(retentionDirectory, name);
2312
+ const before = observe(target, false)!;
2313
+ if (before.nlink !== 1n) fail("has linked private retention evidence");
2314
+ const raw = read(target, before);
2315
+ files += 1;
2316
+ if (limits.maxWork - work < 2) capacity();
2317
+ work += 2;
2318
+ if (!isRecord(raw)) throw createJournalError("invalid", target, "private retention is not an evidence record");
2319
+ if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
2320
+ throw createJournalError("invalid", target, "has foreign private retention evidence");
2321
+ const entry = validateJournalEntry(raw.entry, target, 1);
2322
+ const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
2323
+ const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
2324
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
2325
+ disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
2326
+ disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
2327
+ name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
2328
+ journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition })) fail("has inconsistent private retention evidence");
2329
+ if (updateIds.has(entry.updateId)) fail("has duplicate private retention sources");
2330
+ updateIds.add(entry.updateId);
2331
+ const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
2332
+ if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
2333
+ fail("private retention contradicts its journal disposition");
2334
+ retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
2335
+ failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
2336
+ }
2337
+ }
2338
+ if (inspectRetention && file?.operatorDispositions?.some(disposition =>
2339
+ "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
2340
+ disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
2341
+ !retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
2342
+ fail("committed abandonment lost its private original");
2001
2343
  // Re-enumeration has the same bound, rather than allocating an unbounded census.
2002
2344
  files = snapshot ? 1 : 0;
2003
2345
  if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
2004
2346
  files += names.length;
2347
+ if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern))) fail("changed private retention namespace");
2348
+ files += retentionNames.length;
2005
2349
  for (const [target, before] of observed) {
2006
2350
  const after = status(target);
2007
2351
  const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
@@ -2023,6 +2367,7 @@ function acquireTelegramUpdateJournalFamily(
2023
2367
  evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
2024
2368
  snapshotRevision,
2025
2369
  segments: acquiredSegments,
2370
+ ...(inspectRetention ? { retainedInputs } : {}),
2026
2371
  };
2027
2372
  } catch (error) {
2028
2373
  if (error instanceof TelegramUpdateJournalError) throw error;
@@ -2036,16 +2381,35 @@ function acquireTelegramUpdateJournalFamily(
2036
2381
  * Recensus detects observable changes, not hostile same-user path swaps; arbitrary
2037
2382
  * consumer references and archive consumption require separate audits.
2038
2383
  */
2039
- export function inspectTelegramProfileJournalNamespace(input: {
2384
+ export interface TelegramJournalNamespaceInspectionInput {
2040
2385
  directory: string;
2041
2386
  profile: string;
2042
2387
  botIdentity: TelegramUpdateJournalBotIdentity;
2043
2388
  limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
2044
- }): {
2045
- sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
2389
+ /** Leader polling journal named by owners.json; defaults to the flat root `inbox`. */
2390
+ pollingPath?: string;
2391
+ /** Hold a participating consumer reference during each physical family read. */
2392
+ withSourceReference?: <T>(path: string, operation: () => T) => T;
2393
+ }
2394
+
2395
+ export interface TelegramJournalNamespaceInspection {
2396
+ /** Original preservation facts only, never replay/deletion grants. */
2397
+ retainedInputs?: (TelegramUpdateJournalRetentionInspection["retainedInputs"][number] & { journalPath: string })[];
2398
+ sources: { role: "polling" | "follower" | "session"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
2046
2399
  accounting: { directoryEntries: number; files: number; bytes: number; work: number };
2047
2400
  knownBotId?: number;
2048
- } {
2401
+ }
2402
+
2403
+ export function inspectTelegramProfileJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
2404
+ return inspectTelegramJournalNamespace(input, false);
2405
+ }
2406
+
2407
+ /** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
2408
+ export function inspectTelegramSessionJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
2409
+ return inspectTelegramJournalNamespace(input, true);
2410
+ }
2411
+
2412
+ function inspectTelegramJournalNamespace(input: TelegramJournalNamespaceInspectionInput, includeSessions: boolean): TelegramJournalNamespaceInspection {
2049
2413
  const { directory, profile, limits } = input;
2050
2414
  const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
2051
2415
  const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
@@ -2062,46 +2426,99 @@ export function inspectTelegramProfileJournalNamespace(input: {
2062
2426
  size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
2063
2427
  };
2064
2428
  const suffix = profile === "default" ? "" : `.${profile}`;
2065
- const polling = `inbox${suffix}.json`;
2429
+ const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
2430
+ const polling = relative(directory, pollingPath);
2431
+ {
2432
+ const parts = polling.split(sep);
2433
+ if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
2434
+ !(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
2435
+ decodeTelegramSessionDirectoryName(parts[1]!) !== undefined))) fail("requires a canonical polling journal path");
2436
+ }
2066
2437
  try {
2067
2438
  const root = lstatSync(directory);
2068
2439
  if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
2069
2440
  const beforeRoot = metadata(directory);
2070
2441
  const census = () => {
2071
2442
  const entries = new Map<string, ReturnType<typeof metadata>>();
2072
- const followers = new Set<string>();
2073
- const handle = opendirSync(directory, { bufferSize: 1 });
2074
- try {
2075
- for (;;) {
2076
- const entry = handle.readSync();
2077
- if (!entry) break;
2078
- if (entries.size >= limits.maxDirectoryEntries) capacity();
2079
- if (entry.name.toLowerCase() === "recovery") fail("retains unclassified recovery storage");
2080
- const target = join(directory, entry.name);
2081
- entries.set(entry.name, metadata(target));
2082
- if (!/inbox/iu.test(entry.name)) continue;
2083
- const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.segments)?$/u.exec(entry.name);
2084
- if (!match || match[2] === "default") fail("has a noncanonical journal-like entry");
2085
- const value = lstatSync(target);
2086
- if (value.isSymbolicLink() || !(match![3] ? value.isDirectory() : value.isFile())) fail("has a linked or unexpected journal type");
2087
- if ((match![2] ?? "default") !== profile) continue;
2088
- if (match![1] !== "inbox") followers.add(entry.name.replace(/\.segments$/u, ""));
2443
+ const families = new Map<string, "follower" | "session">(), profiles = new Set<string>();
2444
+ const scan = (parent: string, visit: (name: string, target: string) => void): void => {
2445
+ const handle = opendirSync(parent, { bufferSize: 1 });
2446
+ try {
2447
+ for (;;) {
2448
+ const entry = handle.readSync();
2449
+ if (!entry) break;
2450
+ if (entries.size >= limits.maxDirectoryEntries) capacity();
2451
+ const target = join(parent, entry.name);
2452
+ const key = relative(directory, target);
2453
+ if (entries.has(key)) fail("repeated an observed namespace entry");
2454
+ entries.set(key, metadata(target));
2455
+ visit(entry.name, target);
2456
+ }
2457
+ } finally { handle.closeSync(); }
2458
+ };
2459
+ const requireType = (target: string, isDirectory: boolean): void => {
2460
+ const value = lstatSync(target);
2461
+ if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
2462
+ (!isDirectory && value.nlink !== 1)) fail("has a linked or unexpected journal type");
2463
+ };
2464
+ const evidenceDirectory = (target: string, retained: boolean): void => scan(target, (name, path) => {
2465
+ if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name)) fail("has an unclassified journal evidence entry");
2466
+ requireType(path, false);
2467
+ });
2468
+ const session = (target: string): void => scan(target, (name, path) => {
2469
+ if (name === "recovery") return;
2470
+ const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
2471
+ if (!match || match[1] === "default") fail("has an unclassified session journal entry");
2472
+ requireType(path, !!match![2]);
2473
+ if (match![2]) evidenceDirectory(path, match![2] === ".retained");
2474
+ const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![1] ?? "default";
2475
+ profiles.add(familyProfile);
2476
+ if (familyProfile === profile && base !== pollingPath) families.set(base, "session");
2477
+ });
2478
+ scan(directory, (name, target) => {
2479
+ // Legacy quarantine folders are disposable housekeeping, never journal authority.
2480
+ if (name.toLowerCase() === "recovery") return;
2481
+ if (name.toLowerCase() === "sessions") {
2482
+ if (!includeSessions) fail("requires session-qualified namespace inspection");
2483
+ if (name !== "sessions") fail("has a noncanonical session root");
2484
+ requireType(target, true);
2485
+ scan(target, (sessionName, sessionPath) => {
2486
+ if (decodeTelegramSessionDirectoryName(sessionName) === undefined) fail("has a noncanonical session directory");
2487
+ requireType(sessionPath, true);
2488
+ session(sessionPath);
2489
+ });
2490
+ return;
2089
2491
  }
2090
- } finally { handle.closeSync(); }
2091
- return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
2492
+ if (includeSessions && /^journal\./iu.test(name)) fail("has a misplaced session journal");
2493
+ if (!/inbox/iu.test(name)) return;
2494
+ const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
2495
+ if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained")) fail("has a noncanonical journal-like entry");
2496
+ requireType(target, !!match![3]);
2497
+ if (includeSessions && match![3]) evidenceDirectory(target, match![3] === ".retained");
2498
+ const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![2] ?? "default";
2499
+ profiles.add(familyProfile);
2500
+ if (familyProfile === profile && base !== pollingPath)
2501
+ families.set(base, match![1] === "inbox" ? "session" : "follower");
2502
+ });
2503
+ return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
2504
+ families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
2092
2505
  };
2093
2506
  const before = census();
2094
- const sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[] = [];
2507
+ const sourceInventory = [[pollingPath, "polling"], ...before.families] as const;
2508
+ const sources: TelegramJournalNamespaceInspection["sources"] = [];
2509
+ const retainedInputs: NonNullable<TelegramJournalNamespaceInspection["retainedInputs"]> = [];
2095
2510
  const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
2096
2511
  let knownBotId = expected.botId;
2097
- for (const name of [polling, ...before.followers]) {
2512
+ for (const [path, role] of sourceInventory) {
2098
2513
  const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
2099
2514
  maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
2100
2515
  if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0) capacity();
2101
- const path = join(directory, name);
2102
- const evidence = inspectTelegramUpdateJournalFamily({ directory, path, profile,
2103
- botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining });
2104
- if (evidence.kind === "absent" && (name !== polling || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
2516
+ const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
2517
+ botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
2518
+ const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
2519
+ const evidence = acquired.evidence;
2520
+ retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
2521
+ if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
2105
2522
  fail("lost a discovered journal family");
2106
2523
  }
2107
2524
  if (evidence.kind === "present") {
@@ -2110,16 +2527,46 @@ export function inspectTelegramProfileJournalNamespace(input: {
2110
2527
  accounting.bytes += evidence.accounting.bytes;
2111
2528
  }
2112
2529
  accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
2113
- sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
2530
+ sources.push({ role, path, evidence });
2114
2531
  }
2115
- if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory))) fail("changed observed root namespace or identity");
2116
- return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
2532
+ const namespaceCurrent = (): boolean => {
2533
+ try { return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
2534
+ isDeepStrictEqual(beforeRoot, metadata(directory)); }
2535
+ catch { return false; }
2536
+ };
2537
+ if (!namespaceCurrent()) fail("changed observed root namespace or identity");
2538
+ return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
2117
2539
  } catch (error) {
2118
2540
  if (error instanceof TelegramUpdateJournalError) throw error;
2119
2541
  throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
2120
2542
  }
2121
2543
  }
2122
2544
 
2545
+ /** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
2546
+ export function isTelegramThreadCleanupJournalNamespaceClear(input: TelegramJournalNamespaceInspectionInput & {
2547
+ requiredJournalBindingKeys: readonly string[];
2548
+ withSourceReference: NonNullable<TelegramJournalNamespaceInspectionInput["withSourceReference"]>;
2549
+ }): boolean {
2550
+ if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
2551
+ throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
2552
+ const requiredPaths = new Set<string>();
2553
+ for (const key of input.requiredJournalBindingKeys) {
2554
+ const path = getTelegramUpdateJournalBindingPath(key);
2555
+ const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
2556
+ if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
2557
+ path, profileName: input.profile, botIdentity,
2558
+ }))) throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
2559
+ requiredPaths.add(path);
2560
+ }
2561
+ const namespace = inspectTelegramSessionJournalNamespace(input);
2562
+ if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
2563
+ namespace.retainedInputs?.some(original => original.state !== "committed") ||
2564
+ [...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
2565
+ return false;
2566
+ // Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
2567
+ return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
2568
+ }
2569
+
2123
2570
  function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
2124
2571
  return {
2125
2572
  ...entry,
@@ -2134,6 +2581,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
2134
2581
  ? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
2135
2582
  : {}),
2136
2583
  ...(entry.failure ? { failure: { ...entry.failure } } : {}),
2584
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
2137
2585
  };
2138
2586
  }
2139
2587
 
@@ -2147,6 +2595,7 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
2147
2595
  profile: file.profile,
2148
2596
  botIdentity: { ...file.botIdentity },
2149
2597
  entries: file.entries.map(cloneEntry),
2598
+ ...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
2150
2599
  ...(file.operatorDispositions?.length
2151
2600
  ? {
2152
2601
  operatorDispositions: file.operatorDispositions.map(
@@ -2288,6 +2737,46 @@ function mergeBotIdentity(
2288
2737
  };
2289
2738
  }
2290
2739
 
2740
+ function createPendingRetentionReference(input: {
2741
+ path: string;
2742
+ journalBindingKey: string;
2743
+ entry: TelegramUpdateJournalEntry;
2744
+ maxBytes: number;
2745
+ }) {
2746
+ const { path, journalBindingKey, entry, maxBytes } = input;
2747
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
2748
+ const failureId = `abandon-${evidenceSha256}`;
2749
+ const retainedDirectory = `${path}.retained`;
2750
+ const retainedPath = join(retainedDirectory, `${failureId}.json`);
2751
+ const read = (): TelegramUpdateJournalRetainedInput | undefined => {
2752
+ let directory;
2753
+ try { directory = lstatSync(retainedDirectory); }
2754
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
2755
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
2756
+ throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
2757
+ }
2758
+ let stat;
2759
+ try { stat = lstatSync(retainedPath); }
2760
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
2761
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
2762
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
2763
+ }
2764
+ const retained: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
2765
+ if (!isRecord(retained)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
2766
+ const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
2767
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
2768
+ disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
2769
+ disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
2770
+ !isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
2771
+ entry, requestedDisposition: disposition })) {
2772
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
2773
+ }
2774
+ return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
2775
+ requestedDisposition: disposition };
2776
+ };
2777
+ return { evidenceSha256, failureId, retainedPath, read };
2778
+ }
2779
+
2291
2780
  function writeJournalFile(
2292
2781
  path: string,
2293
2782
  serialized: string,
@@ -2428,7 +2917,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
2428
2917
  runtimeKey: string;
2429
2918
  recoveryKey: string;
2430
2919
  journal: TelegramUpdateJournalStore;
2431
- readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
2920
+ readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[]; exists: boolean };
2432
2921
  }
2433
2922
 
2434
2923
  export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
@@ -2438,11 +2927,14 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
2438
2927
  getJournalPath: (profileName?: string) => string;
2439
2928
  getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
2440
2929
  withWriterAdmission?: <T>(operation: () => T) => T;
2930
+ withSourceSerialization?: TelegramUpdateJournalStoreOptions["withSourceSerialization"];
2441
2931
  getWorkspaceAdmission?: () => Pick<
2442
2932
  TelegramWorkspaceAdmissionLedger,
2443
2933
  "acquireAdmission" | "releaseAdmission"
2444
2934
  > | undefined;
2445
2935
  onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
2936
+ /** Strict no-follow source handles; defaults to platform support (absent on Windows). */
2937
+ strictSourceAccess?: boolean;
2446
2938
  }
2447
2939
 
2448
2940
  export function createTelegramUpdateJournalRuntimeBindingResolver(
@@ -2460,6 +2952,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2460
2952
  });
2461
2953
  const path = deps.getJournalPath(configuredProfileName);
2462
2954
  const workspaceAdmission = deps.getWorkspaceAdmission?.();
2955
+ const sourceAccess = { directory: dirname(path), limits: {
2956
+ maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2957
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2958
+ } };
2959
+ const options: TelegramUpdateJournalStoreOptions = { path, profileName, botIdentity,
2960
+ ...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
2961
+ ...(workspaceAdmission ? { workspaceAdmission } : {}),
2962
+ ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
2963
+ ...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
2964
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
2965
+ const journal = createTelegramUpdateJournalStore(options);
2966
+ // Scoped completion and receipt observation use strict private handles where the platform provides no-follow
2967
+ // evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
2968
+ const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
2969
+ const completionJournal = deps.withSourceSerialization
2970
+ ? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
2971
+ // Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
2972
+ const observeQueuedReceipt = (expected: TelegramUpdateJournalQueuedCompletion) => strict
2973
+ ? completionJournal!.inspectQueuedReceipt(expected)
2974
+ : inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
2463
2975
  return {
2464
2976
  runtimeKey: JSON.stringify({
2465
2977
  path,
@@ -2474,27 +2986,24 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2474
2986
  readForProtection() {
2475
2987
  // Protection must never turn corruption/recovery into empty-work evidence.
2476
2988
  const evidence = inspectTelegramUpdateJournalFamily({
2477
- directory: dirname(path), path, profile: profileName, botIdentity,
2478
- limits: {
2479
- maxFiles: 1024,
2480
- maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2481
- maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
2482
- maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2483
- },
2989
+ ...sourceAccess, path, profile: profileName, botIdentity,
2484
2990
  });
2485
- return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
2991
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
2486
2992
  },
2487
- journal: createTelegramUpdateJournalStore({
2488
- path,
2489
- profileName,
2490
- botIdentity,
2491
- ...(deps.getQueueRuntimeIdentity
2492
- ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
2493
- : {}),
2494
- ...(workspaceAdmission ? { workspaceAdmission } : {}),
2495
- ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
2496
- ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
2497
- }),
2993
+ journal: completionJournal ? { ...journal,
2994
+ removeCompletedExact(updateIds, expectedSources, completions) {
2995
+ return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
2996
+ : journal.removeCompletedExact(updateIds, expectedSources);
2997
+ },
2998
+ completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
2999
+ inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
3000
+ inspectQueuedReceipt: observeQueuedReceipt,
3001
+ isQueueReceiptCurrent(receipt, owner) {
3002
+ if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity })) return false;
3003
+ return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
3004
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
3005
+ },
3006
+ } : journal,
2498
3007
  };
2499
3008
  };
2500
3009
  }
@@ -2560,13 +3069,82 @@ export function withTelegramResolvedUpdateJournalReference<T>(input: {
2560
3069
  recoveryKey: binding.recoveryKey }, () => input.operation(binding));
2561
3070
  }
2562
3071
 
3072
+ function validateQueuedReceiptObservation(expected: TelegramUpdateJournalQueuedCompletion, path: string): TelegramUpdateJournalQueueOwner {
3073
+ if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
3074
+ !isNonEmptyString(expected.receiptId) ||
3075
+ !Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
3076
+ expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
3077
+ expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]!))) {
3078
+ throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
3079
+ }
3080
+ return validateJournalQueueOwner(expected.queueOwner, path);
3081
+ }
3082
+
3083
+ function inspectJournalQueuedReceipt(file: TelegramUpdateJournalFile, expected: TelegramUpdateJournalQueuedCompletion,
3084
+ path: string): TelegramUpdateJournalQueuedReceiptEvidence | undefined {
3085
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
3086
+ const { queueOwner } = expected;
3087
+ const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
3088
+ if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) =>
3089
+ entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
3090
+ entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner))) return undefined;
3091
+ return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
3092
+ sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
3093
+ sources: entries.map(createTelegramUpdateJournalEntryDigest),
3094
+ queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
3095
+ }
3096
+
3097
+ function inspectJournalSourceCompletion(file: TelegramUpdateJournalFile, completion: TelegramUpdateJournalSourceCompletion,
3098
+ path: string): TelegramUpdateJournalSourceCompletion | undefined {
3099
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
3100
+ const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
3101
+ if (!retained) return undefined;
3102
+ if (!isDeepStrictEqual(retained, completion)) throw createJournalError("conflict", path, "source completion belongs to another exact scope");
3103
+ return { ...retained };
3104
+ }
3105
+
3106
+ /** Operator-distinct authority: routing never treats it as an owner cancellation. */
3107
+ export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
3108
+
3109
+ export interface TelegramSessionPendingAdoptionInput {
3110
+ recipientBindingKey: string;
3111
+ predecessorSessionId: string;
3112
+ successorSessionId: string;
3113
+ /** Caller proves the successor registration is current and no predecessor worker runs. */
3114
+ isCurrent: () => boolean;
3115
+ }
3116
+
3117
+ export interface TelegramSessionPendingAdoptionResult {
3118
+ adoptedUpdateIds: number[];
3119
+ /** Already committed away by an earlier attempt; never re-appended, the private original remains. */
3120
+ retainedUpdateIds: number[];
3121
+ }
3122
+
3123
+ function isAdoptablePendingEntry(entry: TelegramUpdateJournalEntry): boolean {
3124
+ return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
3125
+ !entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
3126
+ entry.preApprovalExcluded !== true;
3127
+ }
3128
+
2563
3129
  export interface TelegramUpdateJournalBindingRuntime {
2564
3130
  resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
2565
3131
  resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
2566
3132
  resolveActive: () => TelegramUpdateJournalRuntimeBinding | undefined;
2567
3133
  getActiveRecoveryKey: () => string | undefined;
3134
+ /** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
3135
+ inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
3136
+ inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
3137
+ inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
3138
+ /** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
3139
+ adoptPredecessorPending: (input: TelegramSessionPendingAdoptionInput) => TelegramSessionPendingAdoptionResult;
3140
+ /** Process-local: adopts from the previously prepared session of the same recipient key, then records the active one. */
3141
+ prepareActiveFollowerSuccession: (isCurrent: () => boolean) => TelegramSessionPendingAdoptionResult | undefined;
2568
3142
  createRecipientResolver: (
2569
3143
  recipientBindingKey: string,
3144
+ sessionId?: string,
3145
+ ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
3146
+ createLegacyRecipientResolver: (
3147
+ recipientBindingKey: string,
2570
3148
  ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
2571
3149
  createPathResolver: (
2572
3150
  path: string,
@@ -2576,11 +3154,15 @@ export interface TelegramUpdateJournalBindingRuntime {
2576
3154
  export function createTelegramUpdateJournalBindingRuntime(deps: {
2577
3155
  base: Omit<TelegramUpdateJournalRuntimeBindingResolverDeps, "getJournalPath">;
2578
3156
  getLeaderJournalPath: (profileName?: string) => string;
3157
+ /** Runtime root; defaults to the leader journal's directory for flat layouts. */
3158
+ getRuntimeDir?: () => string;
2579
3159
  getFollowerJournalPath: (
2580
3160
  bindingKey: string,
2581
3161
  profileName?: string,
3162
+ sessionId?: string,
2582
3163
  ) => string;
2583
3164
  getActiveFollowerBindingKey: () => string;
3165
+ getActiveFollowerSessionId?: () => string | undefined;
2584
3166
  isFollowerRegistered: () => boolean;
2585
3167
  }): TelegramUpdateJournalBindingRuntime {
2586
3168
  const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
@@ -2590,8 +3172,12 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2590
3172
  const createFollowerResolver = (
2591
3173
  bindingKey: string,
2592
3174
  includeQueueRuntimeIdentity: boolean,
2593
- ) =>
2594
- createTelegramUpdateJournalRuntimeBindingResolver({
3175
+ sessionId?: string,
3176
+ legacy = false,
3177
+ ) => {
3178
+ // Session-aware composition must not fall back to a process journal before preparation supplies its ID.
3179
+ if (!legacy && deps.getActiveFollowerSessionId && !sessionId) return () => undefined;
3180
+ return createTelegramUpdateJournalRuntimeBindingResolver({
2595
3181
  getProfileName: deps.base.getProfileName,
2596
3182
  getBotToken: deps.base.getBotToken,
2597
3183
  getBotId: deps.base.getBotId,
@@ -2600,25 +3186,129 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2600
3186
  : {}),
2601
3187
  ...(deps.base.withWriterAdmission
2602
3188
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
3189
+ // Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
3190
+ ...(deps.base.withSourceSerialization
3191
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
2603
3192
  ...(deps.base.getWorkspaceAdmission
2604
3193
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2605
3194
  : {}),
2606
3195
  ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
2607
3196
  getJournalPath(profileName) {
2608
- return deps.getFollowerJournalPath(bindingKey, profileName);
3197
+ return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
2609
3198
  },
2610
3199
  });
3200
+ };
2611
3201
  const resolveFollower = () =>
2612
- createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
3202
+ createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
2613
3203
  const resolveActive = () =>
2614
3204
  deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
2615
- return {
3205
+ const inspectHistoricalSource = <T>(journalBindingKey: string,
3206
+ operation: (file: TelegramUpdateJournalFile, originals: TelegramUpdateJournalRetentionInspection["retainedInputs"], path: string) => T | undefined): T | undefined => {
3207
+ const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
3208
+ const token = deps.base.getBotToken(), botId = deps.base.getBotId();
3209
+ const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
3210
+ if (!path || !token) return undefined;
3211
+ const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
3212
+ const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity =>
3213
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
3214
+ if (!keys.includes(journalBindingKey)) return undefined;
3215
+ const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
3216
+ const root = getRoot();
3217
+ const parts = relative(root, path).split(sep);
3218
+ const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
3219
+ const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
3220
+ const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
3221
+ if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
3222
+ !(parts.length === 1 && flat.test(parts[0]!) || parts.length === 3 && parts[0] === "sessions" &&
3223
+ decodeTelegramSessionDirectoryName(parts[1]!) !== undefined && session.test(parts[2]!))) return undefined;
3224
+ const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
3225
+ (deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
3226
+ const inspect = () => {
3227
+ if (!current()) return undefined;
3228
+ const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
3229
+ limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
3230
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
3231
+ if (observed.evidence.kind !== "present" || !current()) return undefined;
3232
+ return operation(observed.evidence.file, observed.retainedInputs, path);
3233
+ };
3234
+ const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
3235
+ return current() ? result : undefined;
3236
+ };
3237
+ let preparedFollower: { recipientBindingKey: string; sessionId: string } | undefined;
3238
+ const runtime: TelegramUpdateJournalBindingRuntime = {
2616
3239
  resolveLeader,
2617
3240
  resolveFollower,
2618
3241
  resolveActive,
2619
3242
  getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
2620
- createRecipientResolver: (bindingKey) =>
2621
- createFollowerResolver(bindingKey, false),
3243
+ prepareActiveFollowerSuccession(isCurrent) {
3244
+ const recipientBindingKey = deps.getActiveFollowerBindingKey();
3245
+ const sessionId = deps.getActiveFollowerSessionId?.();
3246
+ if (!sessionId) return undefined;
3247
+ const previous = preparedFollower;
3248
+ const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
3249
+ ? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
3250
+ successorSessionId: sessionId, isCurrent })
3251
+ : undefined;
3252
+ // Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
3253
+ preparedFollower = { recipientBindingKey, sessionId };
3254
+ return result;
3255
+ },
3256
+ inspectSourceAbandonment(journalBindingKey, updateId) {
3257
+ if (!isSafeNonNegativeInteger(updateId)) throw new Error("Telegram abandonment proof requires an exact update ID.");
3258
+ return inspectHistoricalSource(journalBindingKey, (file, originals) => {
3259
+ const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
3260
+ value.journalBindingKey === journalBindingKey);
3261
+ if (!original) return undefined;
3262
+ const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
3263
+ if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody") return undefined;
3264
+ return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
3265
+ });
3266
+ },
3267
+ inspectSourceCompletion(journalBindingKey, expected) {
3268
+ const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
3269
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
3270
+ },
3271
+ inspectQueuedReceipt(journalBindingKey, expected) {
3272
+ const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
3273
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
3274
+ },
3275
+ adoptPredecessorPending(input) {
3276
+ const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
3277
+ if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
3278
+ predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
3279
+ throw new Error("Telegram session adoption requires distinct exact session identities.");
3280
+ }
3281
+ const assertCurrent = () => {
3282
+ if (!isCurrent()) throw new Error("Telegram session adoption lost successor authority.");
3283
+ };
3284
+ assertCurrent();
3285
+ const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
3286
+ const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
3287
+ if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
3288
+ throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
3289
+ }
3290
+ const result: TelegramSessionPendingAdoptionResult = { adoptedUpdateIds: [], retainedUpdateIds: [] };
3291
+ // Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
3292
+ const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
3293
+ .sort((left, right) => left.updateId - right.updateId);
3294
+ for (const entry of candidates) {
3295
+ assertCurrent();
3296
+ // Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
3297
+ const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
3298
+ operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
3299
+ if (committed.duplicate) {
3300
+ result.retainedUpdateIds.push(entry.updateId);
3301
+ continue;
3302
+ }
3303
+ assertCurrent();
3304
+ successor.journal.appendBatch([entry.update]);
3305
+ result.adoptedUpdateIds.push(entry.updateId);
3306
+ }
3307
+ return result;
3308
+ },
3309
+ createRecipientResolver: (bindingKey, sessionId) =>
3310
+ createFollowerResolver(bindingKey, false, sessionId),
3311
+ createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
2622
3312
  createPathResolver: (path) =>
2623
3313
  createTelegramUpdateJournalRuntimeBindingResolver({
2624
3314
  getProfileName: deps.base.getProfileName,
@@ -2626,6 +3316,8 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2626
3316
  getBotId: deps.base.getBotId,
2627
3317
  ...(deps.base.withWriterAdmission
2628
3318
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
3319
+ ...(deps.base.withSourceSerialization
3320
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
2629
3321
  ...(deps.base.getWorkspaceAdmission
2630
3322
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2631
3323
  : {}),
@@ -2633,6 +3325,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2633
3325
  getJournalPath: () => path,
2634
3326
  }),
2635
3327
  };
3328
+ return runtime;
2636
3329
  }
2637
3330
 
2638
3331
  export function createTelegramUpdateJournalStore(
@@ -2720,7 +3413,6 @@ function createJournalStoreCore(
2720
3413
  };
2721
3414
  const getQueueProcessLiveness =
2722
3415
  options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
2723
-
2724
3416
  const validateQueueHandoffInput = (
2725
3417
  input: TelegramUpdateJournalQueueHandoffInput,
2726
3418
  operation: "offer" | "accept" | "cancel",
@@ -2841,6 +3533,9 @@ function createJournalStoreCore(
2841
3533
  `exceeds its ${maxEntries}-operator-disposition limit`,
2842
3534
  );
2843
3535
  }
3536
+ if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
3537
+ throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
3538
+ }
2844
3539
  const serializedBytes = Buffer.byteLength(serialized);
2845
3540
  if (serializedBytes > maxBytes) {
2846
3541
  throw createJournalError(
@@ -2859,7 +3554,7 @@ function createJournalStoreCore(
2859
3554
  entries: [],
2860
3555
  });
2861
3556
 
2862
- const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
3557
+ const readCurrentStrict = (allowReconciliation = true): ReadTelegramUpdateJournalResult => {
2863
3558
  let source: string;
2864
3559
  let recoveringMissingSnapshot = false;
2865
3560
  try {
@@ -2893,6 +3588,7 @@ function createJournalStoreCore(
2893
3588
  );
2894
3589
  }
2895
3590
  if (orphanedSegmentNames.length > 0) {
3591
+ if (!allowReconciliation) throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
2896
3592
  if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
2897
3593
  recoveringMissingSnapshot = true;
2898
3594
  source = serializeJournalFile(emptyFile());
@@ -3001,6 +3697,7 @@ function createJournalStoreCore(
3001
3697
  "belongs to another journal identity",
3002
3698
  );
3003
3699
  }
3700
+ assertJournalSourceCompletionContinuity(file, segment, segmentPath);
3004
3701
  const entriesById = new Map(
3005
3702
  file.entries.map((entry) => [entry.updateId, entry]),
3006
3703
  );
@@ -3040,6 +3737,8 @@ function createJournalStoreCore(
3040
3737
  : file.operatorDispositions?.length
3041
3738
  ? { operatorDispositions: file.operatorDispositions }
3042
3739
  : {}),
3740
+ ...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
3741
+ : file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
3043
3742
  },
3044
3743
  segmentPath,
3045
3744
  version,
@@ -3049,9 +3748,9 @@ function createJournalStoreCore(
3049
3748
  const identityChanged =
3050
3749
  storedProfile !== profile ||
3051
3750
  !identitiesMatch(file.botIdentity, expectedIdentity) ||
3052
- (withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
3751
+ ((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
3053
3752
  if (identityChanged) {
3054
- if (file.entries.length > 0 || withPairingAdmission) {
3753
+ if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
3055
3754
  throw createJournalError(
3056
3755
  "identity-mismatch",
3057
3756
  path,
@@ -3205,66 +3904,35 @@ function createJournalStoreCore(
3205
3904
  }
3206
3905
  }
3207
3906
 
3208
- const recoveryDirectory = join(
3209
- dirname(path),
3210
- "recovery",
3211
- `${getNowMs()}-${process.pid}-${randomUUID()}`,
3212
- );
3213
- mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
3214
- const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
3215
- const segmentQuarantinePath = join(
3216
- recoveryDirectory,
3217
- basename(segmentDirectory),
3218
- );
3219
- if (
3220
- snapshotExists &&
3221
- !renameTelegramPathWithRetry(path, snapshotQuarantinePath)
3222
- ) {
3223
- throw createJournalError(
3224
- "io",
3225
- path,
3226
- "could not quarantine an unrecoverable journal snapshot",
3227
- error,
3228
- );
3229
- }
3230
- if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
3231
- if (snapshotExists) {
3232
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
3907
+ // Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
3908
+ // Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
3909
+ const deletedPaths: string[] = [];
3910
+ const deleteDamaged = (target: string, label: string): void => {
3911
+ try {
3912
+ lstatSync(target);
3913
+ } catch (statError) {
3914
+ if ((statError as NodeJS.ErrnoException).code === "ENOENT") return;
3915
+ throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
3233
3916
  }
3234
- throw createJournalError(
3235
- "io",
3236
- segmentDirectory,
3237
- "could not quarantine an unrecoverable journal segment history",
3238
- error,
3239
- );
3240
- }
3917
+ try {
3918
+ rmSync(target, { recursive: true, force: true });
3919
+ } catch (removeError) {
3920
+ throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
3921
+ }
3922
+ deletedPaths.push(target);
3923
+ };
3924
+ deleteDamaged(segmentDirectory, "journal segment history");
3925
+ // A regular snapshot is replaced atomically below; anything else cannot be a rename target.
3926
+ if (snapshotExists && !lstatSync(path).isFile()) deleteDamaged(path, "journal snapshot");
3241
3927
  const reset = emptyFile();
3242
3928
  const serialized = serializeJournalFile(reset);
3243
3929
  const serializedBytes = assertCapacity(reset, serialized);
3244
- try {
3245
- writeJournalFile(path, serialized, onPublicationBoundary);
3246
- } catch (publicationError) {
3247
- const segmentsRestored = renameTelegramPathWithRetry(
3248
- segmentQuarantinePath,
3249
- segmentDirectory,
3250
- );
3251
- const snapshotRestored =
3252
- !snapshotExists ||
3253
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
3254
- if (!segmentsRestored || !snapshotRestored) {
3255
- throw createJournalError(
3256
- "io",
3257
- path,
3258
- "reset publication failed after journal evidence was quarantined",
3259
- publicationError,
3260
- );
3261
- }
3262
- throw publicationError;
3263
- }
3930
+ writeJournalFile(path, serialized, onPublicationBoundary);
3931
+ if (snapshotExists && !deletedPaths.includes(path)) deletedPaths.push(path);
3264
3932
  notifyRecovery({
3265
3933
  kind: "reset",
3266
3934
  path,
3267
- quarantinePath: recoveryDirectory,
3935
+ deletedPaths,
3268
3936
  reason: error.message,
3269
3937
  });
3270
3938
  return { file: reset, exists: true, serializedBytes };
@@ -3315,7 +3983,7 @@ function createJournalStoreCore(
3315
3983
  const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
3316
3984
  const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
3317
3985
  const getFileWork = (file: TelegramUpdateJournalFile): number =>
3318
- file.entries.length + (file.operatorDispositions?.length ?? 0);
3986
+ file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
3319
3987
  const advanceInputHeadroomState = (state: InputHeadroomState,
3320
3988
  entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
3321
3989
  const revision = (state.file.revision ?? 0) + 1;
@@ -3491,7 +4159,7 @@ function createJournalStoreCore(
3491
4159
  ): TelegramUpdateJournalSegment => {
3492
4160
  const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
3493
4161
  const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
3494
- return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
4162
+ return { version,
3495
4163
  revision: next.revision!, previousRevision: next.revision! - 1,
3496
4164
  profile: next.profile, botIdentity: next.botIdentity,
3497
4165
  upsertedEntries: next.entries.filter(entry =>
@@ -3521,7 +4189,7 @@ function createJournalStoreCore(
3521
4189
  : { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
3522
4190
  if (publishedSegment) {
3523
4191
  const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
3524
- (publishedSegment.operatorDispositions?.length ?? 0);
4192
+ (publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
3525
4193
  const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
3526
4194
  baseRetainedCount += 1;
3527
4195
  baseRetainedBytes += segmentBytes;
@@ -3580,9 +4248,10 @@ function createJournalStoreCore(
3580
4248
  const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
3581
4249
  const segmentBytes = Buffer.byteLength(segmentText);
3582
4250
  const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
3583
- segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
3584
- const segmentWork = collections[0]! + collections[1]! + collections[2]!;
3585
- const fileWork = collections[3]! + collections[4]!;
4251
+ segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
4252
+ segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
4253
+ const segmentWork = collections[0]! + collections[1]! + collections[2]! + collections[5]!;
4254
+ const fileWork = collections[3]! + collections[4]! + collections[6]!;
3586
4255
  const accounting = source.evidence.accounting;
3587
4256
  // Both the segment-first state and snapshot-first cleanup residue must fit.
3588
4257
  assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
@@ -3625,7 +4294,8 @@ function createJournalStoreCore(
3625
4294
  acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
3626
4295
  publicationBoundary = onPublicationBoundary,
3627
4296
  inputHeadroom: number | readonly number[] | "all" | false =
3628
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false,
4297
+ journalHasCustodyFields(version) ? "all" : false,
4298
+ sourceCompletions = current.file.sourceCompletions,
3629
4299
  ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
3630
4300
  const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
3631
4301
  current.file.botIdentity,
@@ -3638,7 +4308,8 @@ function createJournalStoreCore(
3638
4308
  current.file.operatorDispositions ?? [],
3639
4309
  operatorDispositions ?? [],
3640
4310
  ) &&
3641
- current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
4311
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
4312
+ isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])
3642
4313
  ) {
3643
4314
  return { file: current.file, serializedBytes: current.serializedBytes };
3644
4315
  }
@@ -3653,21 +4324,24 @@ function createJournalStoreCore(
3653
4324
  ...(operatorDispositions?.length
3654
4325
  ? { operatorDispositions }
3655
4326
  : {}),
4327
+ ...(sourceCompletions?.length ? { sourceCompletions } : {}),
3656
4328
  };
4329
+ assertJournalRoutingInputContinuity(current.file, entries, path);
3657
4330
  if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
3658
4331
  const serialized = serializeJournalFile(file);
3659
4332
  const serializedBytes = assertCapacity(file, serialized);
3660
4333
  const changed =
3661
4334
  contentChanged ||
3662
4335
  current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
3663
- !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
4336
+ !isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
4337
+ !isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
3664
4338
  if (!changed) {
3665
4339
  return { file, serializedBytes: current.serializedBytes };
3666
4340
  }
3667
4341
  if (!current.exists) {
3668
4342
  if (current.source) {
3669
- const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
3670
- assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
4343
+ const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
4344
+ assertSourceResources(1, serializedBytes, collections[0]! + collections[1]! + collections[2]!, collections);
3671
4345
  }
3672
4346
  if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
3673
4347
  writeJournalFile(path, serialized, publicationBoundary);
@@ -3709,13 +4383,14 @@ function createJournalStoreCore(
3709
4383
  )
3710
4384
  ? { operatorDispositions: operatorDispositions ?? [] }
3711
4385
  : {}),
4386
+ ...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
3712
4387
  };
3713
4388
  if (current.source) {
3714
4389
  if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
3715
4390
  const revisedFile = { ...file, revision };
3716
4391
  if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
3717
4392
  return publishSourceSegment(current, file, segment, publicationBoundary,
3718
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
4393
+ journalHasCustodyFields(version));
3719
4394
  }
3720
4395
  publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
3721
4396
  const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
@@ -3768,6 +4443,160 @@ function createJournalStoreCore(
3768
4443
  return { file: revisedFile, serializedBytes };
3769
4444
  };
3770
4445
 
4446
+ const removeCompleted = (updateIds: readonly number[], expectedSources?: readonly TelegramUpdateJournalEntryDigest[], exact = false,
4447
+ completionInput?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult =>
4448
+ runMutation((readCurrent) => {
4449
+ const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
4450
+ if (completionInput !== undefined && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
4451
+ const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
4452
+ if (exact && expectedSources === undefined) throw createJournalError("invalid", path, "received no exact completion sources");
4453
+ const requestedIds = new Set<number>();
4454
+ for (const updateId of updateIds) {
4455
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "received an invalid removal update id");
4456
+ requestedIds.add(updateId);
4457
+ }
4458
+ if (expectedSources !== undefined) {
4459
+ if (!Array.isArray(expectedSources) || !expectedSources.length) throw createJournalError("invalid", path, "received no exact completion sources");
4460
+ const expectedIds = new Set<number>();
4461
+ for (const expected of expectedSources) {
4462
+ if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
4463
+ !isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
4464
+ typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
4465
+ throw createJournalError("invalid", path, "received an invalid exact completion source");
4466
+ }
4467
+ expectedIds.add(expected.updateId);
4468
+ const entry = current.file.entries.find(value => value.updateId === expected.updateId);
4469
+ if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
4470
+ throw createJournalError("conflict", path, "exact completion source changed or disappeared");
4471
+ }
4472
+ }
4473
+ }
4474
+ if (completions) {
4475
+ const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
4476
+ if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
4477
+ throw createJournalError("invalid", path, "source completion has no matching exact guard");
4478
+ }
4479
+ }
4480
+ const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
4481
+ const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
4482
+ if (protectedEntry) throw createJournalError("conflict", path, protectedEntry.state === "failed"
4483
+ ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
4484
+ : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
4485
+ const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
4486
+ const sourceCompletions = completions
4487
+ ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
4488
+ if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
4489
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0,
4490
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
4491
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
4492
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
4493
+ });
4494
+ const completeQueued = (receipts: readonly TelegramUpdateJournalQueuedCompletion[],
4495
+ completionInput?: readonly TelegramUpdateJournalSourceCompletion[], exact = false): TelegramUpdateJournalRemoveResult =>
4496
+ runMutation((readCurrent) => {
4497
+ if (exact && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
4498
+ const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
4499
+ if (!Array.isArray(receipts) || receipts.length === 0) throw createJournalError("invalid", path, "received no queued receipts to complete");
4500
+ const current = readCurrent();
4501
+ const receiptIds = new Set<string>(), requestedUpdateIds = new Set<number>();
4502
+ for (const receipt of receipts) {
4503
+ if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
4504
+ receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
4505
+ throw createJournalError("invalid", path, "received an invalid queued completion receipt");
4506
+ }
4507
+ receiptIds.add(receipt.receiptId);
4508
+ const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
4509
+ if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
4510
+ queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
4511
+ throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
4512
+ }
4513
+ const sourceUpdateIds = new Set<number>();
4514
+ for (const updateId of receipt.sourceUpdateIds) {
4515
+ if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
4516
+ throw createJournalError("invalid", path, "received overlapping queued completion update ids");
4517
+ }
4518
+ sourceUpdateIds.add(updateId); requestedUpdateIds.add(updateId);
4519
+ }
4520
+ const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
4521
+ if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry =>
4522
+ !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
4523
+ entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
4524
+ throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
4525
+ }
4526
+ }
4527
+ const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
4528
+ const removedUpdateIds = removedEntries.map(entry => entry.updateId);
4529
+ if (removedUpdateIds.length !== requestedUpdateIds.size) throw createJournalError("conflict", path, "queued completion did not resolve every source update");
4530
+ if (completions) {
4531
+ const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
4532
+ if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
4533
+ throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
4534
+ }
4535
+ }
4536
+ const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
4537
+ : current.file.sourceCompletions;
4538
+ if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
4539
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true,
4540
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
4541
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
4542
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
4543
+ });
4544
+ const mutateRoutingInputs = (input: TelegramRoutingInputAuthority, select: boolean, publishedAtMs?: number) => runMutation(readCurrent => {
4545
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
4546
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
4547
+ if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
4548
+ typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
4549
+ throw createJournalError("invalid", path, "invalid routing input lifetime authority");
4550
+ }
4551
+ const assertCurrent = () => { if (!input.isCurrent()) throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
4552
+ assertCurrent();
4553
+ const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
4554
+ if (new Set(expected.map(entry => entry.updateId)).size !== expected.length) throw createJournalError("invalid", path, "duplicate routing input sources");
4555
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
4556
+ const nowMs = select ? getNowMs() : publishedAtMs;
4557
+ if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs())) throw createJournalError("invalid", path, "invalid routing input lifetime clock");
4558
+ let issued = false;
4559
+ const changed = new Map<number, TelegramUpdateJournalEntry>();
4560
+ const result = expected.map(original => {
4561
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
4562
+ if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
4563
+ throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
4564
+ }
4565
+ const withoutLifetime = (value: TelegramUpdateJournalEntry) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
4566
+ if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
4567
+ (original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
4568
+ throw createJournalError("conflict", path, "routing input source changed");
4569
+ }
4570
+ const lifetime = entry.routingInput;
4571
+ if (lifetime && lifetime.operatorUserId !== input.operatorUserId) throw createJournalError("conflict", path, "routing input belongs to another operator");
4572
+ if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
4573
+ throw createJournalError("conflict", path, "routing input has protected retention");
4574
+ }
4575
+ if (select) {
4576
+ if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs)) throw createJournalError("conflict", path, "routing input choice expired or was not armed");
4577
+ if (lifetime.phase === "selected") return cloneEntry(entry);
4578
+ issued = true;
4579
+ const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" as const } };
4580
+ changed.set(entry.updateId, selected); return cloneEntry(selected);
4581
+ }
4582
+ if (lifetime) {
4583
+ if (lifetime.phase !== "waiting") throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
4584
+ return cloneEntry(entry);
4585
+ }
4586
+ if (nowMs < entry.admittedAtMs) throw createJournalError("conflict", path, "routing input publication predates admission");
4587
+ issued = true;
4588
+ const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
4589
+ publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" as const } };
4590
+ changed.set(entry.updateId, armed); return cloneEntry(armed);
4591
+ });
4592
+ assertCurrent();
4593
+ if (select && issued && result.some(entry => !changed.has(entry.updateId))) throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
4594
+ if (changed.size) publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true,
4595
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
4596
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
4597
+ assertCurrent();
4598
+ return { issued, entries: result };
4599
+ });
3771
4600
  const journal: TelegramUpdateJournalStore = {
3772
4601
  read() {
3773
4602
  return runMutation((readCurrent) => {
@@ -3815,11 +4644,12 @@ function createJournalStoreCore(
3815
4644
  );
3816
4645
  const previousAcceptedThroughUpdateId =
3817
4646
  current.file.acceptedThroughUpdateId;
3818
- const discardedUpdateIds = new Set(
3819
- (current.file.operatorDispositions ?? [])
4647
+ const discardedUpdateIds = new Set([
4648
+ ...(current.file.operatorDispositions ?? [])
3820
4649
  .filter((disposition) => disposition.action === "discard")
3821
4650
  .map((disposition) => disposition.updateId),
3822
- );
4651
+ ...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
4652
+ ]);
3823
4653
  let admittedAtMs: number | undefined;
3824
4654
  const addedUpdateIds: number[] = [];
3825
4655
  const duplicateUpdateIds: number[] = [];
@@ -4193,6 +5023,7 @@ function createJournalStoreCore(
4193
5023
  updateId: entry.updateId,
4194
5024
  update: entry.update,
4195
5025
  admittedAtMs: entry.admittedAtMs,
5026
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
4196
5027
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
4197
5028
  state: input.disposition,
4198
5029
  failure,
@@ -4328,9 +5159,9 @@ function createJournalStoreCore(
4328
5159
  applyLegacyCustodyDisposition(authority) {
4329
5160
  return runMutation((readCurrent) => {
4330
5161
  const current = readCurrent();
4331
- if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
5162
+ if (!journalHasCustodyFields(current.file.version))
4332
5163
  throw createJournalError("conflict", path,
4333
- "legacy custody disposition requires a v3 journal");
5164
+ "legacy custody disposition requires a custody journal");
4334
5165
  const existing = current.file.operatorDispositions?.find(
4335
5166
  candidate => candidate.failureId === authority.dispositionId);
4336
5167
  if (existing) {
@@ -4647,116 +5478,8 @@ function createJournalStoreCore(
4647
5478
  };
4648
5479
  });
4649
5480
  },
4650
- completeQueued(receipts) {
4651
- return runMutation((readCurrent) => {
4652
- if (!Array.isArray(receipts) || receipts.length === 0) {
4653
- throw createJournalError(
4654
- "invalid",
4655
- path,
4656
- "received no queued receipts to complete",
4657
- );
4658
- }
4659
- const current = readCurrent();
4660
- const receiptIds = new Set<string>();
4661
- const requestedUpdateIds = new Set<number>();
4662
- for (const receipt of receipts) {
4663
- if (
4664
- (receipt.queueKind !== "prompt" &&
4665
- receipt.queueKind !== "control") ||
4666
- !isNonEmptyString(receipt.receiptId) ||
4667
- receiptIds.has(receipt.receiptId) ||
4668
- !Array.isArray(receipt.sourceUpdateIds) ||
4669
- receipt.sourceUpdateIds.length === 0
4670
- ) {
4671
- throw createJournalError(
4672
- "invalid",
4673
- path,
4674
- "received an invalid queued completion receipt",
4675
- );
4676
- }
4677
- receiptIds.add(receipt.receiptId);
4678
- const queueOwner = validateJournalQueueOwner(
4679
- receipt.queueOwner,
4680
- path,
4681
- );
4682
- if (
4683
- queueRuntimeIdentity &&
4684
- (queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
4685
- queueOwner.processId !== queueRuntimeIdentity.processId ||
4686
- queueOwner.processBirthId !==
4687
- queueRuntimeIdentity.processBirthId)
4688
- ) {
4689
- throw createJournalError(
4690
- "conflict",
4691
- path,
4692
- `cannot complete foreign queue receipt ${receipt.receiptId}`,
4693
- );
4694
- }
4695
- const sourceUpdateIds = new Set<number>();
4696
- for (const updateId of receipt.sourceUpdateIds) {
4697
- if (
4698
- !isSafeNonNegativeInteger(updateId) ||
4699
- sourceUpdateIds.has(updateId) ||
4700
- requestedUpdateIds.has(updateId)
4701
- ) {
4702
- throw createJournalError(
4703
- "invalid",
4704
- path,
4705
- "received overlapping queued completion update ids",
4706
- );
4707
- }
4708
- sourceUpdateIds.add(updateId);
4709
- requestedUpdateIds.add(updateId);
4710
- }
4711
- const persistedReceiptEntries = current.file.entries.filter(
4712
- (entry) => entry.queueReceiptId === receipt.receiptId,
4713
- );
4714
- if (
4715
- persistedReceiptEntries.length !== sourceUpdateIds.size ||
4716
- persistedReceiptEntries.some(
4717
- (entry) =>
4718
- !sourceUpdateIds.has(entry.updateId) ||
4719
- entry.state !== "queued" ||
4720
- entry.queueKind !== receipt.queueKind ||
4721
- !entry.queueOwner ||
4722
- entry.queueHandoff !== undefined ||
4723
- !areTelegramUpdateJournalQueueOwnersEqual(
4724
- entry.queueOwner,
4725
- queueOwner,
4726
- ),
4727
- )
4728
- ) {
4729
- throw createJournalError(
4730
- "conflict",
4731
- path,
4732
- `cannot complete stale or foreign queue receipt ${receipt.receiptId}`,
4733
- );
4734
- }
4735
- }
4736
- const removedUpdateIds = current.file.entries
4737
- .filter((entry) => requestedUpdateIds.has(entry.updateId))
4738
- .map((entry) => entry.updateId);
4739
- if (removedUpdateIds.length !== requestedUpdateIds.size) {
4740
- throw createJournalError(
4741
- "conflict",
4742
- path,
4743
- "queued completion did not resolve every source update",
4744
- );
4745
- }
4746
- const published = publishMutation(
4747
- current,
4748
- current.file.entries.filter(
4749
- (entry) => !requestedUpdateIds.has(entry.updateId),
4750
- ),
4751
- true,
4752
- );
4753
- return {
4754
- removedUpdateIds,
4755
- entryCount: published.file.entries.length,
4756
- serializedBytes: published.serializedBytes,
4757
- };
4758
- });
4759
- },
5481
+ completeQueued(receipts) { return completeQueued(receipts); },
5482
+ completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
4760
5483
  discardQueued(input) {
4761
5484
  return runMutation((readCurrent) => {
4762
5485
  if (
@@ -4957,53 +5680,186 @@ function createJournalStoreCore(
4957
5680
  };
4958
5681
  });
4959
5682
  },
4960
- removeCompleted(updateIds) {
5683
+ inspectAbandonedPending(updateId) {
4961
5684
  return runMutation((readCurrent) => {
4962
- const current = readCurrent();
4963
- const requestedIds = new Set<number>();
4964
- for (const updateId of updateIds) {
4965
- if (!isSafeNonNegativeInteger(updateId)) {
4966
- throw createJournalError(
4967
- "invalid",
4968
- path,
4969
- "received an invalid removal update id",
4970
- );
4971
- }
4972
- requestedIds.add(updateId);
5685
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5686
+ throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
4973
5687
  }
4974
- const requestedEntries = current.file.entries.filter((entry) =>
4975
- requestedIds.has(entry.updateId),
4976
- );
4977
- const protectedEntry = requestedEntries.find(
4978
- (entry) => entry.state === "failed" || entry.state === "queued",
4979
- );
4980
- if (protectedEntry) {
4981
- throw createJournalError(
4982
- "conflict",
4983
- path,
4984
- protectedEntry.state === "failed"
4985
- ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
4986
- : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`,
4987
- );
5688
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid abandonment update id");
5689
+ // Inspection never repairs or quarantines source evidence.
5690
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
5691
+ const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
5692
+ const disposition = matches[0];
5693
+ if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
5694
+ disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
5695
+ disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
5696
+ current.file.entries.some(entry => entry.updateId === updateId)) return undefined;
5697
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5698
+ const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
5699
+ let stat;
5700
+ try { stat = lstatSync(retainedPath); }
5701
+ catch (error) {
5702
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
5703
+ throw error;
4988
5704
  }
4989
- const removedUpdateIds = requestedEntries.map(
4990
- (entry) => entry.updateId,
4991
- );
4992
- const published = publishMutation(
4993
- current,
4994
- current.file.entries.filter(
4995
- (entry) => !requestedIds.has(entry.updateId),
4996
- ),
4997
- removedUpdateIds.length > 0,
4998
- );
4999
- return {
5000
- removedUpdateIds,
5001
- entryCount: published.file.entries.length,
5002
- serializedBytes: published.serializedBytes,
5705
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
5706
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
5707
+ }
5708
+ const parsed: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
5709
+ if (!isRecord(parsed)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
5710
+ const entry = validateJournalEntry(parsed.entry, retainedPath, version);
5711
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
5712
+ const retained = reference.read();
5713
+ if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
5714
+ !isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
5715
+ throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
5716
+ }
5717
+ return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
5718
+ });
5719
+ },
5720
+ inspectPendingRetention(entry) {
5721
+ return runMutation(() => {
5722
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5723
+ throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
5724
+ }
5725
+ const expected = validateJournalEntry(entry, path, version);
5726
+ if (expected.state !== "pending") throw createJournalError("conflict", path, "retention inspection requires a pending source");
5727
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5728
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
5729
+ const retained = reference.read();
5730
+ return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
5731
+ requestedDisposition: retained.requestedDisposition } : undefined;
5732
+ });
5733
+ },
5734
+ abandonPending(input) {
5735
+ return runMutation((readCurrent) => {
5736
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5737
+ throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
5738
+ }
5739
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5740
+ if (input.journalBindingKey !== journalBindingKey) {
5741
+ throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
5742
+ }
5743
+ if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
5744
+ !input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
5745
+ throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
5746
+ }
5747
+ const assertCurrent = () => {
5748
+ if (!input.isCurrent()) throw createJournalError("conflict", path, "pending abandonment lost authority");
5749
+ };
5750
+ assertCurrent();
5751
+ const expected = validateJournalEntry(input.entry, path, version);
5752
+ if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
5753
+ expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
5754
+ throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
5755
+ }
5756
+ // Never repair/quarantine source evidence while deciding an operator cancellation.
5757
+ const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
5758
+ const current = readStrict();
5759
+ const { evidenceSha256, failureId, retainedPath, read: readRetention } =
5760
+ createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
5761
+ const existing = current.file.operatorDispositions?.find(item =>
5762
+ item.failureId === failureId || item.updateId === expected.updateId);
5763
+ const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
5764
+ const assertRetention = (retained: TelegramUpdateJournalRetainedInput | undefined,
5765
+ disposition: TelegramUpdateJournalLegacyCustodyDisposition): void => {
5766
+ if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
5767
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
5768
+ }
5003
5769
  };
5770
+ if (existing) {
5771
+ if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
5772
+ existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
5773
+ throw createJournalError("conflict", path, "pending source already has another disposition");
5774
+ }
5775
+ assertRetention(readRetention(), existing);
5776
+ assertCurrent();
5777
+ return { disposition: existing, retainedPath, duplicate: true,
5778
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5779
+ }
5780
+ if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
5781
+ throw createJournalError("conflict", path, "pending source changed before abandonment");
5782
+ }
5783
+ const nowMs = getNowMs();
5784
+ if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path, "invalid abandonment timestamp");
5785
+ const retained = readRetention();
5786
+ let disposition: TelegramUpdateJournalLegacyCustodyDisposition;
5787
+ if (retained) {
5788
+ disposition = retained.requestedDisposition;
5789
+ } else {
5790
+ // Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
5791
+ // This is an operator disposition, not a fabricated execution failure or completion.
5792
+ disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
5793
+ action: "discard", committedAtMs: nowMs, evidenceSha256,
5794
+ operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
5795
+ const copy: TelegramUpdateJournalRetainedInput = { version: 1, kind: "pending-input-retention",
5796
+ journalBindingKey, entry: expected, requestedDisposition: disposition };
5797
+ writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
5798
+ onPublicationBoundary?.(boundary, target);
5799
+ assertCurrent();
5800
+ });
5801
+ }
5802
+ assertCurrent();
5803
+ assertRetention(readRetention(), disposition);
5804
+ const committed = (snapshot: ReadTelegramUpdateJournalResult) =>
5805
+ !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
5806
+ snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
5807
+ try {
5808
+ const published = publishMutation(current,
5809
+ current.file.entries.filter(entry => entry.updateId !== expected.updateId), true,
5810
+ [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
5811
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
5812
+ return { disposition, retainedPath, duplicate: false,
5813
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5814
+ } catch (error) {
5815
+ // A segment may be durable even if later snapshot compaction failed.
5816
+ // Only exact read-back can turn that unknown acknowledgement into success.
5817
+ let observed: ReadTelegramUpdateJournalResult;
5818
+ try { observed = readStrict(); } catch { throw error; }
5819
+ if (!committed(observed)) throw error;
5820
+ assertRetention(readRetention(), disposition);
5821
+ return { disposition, retainedPath, duplicate: false,
5822
+ entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
5823
+ }
5824
+ });
5825
+ },
5826
+ removeCompleted(updateIds) { return removeCompleted(updateIds); },
5827
+ removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
5828
+ inspectQueuedReceipt(expected) {
5829
+ return runMutationCore(readCurrent => {
5830
+ if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5831
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
5832
+ }
5833
+ const queueOwner = validateQueuedReceiptObservation(expected, path);
5834
+ const current = readCurrent();
5835
+ return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
5836
+ });
5837
+ },
5838
+ inspectSourceCompletion(expected) {
5839
+ return runMutation(readCurrent => {
5840
+ if (!sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
5841
+ const completion = validateJournalSourceCompletion(expected, path);
5842
+ const current = readCurrent();
5843
+ return inspectJournalSourceCompletion(current.file, completion, path);
5004
5844
  });
5005
5845
  },
5006
5846
  };
5847
+ const abandonRoutingPending = journal.abandonPending.bind(journal);
5848
+ if (isTelegramUpdateJournalLegacyFamilyVersion(version)) journal.routingInputs = {
5849
+ arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
5850
+ select(input) { return mutateRoutingInputs(input, true); },
5851
+ expire(input) {
5852
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
5853
+ if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
5854
+ !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
5855
+ throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
5856
+ }
5857
+ const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
5858
+ if (!current()) throw createJournalError("conflict", path, "routing input has not expired under current authority");
5859
+ return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
5860
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
5861
+ },
5862
+ };
5007
5863
  if (!getInputContext) return { journal };
5008
5864
  // Strict source acquisition already requires this exact stored receipt scope.
5009
5865
  const bindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
@@ -5178,7 +6034,7 @@ function createJournalStoreCore(
5178
6034
  const evidence = readTelegramUpdateJournalSource({
5179
6035
  directory: sourceAccess.directory, path, profile,
5180
6036
  botIdentity: expectedIdentity, limits: sourceAccess.limits,
5181
- version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
6037
+ version,
5182
6038
  });
5183
6039
  return evidence.kind === "present"
5184
6040
  ? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];