@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
@@ -6,28 +6,29 @@
6
6
  * It does not own polling, update execution, queue admission, or follower routing.
7
7
  */
8
8
  import { createHash, randomBytes, randomUUID } from "node:crypto";
9
- import { chmodSync, constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, mkdirSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
9
+ import { chmodSync, constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
10
10
  import { basename, dirname, join, resolve, relative, isAbsolute, sep } from "node:path";
11
11
  import { isDeepStrictEqual } from "node:util";
12
12
  import { renameTelegramPathWithRetry, withTelegramFileTransaction, } from "./locks.js";
13
13
  import { getTelegramProcessLiveness, } from "./bus.js";
14
- import { getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
14
+ import { decodeTelegramSessionDirectoryName, getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
15
15
  import { runWithTelegramWorkspaceAdmissions, } from "./workspace-admission.js";
16
+ import { isWireRecord as isRecord, hasOnlyWireKeys as hasOnlyKeys, isNonNegativeWireInteger as isSafeNonNegativeInteger, isNonEmptyWireString as isNonEmptyString, } from "./wire.js";
16
17
  export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1;
17
18
  export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2;
18
19
  export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3;
19
20
  // Receipt and binding identities survive storage-schema upgrades.
20
21
  const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1;
21
- export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
22
- export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
23
- export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
22
+ const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
23
+ const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
24
+ const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
24
25
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
25
- export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
26
+ const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
26
27
  const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
27
28
  const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
28
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
29
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
30
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
29
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
30
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
31
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
31
32
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
32
33
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
33
34
  function escapeTelegramJournalPathPattern(value) {
@@ -68,7 +69,93 @@ export function discoverTelegramFollowerJournalPaths(input) {
68
69
  }
69
70
  return { paths: Array.from(paths).sort(), complete };
70
71
  }
71
- export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
72
+ /** Bounded read-only session-family discovery, not writer closure or deletion authority. */
73
+ export function discoverTelegramSessionJournalPaths(input) {
74
+ const maxEntries = input.maxDirectoryEntries ?? 10_000;
75
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
76
+ return { paths: [], complete: false };
77
+ const root = join(input.directory, "sessions");
78
+ const suffix = getTelegramProfilePathSuffix(input.profileName);
79
+ const paths = new Set();
80
+ let remaining = maxEntries;
81
+ let complete = true;
82
+ const scan = (directory) => {
83
+ const entries = [];
84
+ const handle = opendirSync(directory);
85
+ try {
86
+ for (;;) {
87
+ const entry = handle.readSync();
88
+ if (!entry)
89
+ break;
90
+ if (--remaining < 0) {
91
+ complete = false;
92
+ break;
93
+ }
94
+ entries.push(entry);
95
+ }
96
+ }
97
+ finally {
98
+ handle.closeSync();
99
+ }
100
+ return entries;
101
+ };
102
+ try {
103
+ const stat = lstatSync(root);
104
+ if (!stat.isDirectory() || stat.isSymbolicLink())
105
+ return { paths: [], complete: false };
106
+ }
107
+ catch (error) {
108
+ return { paths: [], complete: error.code === "ENOENT" };
109
+ }
110
+ try {
111
+ for (const session of scan(root)) {
112
+ if (!complete)
113
+ break;
114
+ if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
115
+ complete = false;
116
+ break;
117
+ }
118
+ const directory = join(root, session.name);
119
+ const sessionStat = lstatSync(directory);
120
+ if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
121
+ complete = false;
122
+ break;
123
+ }
124
+ for (const entry of scan(directory)) {
125
+ if (!complete)
126
+ break;
127
+ // The leader polling journal and legacy recovery folders are not recipient custody.
128
+ if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name))
129
+ continue;
130
+ const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
131
+ if (!match) {
132
+ complete = false;
133
+ break;
134
+ }
135
+ const stat = lstatSync(join(directory, entry.name));
136
+ if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
137
+ complete = false;
138
+ break;
139
+ }
140
+ if ((match[2] ?? "") !== suffix)
141
+ continue;
142
+ paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
143
+ }
144
+ }
145
+ }
146
+ catch {
147
+ complete = false;
148
+ }
149
+ return { paths: Array.from(paths).sort(), complete };
150
+ }
151
+ /** Transitional discovery covers both retained flat recipients and session-owned families. */
152
+ export function discoverTelegramRecipientJournalPaths(input) {
153
+ const legacy = discoverTelegramFollowerJournalPaths(input);
154
+ const sessions = discoverTelegramSessionJournalPaths(input);
155
+ return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
156
+ complete: legacy.complete && sessions.complete };
157
+ }
158
+ const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
72
159
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
73
160
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
74
161
  export class TelegramUpdateJournalError extends Error {
@@ -157,7 +244,7 @@ export function getTelegramUpdateJournalAdmissionScopes(updates) {
157
244
  .sort(([left], [right]) => left.localeCompare(right))
158
245
  .map(([, scope]) => scope);
159
246
  }
160
- export function listTelegramUpdateJournalLegacyCustodyCandidates(snapshot) {
247
+ function listTelegramUpdateJournalLegacyCustodyCandidates(snapshot) {
161
248
  return snapshot.entries.flatMap(entry => {
162
249
  const evidence = createTelegramUpdateJournalLegacyCustodyEvidence(entry);
163
250
  return evidence ? [{ updateId: evidence.updateId, state: evidence.state,
@@ -194,25 +281,16 @@ export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
194
281
  evidenceSha256: expected.evidenceSha256, action: value.action,
195
282
  operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
196
283
  }
284
+ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
285
+ export function createTelegramUpdateJournalEntryDigest(entry) {
286
+ return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
287
+ }
197
288
  function createJournalError(code, path, detail, cause) {
198
289
  return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
199
290
  }
200
- function isRecord(value) {
201
- return !!value && typeof value === "object" && !Array.isArray(value);
202
- }
203
- function hasOnlyKeys(value, allowedKeys) {
204
- const allowed = new Set(allowedKeys);
205
- return Object.keys(value).every((key) => allowed.has(key));
206
- }
207
- function isSafeNonNegativeInteger(value) {
208
- return Number.isSafeInteger(value) && value >= 0;
209
- }
210
291
  function isSafePositiveInteger(value) {
211
292
  return Number.isSafeInteger(value) && value > 0;
212
293
  }
213
- function isNonEmptyString(value) {
214
- return typeof value === "string" && value.length > 0;
215
- }
216
294
  function isBoundedString(value, maxLength) {
217
295
  return isNonEmptyString(value) && value.length <= maxLength;
218
296
  }
@@ -433,8 +511,14 @@ function validateJournalOperatorDisposition(value, path) {
433
511
  terminalReason: value.terminalReason };
434
512
  }
435
513
  function journalRequiresExclusion(version) {
436
- return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
437
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
514
+ return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
515
+ }
516
+ function journalHasCustodyFields(version) {
517
+ return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
518
+ }
519
+ /** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
520
+ export function isTelegramUpdateJournalLegacyFamilyVersion(version) {
521
+ return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
438
522
  }
439
523
  function validateJournalInputClaim(value, path, updateId) {
440
524
  if (!isRecord(value) ||
@@ -494,6 +578,7 @@ function validateJournalEntry(value, path, version) {
494
578
  "updateId",
495
579
  "update",
496
580
  "admittedAtMs",
581
+ ...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
497
582
  "state",
498
583
  "queueKind",
499
584
  "queueReceiptId",
@@ -505,7 +590,7 @@ function validateJournalEntry(value, path, version) {
505
590
  "terminalReason",
506
591
  "terminalFailureId",
507
592
  ...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
508
- ...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
593
+ ...(journalHasCustodyFields(version)
509
594
  ? ["inputClaim", "inputProvenance"] : []),
510
595
  ]) ||
511
596
  !isSafeNonNegativeInteger(value.updateId) ||
@@ -520,6 +605,20 @@ function validateJournalEntry(value, path, version) {
520
605
  if (update.update_id !== value.updateId) {
521
606
  throw createJournalError("invalid", path, "contains an entry/update id mismatch");
522
607
  }
608
+ let routingInput;
609
+ if (value.routingInput !== undefined) {
610
+ const routing = value.routingInput;
611
+ if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
612
+ !isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
613
+ routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
614
+ routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
615
+ (routing.phase !== "waiting" && routing.phase !== "selected") ||
616
+ (routing.phase === "waiting" && value.state === "queued")) {
617
+ throw createJournalError("invalid", path, "contains invalid routing input lifetime");
618
+ }
619
+ routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
620
+ expiresAtMs: routing.expiresAtMs, phase: routing.phase };
621
+ }
523
622
  const queueKind = value.queueKind;
524
623
  const queueReceiptId = value.queueReceiptId;
525
624
  const hasQueueMetadata = queueKind !== undefined ||
@@ -595,6 +694,7 @@ function validateJournalEntry(value, path, version) {
595
694
  ...(inputClaim ? { inputClaim } : {}),
596
695
  ...(inputProvenance ? { inputProvenance } : {}),
597
696
  admittedAtMs: value.admittedAtMs,
697
+ ...(routingInput ? { routingInput } : {}),
598
698
  state: value.state,
599
699
  ...(queueKind === "prompt" || queueKind === "control"
600
700
  ? { queueKind }
@@ -634,6 +734,29 @@ function assertSupportedJournalVersion(value, path, version = TELEGRAM_UPDATE_JO
634
734
  throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
635
735
  }
636
736
  }
737
+ function validateJournalSourceCompletion(value, path) {
738
+ if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
739
+ !isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
740
+ typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
741
+ throw createJournalError("invalid", path, "has invalid source completion evidence");
742
+ }
743
+ return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
744
+ }
745
+ function validateJournalSourceCompletions(value, path, version, allowEmpty = false) {
746
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
747
+ throw createJournalError("invalid", path, "has invalid source completion collection");
748
+ }
749
+ const completions = value.map(item => validateJournalSourceCompletion(item, path));
750
+ const scopes = new Set();
751
+ for (let index = 0; index < completions.length; index++) {
752
+ const completion = completions[index];
753
+ if ((index > 0 && completions[index - 1].updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
754
+ throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
755
+ }
756
+ scopes.add(completion.completionSha256);
757
+ }
758
+ return completions;
759
+ }
637
760
  function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
638
761
  assertSupportedJournalVersion(value, path, version);
639
762
  if (journalRequiresExclusion(version) &&
@@ -649,6 +772,7 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
649
772
  "botIdentity",
650
773
  "entries",
651
774
  "operatorDispositions",
775
+ "sourceCompletions",
652
776
  ]) ||
653
777
  !Number.isSafeInteger(value.version) ||
654
778
  (value.revision !== undefined &&
@@ -707,6 +831,14 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
707
831
  }
708
832
  }
709
833
  const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
834
+ const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
835
+ const botIdentity = validateBotIdentity(value.botIdentity, path);
836
+ const activeIds = new Set(entries.map(entry => entry.updateId));
837
+ const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
838
+ if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
839
+ value.acceptedThroughUpdateId !== undefined && completion.updateId > value.acceptedThroughUpdateId)) {
840
+ throw createJournalError("invalid", path, "has contradictory source completion evidence");
841
+ }
710
842
  const dispositionFailureIds = new Set();
711
843
  const entriesByUpdateId = new Map(entries.map((entry) => [entry.updateId, entry]));
712
844
  for (const disposition of operatorDispositions) {
@@ -729,11 +861,65 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
729
861
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
730
862
  : {}),
731
863
  profile: value.profile,
732
- botIdentity: validateBotIdentity(value.botIdentity, path),
864
+ botIdentity,
733
865
  entries,
734
866
  ...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
867
+ ...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
735
868
  };
736
869
  }
870
+ function assertJournalRoutingInputContinuity(file, entries, path) {
871
+ const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
872
+ for (const entry of entries) {
873
+ const retained = previous.get(entry.updateId), next = entry.routingInput;
874
+ if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
875
+ next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
876
+ throw createJournalError("invalid", path, "regresses retained routing input lifetime");
877
+ }
878
+ }
879
+ }
880
+ function assertJournalSourceCompletionContinuity(file, segment, path) {
881
+ assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
882
+ if (segment.sourceCompletions === undefined)
883
+ return;
884
+ const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
885
+ const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
886
+ if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
887
+ throw createJournalError("invalid", path, "regresses retained source completion evidence");
888
+ }
889
+ const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
890
+ const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
891
+ const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
892
+ // Grouped markers share one whole-receipt check rather than rescanning the group per source.
893
+ const queueGroups = new Map(), queueRemovals = new Map();
894
+ for (const entry of file.entries) {
895
+ if (!entry.queueReceiptId)
896
+ continue;
897
+ const group = queueGroups.get(entry.queueReceiptId) ?? [];
898
+ group.push(entry);
899
+ queueGroups.set(entry.queueReceiptId, group);
900
+ }
901
+ const queueWasRemoved = (source) => {
902
+ const receiptId = source.queueReceiptId;
903
+ if (!receiptId || !source.queueOwner)
904
+ return false;
905
+ if (queueRemovals.has(receiptId))
906
+ return queueRemovals.get(receiptId);
907
+ const group = queueGroups.get(receiptId);
908
+ const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry => removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
909
+ entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner));
910
+ queueRemovals.set(receiptId, valid);
911
+ return valid;
912
+ };
913
+ for (const completion of segment.sourceCompletions) {
914
+ if (previous.has(completion.updateId))
915
+ continue;
916
+ const source = entries.get(completion.updateId);
917
+ if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
918
+ createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
919
+ throw createJournalError("invalid", path, "source completion does not match its removal revision");
920
+ }
921
+ }
922
+ }
737
923
  function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
738
924
  assertSupportedJournalVersion(value, path, version);
739
925
  if (journalRequiresExclusion(version) &&
@@ -751,6 +937,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
751
937
  "upsertedEntries",
752
938
  "removedUpdateIds",
753
939
  "operatorDispositions",
940
+ "sourceCompletions",
754
941
  ]) ||
755
942
  value.version !== version ||
756
943
  !isSafePositiveInteger(value.revision) ||
@@ -790,6 +977,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
790
977
  removedUpdateIds.push(updateId);
791
978
  }
792
979
  const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
980
+ const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
793
981
  return {
794
982
  version,
795
983
  revision: value.revision,
@@ -798,12 +986,13 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
798
986
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
799
987
  : {}),
800
988
  profile: value.profile,
801
- botIdentity: validateBotIdentity(value.botIdentity, path),
989
+ botIdentity: segmentBotIdentity,
802
990
  upsertedEntries,
803
991
  removedUpdateIds,
804
992
  ...(value.operatorDispositions !== undefined
805
993
  ? { operatorDispositions }
806
994
  : {}),
995
+ ...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
807
996
  };
808
997
  }
809
998
  /**
@@ -833,6 +1022,11 @@ export function inspectTelegramInputCustodySourceStatus(input) {
833
1022
  export function inspectTelegramUpdateJournalFamily(input) {
834
1023
  return acquireTelegramUpdateJournalFamily(input).evidence;
835
1024
  }
1025
+ /** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
1026
+ export function inspectTelegramUpdateJournalRetention(input) {
1027
+ const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
1028
+ return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
1029
+ }
836
1030
  /**
837
1031
  * Read-only source evidence for cooperating writers serialized by the caller through
838
1032
  * consumption; never readiness, recovery, or permission to publish. Ancestors retain
@@ -845,7 +1039,7 @@ export function inspectTelegramUpdateJournalFamily(input) {
845
1039
  */
846
1040
  export function readTelegramUpdateJournalSource(input) {
847
1041
  const version = input.version;
848
- if (version !== 1 && version !== 2 && version !== 3) {
1042
+ if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
849
1043
  throw createJournalError("unsupported-version", input.path, "requires a selected source version");
850
1044
  }
851
1045
  return acquireTelegramUpdateJournalFamily({
@@ -854,7 +1048,7 @@ export function readTelegramUpdateJournalSource(input) {
854
1048
  limits: { ...input.limits },
855
1049
  }, version).evidence;
856
1050
  }
857
- function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1051
+ function acquireTelegramUpdateJournalFamily(input, selectedVersion, inspectRetention = false) {
858
1052
  const { path, limits } = input;
859
1053
  const acquiredSegments = [];
860
1054
  let snapshotRevision = 0;
@@ -906,7 +1100,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
906
1100
  const charge = (value) => {
907
1101
  if (!isRecord(value))
908
1102
  return;
909
- for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
1103
+ for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
910
1104
  const collection = value[key];
911
1105
  if (!Array.isArray(collection))
912
1106
  continue;
@@ -948,18 +1142,18 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
948
1142
  }
949
1143
  };
950
1144
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
951
- const enumerate = () => {
1145
+ const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files) => {
952
1146
  const names = [];
953
- const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
1147
+ const directory = opendirSync(target, { bufferSize: 1 });
954
1148
  try {
955
1149
  for (;;) {
956
1150
  const entry = directory.readSync();
957
1151
  if (!entry)
958
1152
  break;
959
- if (names.length >= limits.maxFiles - files)
1153
+ if (names.length >= limits.maxFiles - usedFiles)
960
1154
  capacity();
961
- if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
962
- fail("has an unexpected segment entry");
1155
+ if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
1156
+ fail("has an unexpected journal evidence entry");
963
1157
  names.push(entry.name);
964
1158
  }
965
1159
  }
@@ -1027,6 +1221,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1027
1221
  if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
1028
1222
  segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId)
1029
1223
  fail("regresses the admission cursor");
1224
+ assertJournalSourceCompletionContinuity(file, segment, target);
1030
1225
  const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1031
1226
  for (const id of segment.removedUpdateIds)
1032
1227
  entries.delete(id);
@@ -1046,16 +1241,70 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1046
1241
  ...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
1047
1242
  entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
1048
1243
  operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
1244
+ sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
1049
1245
  };
1050
1246
  charge(next);
1051
1247
  file = parseJournalFile(next, target, file.version);
1052
1248
  }
1053
1249
  }
1250
+ const retainedInputs = [];
1251
+ const retentionDirectory = `${path}.retained`;
1252
+ const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
1253
+ const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
1254
+ const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
1255
+ if (retentionNames.length > limits.maxEntries)
1256
+ capacity();
1257
+ if (retention) {
1258
+ if (!file || file.version !== 1)
1259
+ fail("private retention requires its exact v1 journal snapshot");
1260
+ const current = file;
1261
+ const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity => createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
1262
+ const updateIds = new Set();
1263
+ for (const name of retentionNames) {
1264
+ const target = join(retentionDirectory, name);
1265
+ const before = observe(target, false);
1266
+ if (before.nlink !== 1n)
1267
+ fail("has linked private retention evidence");
1268
+ const raw = read(target, before);
1269
+ files += 1;
1270
+ if (limits.maxWork - work < 2)
1271
+ capacity();
1272
+ work += 2;
1273
+ if (!isRecord(raw))
1274
+ throw createJournalError("invalid", target, "private retention is not an evidence record");
1275
+ if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
1276
+ throw createJournalError("invalid", target, "has foreign private retention evidence");
1277
+ const entry = validateJournalEntry(raw.entry, target, 1);
1278
+ const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
1279
+ const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
1280
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
1281
+ disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
1282
+ disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
1283
+ name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
1284
+ journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition }))
1285
+ fail("has inconsistent private retention evidence");
1286
+ if (updateIds.has(entry.updateId))
1287
+ fail("has duplicate private retention sources");
1288
+ updateIds.add(entry.updateId);
1289
+ const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
1290
+ if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
1291
+ fail("private retention contradicts its journal disposition");
1292
+ retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
1293
+ failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
1294
+ }
1295
+ }
1296
+ if (inspectRetention && file?.operatorDispositions?.some(disposition => "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
1297
+ disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
1298
+ !retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
1299
+ fail("committed abandonment lost its private original");
1054
1300
  // Re-enumeration has the same bound, rather than allocating an unbounded census.
1055
1301
  files = snapshot ? 1 : 0;
1056
1302
  if (segments && !isDeepStrictEqual(names, enumerate()))
1057
1303
  fail("changed segment namespace");
1058
1304
  files += names.length;
1305
+ if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern)))
1306
+ fail("changed private retention namespace");
1307
+ files += retentionNames.length;
1059
1308
  for (const [target, before] of observed) {
1060
1309
  const after = status(target);
1061
1310
  const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
@@ -1079,6 +1328,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1079
1328
  evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
1080
1329
  snapshotRevision,
1081
1330
  segments: acquiredSegments,
1331
+ ...(inspectRetention ? { retainedInputs } : {}),
1082
1332
  };
1083
1333
  }
1084
1334
  catch (error) {
@@ -1087,13 +1337,14 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1087
1337
  throw createJournalError("io", path, "could not establish strict journal evidence", error);
1088
1338
  }
1089
1339
  }
1090
- /**
1091
- * Canonical namespace evidence only, never source readiness or authorization.
1092
- * Caller-proven serialization/quiescence is mandatory through consumption.
1093
- * Recensus detects observable changes, not hostile same-user path swaps; arbitrary
1094
- * consumer references and archive consumption require separate audits.
1095
- */
1096
1340
  export function inspectTelegramProfileJournalNamespace(input) {
1341
+ return inspectTelegramJournalNamespace(input, false);
1342
+ }
1343
+ /** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
1344
+ export function inspectTelegramSessionJournalNamespace(input) {
1345
+ return inspectTelegramJournalNamespace(input, true);
1346
+ }
1347
+ function inspectTelegramJournalNamespace(input, includeSessions) {
1097
1348
  const { directory, profile, limits } = input;
1098
1349
  const fail = (message) => { throw createJournalError("invalid", directory, message); };
1099
1350
  const capacity = () => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
@@ -1114,7 +1365,15 @@ export function inspectTelegramProfileJournalNamespace(input) {
1114
1365
  size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
1115
1366
  };
1116
1367
  const suffix = profile === "default" ? "" : `.${profile}`;
1117
- const polling = `inbox${suffix}.json`;
1368
+ const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
1369
+ const polling = relative(directory, pollingPath);
1370
+ {
1371
+ const parts = polling.split(sep);
1372
+ if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
1373
+ !(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
1374
+ decodeTelegramSessionDirectoryName(parts[1]) !== undefined)))
1375
+ fail("requires a canonical polling journal path");
1376
+ }
1118
1377
  try {
1119
1378
  const root = lstatSync(directory);
1120
1379
  if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory)
@@ -1122,51 +1381,106 @@ export function inspectTelegramProfileJournalNamespace(input) {
1122
1381
  const beforeRoot = metadata(directory);
1123
1382
  const census = () => {
1124
1383
  const entries = new Map();
1125
- const followers = new Set();
1126
- const handle = opendirSync(directory, { bufferSize: 1 });
1127
- try {
1128
- for (;;) {
1129
- const entry = handle.readSync();
1130
- if (!entry)
1131
- break;
1132
- if (entries.size >= limits.maxDirectoryEntries)
1133
- capacity();
1134
- if (entry.name.toLowerCase() === "recovery")
1135
- fail("retains unclassified recovery storage");
1136
- const target = join(directory, entry.name);
1137
- entries.set(entry.name, metadata(target));
1138
- if (!/inbox/iu.test(entry.name))
1139
- continue;
1140
- const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.segments)?$/u.exec(entry.name);
1141
- if (!match || match[2] === "default")
1142
- fail("has a noncanonical journal-like entry");
1143
- const value = lstatSync(target);
1144
- if (value.isSymbolicLink() || !(match[3] ? value.isDirectory() : value.isFile()))
1145
- fail("has a linked or unexpected journal type");
1146
- if ((match[2] ?? "default") !== profile)
1147
- continue;
1148
- if (match[1] !== "inbox")
1149
- followers.add(entry.name.replace(/\.segments$/u, ""));
1384
+ const families = new Map(), profiles = new Set();
1385
+ const scan = (parent, visit) => {
1386
+ const handle = opendirSync(parent, { bufferSize: 1 });
1387
+ try {
1388
+ for (;;) {
1389
+ const entry = handle.readSync();
1390
+ if (!entry)
1391
+ break;
1392
+ if (entries.size >= limits.maxDirectoryEntries)
1393
+ capacity();
1394
+ const target = join(parent, entry.name);
1395
+ const key = relative(directory, target);
1396
+ if (entries.has(key))
1397
+ fail("repeated an observed namespace entry");
1398
+ entries.set(key, metadata(target));
1399
+ visit(entry.name, target);
1400
+ }
1150
1401
  }
1151
- }
1152
- finally {
1153
- handle.closeSync();
1154
- }
1155
- return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
1402
+ finally {
1403
+ handle.closeSync();
1404
+ }
1405
+ };
1406
+ const requireType = (target, isDirectory) => {
1407
+ const value = lstatSync(target);
1408
+ if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
1409
+ (!isDirectory && value.nlink !== 1))
1410
+ fail("has a linked or unexpected journal type");
1411
+ };
1412
+ const evidenceDirectory = (target, retained) => scan(target, (name, path) => {
1413
+ if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name))
1414
+ fail("has an unclassified journal evidence entry");
1415
+ requireType(path, false);
1416
+ });
1417
+ const session = (target) => scan(target, (name, path) => {
1418
+ if (name === "recovery")
1419
+ return;
1420
+ const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
1421
+ if (!match || match[1] === "default")
1422
+ fail("has an unclassified session journal entry");
1423
+ requireType(path, !!match[2]);
1424
+ if (match[2])
1425
+ evidenceDirectory(path, match[2] === ".retained");
1426
+ const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[1] ?? "default";
1427
+ profiles.add(familyProfile);
1428
+ if (familyProfile === profile && base !== pollingPath)
1429
+ families.set(base, "session");
1430
+ });
1431
+ scan(directory, (name, target) => {
1432
+ // Legacy quarantine folders are disposable housekeeping, never journal authority.
1433
+ if (name.toLowerCase() === "recovery")
1434
+ return;
1435
+ if (name.toLowerCase() === "sessions") {
1436
+ if (!includeSessions)
1437
+ fail("requires session-qualified namespace inspection");
1438
+ if (name !== "sessions")
1439
+ fail("has a noncanonical session root");
1440
+ requireType(target, true);
1441
+ scan(target, (sessionName, sessionPath) => {
1442
+ if (decodeTelegramSessionDirectoryName(sessionName) === undefined)
1443
+ fail("has a noncanonical session directory");
1444
+ requireType(sessionPath, true);
1445
+ session(sessionPath);
1446
+ });
1447
+ return;
1448
+ }
1449
+ if (includeSessions && /^journal\./iu.test(name))
1450
+ fail("has a misplaced session journal");
1451
+ if (!/inbox/iu.test(name))
1452
+ return;
1453
+ const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
1454
+ if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained"))
1455
+ fail("has a noncanonical journal-like entry");
1456
+ requireType(target, !!match[3]);
1457
+ if (includeSessions && match[3])
1458
+ evidenceDirectory(target, match[3] === ".retained");
1459
+ const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[2] ?? "default";
1460
+ profiles.add(familyProfile);
1461
+ if (familyProfile === profile && base !== pollingPath)
1462
+ families.set(base, match[1] === "inbox" ? "session" : "follower");
1463
+ });
1464
+ return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
1465
+ families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
1156
1466
  };
1157
1467
  const before = census();
1468
+ const sourceInventory = [[pollingPath, "polling"], ...before.families];
1158
1469
  const sources = [];
1470
+ const retainedInputs = [];
1159
1471
  const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
1160
1472
  let knownBotId = expected.botId;
1161
- for (const name of [polling, ...before.followers]) {
1473
+ for (const [path, role] of sourceInventory) {
1162
1474
  const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
1163
1475
  maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
1164
1476
  if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0)
1165
1477
  capacity();
1166
- const path = join(directory, name);
1167
- const evidence = inspectTelegramUpdateJournalFamily({ directory, path, profile,
1168
- botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining });
1169
- if (evidence.kind === "absent" && (name !== polling || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
1478
+ const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
1479
+ botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
1480
+ const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
1481
+ const evidence = acquired.evidence;
1482
+ retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
1483
+ if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
1170
1484
  fail("lost a discovered journal family");
1171
1485
  }
1172
1486
  if (evidence.kind === "present") {
@@ -1175,11 +1489,20 @@ export function inspectTelegramProfileJournalNamespace(input) {
1175
1489
  accounting.bytes += evidence.accounting.bytes;
1176
1490
  }
1177
1491
  accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
1178
- sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
1492
+ sources.push({ role, path, evidence });
1179
1493
  }
1180
- if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory)))
1494
+ const namespaceCurrent = () => {
1495
+ try {
1496
+ return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
1497
+ isDeepStrictEqual(beforeRoot, metadata(directory));
1498
+ }
1499
+ catch {
1500
+ return false;
1501
+ }
1502
+ };
1503
+ if (!namespaceCurrent())
1181
1504
  fail("changed observed root namespace or identity");
1182
- return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
1505
+ return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
1183
1506
  }
1184
1507
  catch (error) {
1185
1508
  if (error instanceof TelegramUpdateJournalError)
@@ -1187,6 +1510,28 @@ export function inspectTelegramProfileJournalNamespace(input) {
1187
1510
  throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
1188
1511
  }
1189
1512
  }
1513
+ /** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
1514
+ export function isTelegramThreadCleanupJournalNamespaceClear(input) {
1515
+ if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
1516
+ throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
1517
+ const requiredPaths = new Set();
1518
+ for (const key of input.requiredJournalBindingKeys) {
1519
+ const path = getTelegramUpdateJournalBindingPath(key);
1520
+ const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
1521
+ if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
1522
+ path, profileName: input.profile, botIdentity,
1523
+ })))
1524
+ throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
1525
+ requiredPaths.add(path);
1526
+ }
1527
+ const namespace = inspectTelegramSessionJournalNamespace(input);
1528
+ if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
1529
+ namespace.retainedInputs?.some(original => original.state !== "committed") ||
1530
+ [...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
1531
+ return false;
1532
+ // Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
1533
+ return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
1534
+ }
1190
1535
  function cloneEntry(entry) {
1191
1536
  return {
1192
1537
  ...entry,
@@ -1201,6 +1546,7 @@ function cloneEntry(entry) {
1201
1546
  ? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
1202
1547
  : {}),
1203
1548
  ...(entry.failure ? { failure: { ...entry.failure } } : {}),
1549
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
1204
1550
  };
1205
1551
  }
1206
1552
  function cloneFile(file) {
@@ -1213,6 +1559,7 @@ function cloneFile(file) {
1213
1559
  profile: file.profile,
1214
1560
  botIdentity: { ...file.botIdentity },
1215
1561
  entries: file.entries.map(cloneEntry),
1562
+ ...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
1216
1563
  ...(file.operatorDispositions?.length
1217
1564
  ? {
1218
1565
  operatorDispositions: file.operatorDispositions.map((disposition) => ({ ...disposition })),
@@ -1312,6 +1659,53 @@ function mergeBotIdentity(stored, current) {
1312
1659
  tokenSha256: current.tokenSha256,
1313
1660
  };
1314
1661
  }
1662
+ function createPendingRetentionReference(input) {
1663
+ const { path, journalBindingKey, entry, maxBytes } = input;
1664
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
1665
+ const failureId = `abandon-${evidenceSha256}`;
1666
+ const retainedDirectory = `${path}.retained`;
1667
+ const retainedPath = join(retainedDirectory, `${failureId}.json`);
1668
+ const read = () => {
1669
+ let directory;
1670
+ try {
1671
+ directory = lstatSync(retainedDirectory);
1672
+ }
1673
+ catch (error) {
1674
+ if (error.code === "ENOENT")
1675
+ return undefined;
1676
+ throw error;
1677
+ }
1678
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
1679
+ throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
1680
+ }
1681
+ let stat;
1682
+ try {
1683
+ stat = lstatSync(retainedPath);
1684
+ }
1685
+ catch (error) {
1686
+ if (error.code === "ENOENT")
1687
+ return undefined;
1688
+ throw error;
1689
+ }
1690
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
1691
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
1692
+ }
1693
+ const retained = JSON.parse(readFileSync(retainedPath, "utf8"));
1694
+ if (!isRecord(retained))
1695
+ throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
1696
+ const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
1697
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
1698
+ disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
1699
+ disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
1700
+ !isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
1701
+ entry, requestedDisposition: disposition })) {
1702
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
1703
+ }
1704
+ return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
1705
+ requestedDisposition: disposition };
1706
+ };
1707
+ return { evidenceSha256, failureId, retainedPath, read };
1708
+ }
1315
1709
  function writeJournalFile(path, serialized, onPublicationBoundary, stagingPath = path) {
1316
1710
  const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
1317
1711
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -1432,6 +1826,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
1432
1826
  });
1433
1827
  const path = deps.getJournalPath(configuredProfileName);
1434
1828
  const workspaceAdmission = deps.getWorkspaceAdmission?.();
1829
+ const sourceAccess = { directory: dirname(path), limits: {
1830
+ maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
1831
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
1832
+ } };
1833
+ const options = { path, profileName, botIdentity,
1834
+ ...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
1835
+ ...(workspaceAdmission ? { workspaceAdmission } : {}),
1836
+ ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
1837
+ ...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
1838
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
1839
+ const journal = createTelegramUpdateJournalStore(options);
1840
+ // Scoped completion and receipt observation use strict private handles where the platform provides no-follow
1841
+ // evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
1842
+ const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
1843
+ const completionJournal = deps.withSourceSerialization
1844
+ ? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
1845
+ // Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
1846
+ const observeQueuedReceipt = (expected) => strict
1847
+ ? completionJournal.inspectQueuedReceipt(expected)
1848
+ : inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
1435
1849
  return {
1436
1850
  runtimeKey: JSON.stringify({
1437
1851
  path,
@@ -1446,27 +1860,25 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
1446
1860
  readForProtection() {
1447
1861
  // Protection must never turn corruption/recovery into empty-work evidence.
1448
1862
  const evidence = inspectTelegramUpdateJournalFamily({
1449
- directory: dirname(path), path, profile: profileName, botIdentity,
1450
- limits: {
1451
- maxFiles: 1024,
1452
- maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
1453
- maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
1454
- maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
1455
- },
1863
+ ...sourceAccess, path, profile: profileName, botIdentity,
1456
1864
  });
1457
- return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
1865
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
1458
1866
  },
1459
- journal: createTelegramUpdateJournalStore({
1460
- path,
1461
- profileName,
1462
- botIdentity,
1463
- ...(deps.getQueueRuntimeIdentity
1464
- ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
1465
- : {}),
1466
- ...(workspaceAdmission ? { workspaceAdmission } : {}),
1467
- ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
1468
- ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
1469
- }),
1867
+ journal: completionJournal ? { ...journal,
1868
+ removeCompletedExact(updateIds, expectedSources, completions) {
1869
+ return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
1870
+ : journal.removeCompletedExact(updateIds, expectedSources);
1871
+ },
1872
+ completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
1873
+ inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
1874
+ inspectQueuedReceipt: observeQueuedReceipt,
1875
+ isQueueReceiptCurrent(receipt, owner) {
1876
+ if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity }))
1877
+ return false;
1878
+ return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
1879
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
1880
+ },
1881
+ } : journal,
1470
1882
  };
1471
1883
  };
1472
1884
  }
@@ -1511,42 +1923,200 @@ export function withTelegramResolvedUpdateJournalReference(input) {
1511
1923
  return input.registry.withReference({ referenceClass: input.referenceClass,
1512
1924
  recoveryKey: binding.recoveryKey }, () => input.operation(binding));
1513
1925
  }
1926
+ function validateQueuedReceiptObservation(expected, path) {
1927
+ if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
1928
+ !isNonEmptyString(expected.receiptId) ||
1929
+ !Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
1930
+ expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
1931
+ expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]))) {
1932
+ throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
1933
+ }
1934
+ return validateJournalQueueOwner(expected.queueOwner, path);
1935
+ }
1936
+ function inspectJournalQueuedReceipt(file, expected, path) {
1937
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
1938
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
1939
+ const { queueOwner } = expected;
1940
+ const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
1941
+ if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) => entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
1942
+ entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner)))
1943
+ return undefined;
1944
+ return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
1945
+ sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
1946
+ sources: entries.map(createTelegramUpdateJournalEntryDigest),
1947
+ queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
1948
+ }
1949
+ function inspectJournalSourceCompletion(file, completion, path) {
1950
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
1951
+ throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
1952
+ const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
1953
+ if (!retained)
1954
+ return undefined;
1955
+ if (!isDeepStrictEqual(retained, completion))
1956
+ throw createJournalError("conflict", path, "source completion belongs to another exact scope");
1957
+ return { ...retained };
1958
+ }
1959
+ /** Operator-distinct authority: routing never treats it as an owner cancellation. */
1960
+ export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
1961
+ function isAdoptablePendingEntry(entry) {
1962
+ return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
1963
+ !entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
1964
+ entry.preApprovalExcluded !== true;
1965
+ }
1514
1966
  export function createTelegramUpdateJournalBindingRuntime(deps) {
1515
1967
  const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
1516
1968
  ...deps.base,
1517
1969
  getJournalPath: deps.getLeaderJournalPath,
1518
1970
  });
1519
- const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity) => createTelegramUpdateJournalRuntimeBindingResolver({
1520
- getProfileName: deps.base.getProfileName,
1521
- getBotToken: deps.base.getBotToken,
1522
- getBotId: deps.base.getBotId,
1523
- ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
1524
- ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
1525
- : {}),
1526
- ...(deps.base.withWriterAdmission
1527
- ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
1528
- ...(deps.base.getWorkspaceAdmission
1529
- ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
1530
- : {}),
1531
- ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
1532
- getJournalPath(profileName) {
1533
- return deps.getFollowerJournalPath(bindingKey, profileName);
1534
- },
1535
- });
1536
- const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
1971
+ const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity, sessionId, legacy = false) => {
1972
+ // Session-aware composition must not fall back to a process journal before preparation supplies its ID.
1973
+ if (!legacy && deps.getActiveFollowerSessionId && !sessionId)
1974
+ return () => undefined;
1975
+ return createTelegramUpdateJournalRuntimeBindingResolver({
1976
+ getProfileName: deps.base.getProfileName,
1977
+ getBotToken: deps.base.getBotToken,
1978
+ getBotId: deps.base.getBotId,
1979
+ ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
1980
+ ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
1981
+ : {}),
1982
+ ...(deps.base.withWriterAdmission
1983
+ ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
1984
+ // Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
1985
+ ...(deps.base.withSourceSerialization
1986
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
1987
+ ...(deps.base.getWorkspaceAdmission
1988
+ ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
1989
+ : {}),
1990
+ ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
1991
+ getJournalPath(profileName) {
1992
+ return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
1993
+ },
1994
+ });
1995
+ };
1996
+ const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
1537
1997
  const resolveActive = () => deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
1538
- return {
1998
+ const inspectHistoricalSource = (journalBindingKey, operation) => {
1999
+ const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
2000
+ const token = deps.base.getBotToken(), botId = deps.base.getBotId();
2001
+ const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
2002
+ if (!path || !token)
2003
+ return undefined;
2004
+ const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
2005
+ const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity => createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
2006
+ if (!keys.includes(journalBindingKey))
2007
+ return undefined;
2008
+ const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
2009
+ const root = getRoot();
2010
+ const parts = relative(root, path).split(sep);
2011
+ const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
2012
+ const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
2013
+ const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
2014
+ if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
2015
+ !(parts.length === 1 && flat.test(parts[0]) || parts.length === 3 && parts[0] === "sessions" &&
2016
+ decodeTelegramSessionDirectoryName(parts[1]) !== undefined && session.test(parts[2])))
2017
+ return undefined;
2018
+ const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
2019
+ (deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
2020
+ const inspect = () => {
2021
+ if (!current())
2022
+ return undefined;
2023
+ const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
2024
+ limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2025
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
2026
+ if (observed.evidence.kind !== "present" || !current())
2027
+ return undefined;
2028
+ return operation(observed.evidence.file, observed.retainedInputs, path);
2029
+ };
2030
+ const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
2031
+ return current() ? result : undefined;
2032
+ };
2033
+ let preparedFollower;
2034
+ const runtime = {
1539
2035
  resolveLeader,
1540
2036
  resolveFollower,
1541
2037
  resolveActive,
1542
2038
  getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
1543
- createRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false),
2039
+ prepareActiveFollowerSuccession(isCurrent) {
2040
+ const recipientBindingKey = deps.getActiveFollowerBindingKey();
2041
+ const sessionId = deps.getActiveFollowerSessionId?.();
2042
+ if (!sessionId)
2043
+ return undefined;
2044
+ const previous = preparedFollower;
2045
+ const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
2046
+ ? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
2047
+ successorSessionId: sessionId, isCurrent })
2048
+ : undefined;
2049
+ // Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
2050
+ preparedFollower = { recipientBindingKey, sessionId };
2051
+ return result;
2052
+ },
2053
+ inspectSourceAbandonment(journalBindingKey, updateId) {
2054
+ if (!isSafeNonNegativeInteger(updateId))
2055
+ throw new Error("Telegram abandonment proof requires an exact update ID.");
2056
+ return inspectHistoricalSource(journalBindingKey, (file, originals) => {
2057
+ const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
2058
+ value.journalBindingKey === journalBindingKey);
2059
+ if (!original)
2060
+ return undefined;
2061
+ const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
2062
+ if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody")
2063
+ return undefined;
2064
+ return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
2065
+ });
2066
+ },
2067
+ inspectSourceCompletion(journalBindingKey, expected) {
2068
+ const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
2069
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
2070
+ },
2071
+ inspectQueuedReceipt(journalBindingKey, expected) {
2072
+ const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
2073
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
2074
+ },
2075
+ adoptPredecessorPending(input) {
2076
+ const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
2077
+ if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
2078
+ predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
2079
+ throw new Error("Telegram session adoption requires distinct exact session identities.");
2080
+ }
2081
+ const assertCurrent = () => {
2082
+ if (!isCurrent())
2083
+ throw new Error("Telegram session adoption lost successor authority.");
2084
+ };
2085
+ assertCurrent();
2086
+ const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
2087
+ const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
2088
+ if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
2089
+ throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
2090
+ }
2091
+ const result = { adoptedUpdateIds: [], retainedUpdateIds: [] };
2092
+ // Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
2093
+ const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
2094
+ .sort((left, right) => left.updateId - right.updateId);
2095
+ for (const entry of candidates) {
2096
+ assertCurrent();
2097
+ // Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
2098
+ const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
2099
+ operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
2100
+ if (committed.duplicate) {
2101
+ result.retainedUpdateIds.push(entry.updateId);
2102
+ continue;
2103
+ }
2104
+ assertCurrent();
2105
+ successor.journal.appendBatch([entry.update]);
2106
+ result.adoptedUpdateIds.push(entry.updateId);
2107
+ }
2108
+ return result;
2109
+ },
2110
+ createRecipientResolver: (bindingKey, sessionId) => createFollowerResolver(bindingKey, false, sessionId),
2111
+ createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
1544
2112
  createPathResolver: (path) => createTelegramUpdateJournalRuntimeBindingResolver({
1545
2113
  getProfileName: deps.base.getProfileName,
1546
2114
  getBotToken: deps.base.getBotToken,
1547
2115
  getBotId: deps.base.getBotId,
1548
2116
  ...(deps.base.withWriterAdmission
1549
2117
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
2118
+ ...(deps.base.withSourceSerialization
2119
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
1550
2120
  ...(deps.base.getWorkspaceAdmission
1551
2121
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
1552
2122
  : {}),
@@ -1554,6 +2124,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
1554
2124
  getJournalPath: () => path,
1555
2125
  }),
1556
2126
  };
2127
+ return runtime;
1557
2128
  }
1558
2129
  export function createTelegramUpdateJournalStore(options) {
1559
2130
  return createJournalStoreCore(options).journal;
@@ -1673,6 +2244,9 @@ function createJournalStoreCore(options, getInputContext) {
1673
2244
  if ((file.operatorDispositions?.length ?? 0) > maxEntries) {
1674
2245
  throw createJournalError("capacity", path, `exceeds its ${maxEntries}-operator-disposition limit`);
1675
2246
  }
2247
+ if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
2248
+ throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
2249
+ }
1676
2250
  const serializedBytes = Buffer.byteLength(serialized);
1677
2251
  if (serializedBytes > maxBytes) {
1678
2252
  throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
@@ -1685,7 +2259,7 @@ function createJournalStoreCore(options, getInputContext) {
1685
2259
  botIdentity: { ...expectedIdentity },
1686
2260
  entries: [],
1687
2261
  });
1688
- const readCurrentStrict = () => {
2262
+ const readCurrentStrict = (allowReconciliation = true) => {
1689
2263
  let source;
1690
2264
  let recoveringMissingSnapshot = false;
1691
2265
  try {
@@ -1711,6 +2285,8 @@ function createJournalStoreCore(options, getInputContext) {
1711
2285
  throw createJournalError("io", segmentDirectory, "could not be read while the journal snapshot is missing", segmentError);
1712
2286
  }
1713
2287
  if (orphanedSegmentNames.length > 0) {
2288
+ if (!allowReconciliation)
2289
+ throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
1714
2290
  if (withPairingAdmission)
1715
2291
  throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
1716
2292
  recoveringMissingSnapshot = true;
@@ -1788,6 +2364,7 @@ function createJournalStoreCore(options, getInputContext) {
1788
2364
  (withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)) {
1789
2365
  throw createJournalError("identity-mismatch", segmentPath, "belongs to another journal identity");
1790
2366
  }
2367
+ assertJournalSourceCompletionContinuity(file, segment, segmentPath);
1791
2368
  const entriesById = new Map(file.entries.map((entry) => [entry.updateId, entry]));
1792
2369
  for (const updateId of segment.removedUpdateIds) {
1793
2370
  if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
@@ -1824,14 +2401,16 @@ function createJournalStoreCore(options, getInputContext) {
1824
2401
  : file.operatorDispositions?.length
1825
2402
  ? { operatorDispositions: file.operatorDispositions }
1826
2403
  : {}),
2404
+ ...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
2405
+ : file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
1827
2406
  }, segmentPath, version);
1828
2407
  revision = segment.revision;
1829
2408
  }
1830
2409
  const identityChanged = storedProfile !== profile ||
1831
2410
  !identitiesMatch(file.botIdentity, expectedIdentity) ||
1832
- (withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
2411
+ ((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
1833
2412
  if (identityChanged) {
1834
- if (file.entries.length > 0 || withPairingAdmission) {
2413
+ if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
1835
2414
  throw createJournalError("identity-mismatch", path, storedProfile !== profile
1836
2415
  ? `belongs to profile ${storedProfile}, not ${profile}`
1837
2416
  : "belongs to another Telegram bot identity");
@@ -1970,39 +2549,40 @@ function createJournalStoreCore(options, getInputContext) {
1970
2549
  // Only known schema/JSON corruption may fall through to reset.
1971
2550
  }
1972
2551
  }
1973
- const recoveryDirectory = join(dirname(path), "recovery", `${getNowMs()}-${process.pid}-${randomUUID()}`);
1974
- mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
1975
- const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
1976
- const segmentQuarantinePath = join(recoveryDirectory, basename(segmentDirectory));
1977
- if (snapshotExists &&
1978
- !renameTelegramPathWithRetry(path, snapshotQuarantinePath)) {
1979
- throw createJournalError("io", path, "could not quarantine an unrecoverable journal snapshot", error);
1980
- }
1981
- if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
1982
- if (snapshotExists) {
1983
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
2552
+ // Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
2553
+ // Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
2554
+ const deletedPaths = [];
2555
+ const deleteDamaged = (target, label) => {
2556
+ try {
2557
+ lstatSync(target);
1984
2558
  }
1985
- throw createJournalError("io", segmentDirectory, "could not quarantine an unrecoverable journal segment history", error);
1986
- }
2559
+ catch (statError) {
2560
+ if (statError.code === "ENOENT")
2561
+ return;
2562
+ throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
2563
+ }
2564
+ try {
2565
+ rmSync(target, { recursive: true, force: true });
2566
+ }
2567
+ catch (removeError) {
2568
+ throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
2569
+ }
2570
+ deletedPaths.push(target);
2571
+ };
2572
+ deleteDamaged(segmentDirectory, "journal segment history");
2573
+ // A regular snapshot is replaced atomically below; anything else cannot be a rename target.
2574
+ if (snapshotExists && !lstatSync(path).isFile())
2575
+ deleteDamaged(path, "journal snapshot");
1987
2576
  const reset = emptyFile();
1988
2577
  const serialized = serializeJournalFile(reset);
1989
2578
  const serializedBytes = assertCapacity(reset, serialized);
1990
- try {
1991
- writeJournalFile(path, serialized, onPublicationBoundary);
1992
- }
1993
- catch (publicationError) {
1994
- const segmentsRestored = renameTelegramPathWithRetry(segmentQuarantinePath, segmentDirectory);
1995
- const snapshotRestored = !snapshotExists ||
1996
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
1997
- if (!segmentsRestored || !snapshotRestored) {
1998
- throw createJournalError("io", path, "reset publication failed after journal evidence was quarantined", publicationError);
1999
- }
2000
- throw publicationError;
2001
- }
2579
+ writeJournalFile(path, serialized, onPublicationBoundary);
2580
+ if (snapshotExists && !deletedPaths.includes(path))
2581
+ deletedPaths.push(path);
2002
2582
  notifyRecovery({
2003
2583
  kind: "reset",
2004
2584
  path,
2005
- quarantinePath: recoveryDirectory,
2585
+ deletedPaths,
2006
2586
  reason: error.message,
2007
2587
  });
2008
2588
  return { file: reset, exists: true, serializedBytes };
@@ -2048,7 +2628,7 @@ function createJournalStoreCore(options, getInputContext) {
2048
2628
  const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
2049
2629
  const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
2050
2630
  const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
2051
- const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0);
2631
+ const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
2052
2632
  const advanceInputHeadroomState = (state, entries, projectionSlack) => {
2053
2633
  const revision = (state.file.revision ?? 0) + 1;
2054
2634
  if (!isSafePositiveInteger(revision))
@@ -2190,7 +2770,7 @@ function createJournalStoreCore(options, getInputContext) {
2190
2770
  const createInputHeadroomSegment = (previous, next) => {
2191
2771
  const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
2192
2772
  const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
2193
- return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
2773
+ return { version,
2194
2774
  revision: next.revision, previousRevision: next.revision - 1,
2195
2775
  profile: next.profile, botIdentity: next.botIdentity,
2196
2776
  upsertedEntries: next.entries.filter(entry => !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
@@ -2218,7 +2798,7 @@ function createJournalStoreCore(options, getInputContext) {
2218
2798
  : { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
2219
2799
  if (publishedSegment) {
2220
2800
  const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
2221
- (publishedSegment.operatorDispositions?.length ?? 0);
2801
+ (publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
2222
2802
  const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
2223
2803
  baseRetainedCount += 1;
2224
2804
  baseRetainedBytes += segmentBytes;
@@ -2271,9 +2851,10 @@ function createJournalStoreCore(options, getInputContext) {
2271
2851
  const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
2272
2852
  const segmentBytes = Buffer.byteLength(segmentText);
2273
2853
  const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
2274
- segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
2275
- const segmentWork = collections[0] + collections[1] + collections[2];
2276
- const fileWork = collections[3] + collections[4];
2854
+ segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
2855
+ segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
2856
+ const segmentWork = collections[0] + collections[1] + collections[2] + collections[5];
2857
+ const fileWork = collections[3] + collections[4] + collections[6];
2277
2858
  const accounting = source.evidence.accounting;
2278
2859
  // Both the segment-first state and snapshot-first cleanup residue must fit.
2279
2860
  assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes, accounting.work + segmentWork + fileWork, collections);
@@ -2311,12 +2892,13 @@ function createJournalStoreCore(options, getInputContext) {
2311
2892
  }
2312
2893
  return { file: revisedFile, serializedBytes };
2313
2894
  };
2314
- const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false) => {
2895
+ const publishMutation = (current, entries, contentChanged, operatorDispositions = current.file.operatorDispositions, acceptedThroughUpdateId = current.file.acceptedThroughUpdateId, publicationBoundary = onPublicationBoundary, inputHeadroom = journalHasCustodyFields(version) ? "all" : false, sourceCompletions = current.file.sourceCompletions) => {
2315
2896
  const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(current.file.botIdentity, expectedIdentity);
2316
2897
  if (!contentChanged &&
2317
2898
  isDeepStrictEqual(current.file.botIdentity, botIdentity) &&
2318
2899
  isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? []) &&
2319
- current.file.acceptedThroughUpdateId === acceptedThroughUpdateId) {
2900
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
2901
+ isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])) {
2320
2902
  return { file: current.file, serializedBytes: current.serializedBytes };
2321
2903
  }
2322
2904
  const file = {
@@ -2330,21 +2912,24 @@ function createJournalStoreCore(options, getInputContext) {
2330
2912
  ...(operatorDispositions?.length
2331
2913
  ? { operatorDispositions }
2332
2914
  : {}),
2915
+ ...(sourceCompletions?.length ? { sourceCompletions } : {}),
2333
2916
  };
2917
+ assertJournalRoutingInputContinuity(current.file, entries, path);
2334
2918
  if (withPairingAdmission || current.source)
2335
2919
  parseJournalFile(file, path, version);
2336
2920
  const serialized = serializeJournalFile(file);
2337
2921
  const serializedBytes = assertCapacity(file, serialized);
2338
2922
  const changed = contentChanged ||
2339
2923
  current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
2340
- !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
2924
+ !isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
2925
+ !isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
2341
2926
  if (!changed) {
2342
2927
  return { file, serializedBytes: current.serializedBytes };
2343
2928
  }
2344
2929
  if (!current.exists) {
2345
2930
  if (current.source) {
2346
- const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
2347
- assertSourceResources(1, serializedBytes, collections[0] + collections[1], collections);
2931
+ const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
2932
+ assertSourceResources(1, serializedBytes, collections[0] + collections[1] + collections[2], collections);
2348
2933
  }
2349
2934
  if (inputHeadroom !== false)
2350
2935
  assertInputProgressHeadroom(current, file, inputHeadroom);
@@ -2379,6 +2964,7 @@ function createJournalStoreCore(options, getInputContext) {
2379
2964
  ...(!isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? [])
2380
2965
  ? { operatorDispositions: operatorDispositions ?? [] }
2381
2966
  : {}),
2967
+ ...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
2382
2968
  };
2383
2969
  if (current.source) {
2384
2970
  if (!isSafePositiveInteger(revision))
@@ -2386,7 +2972,7 @@ function createJournalStoreCore(options, getInputContext) {
2386
2972
  const revisedFile = { ...file, revision };
2387
2973
  if (inputHeadroom !== false)
2388
2974
  assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
2389
- return publishSourceSegment(current, file, segment, publicationBoundary, version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
2975
+ return publishSourceSegment(current, file, segment, publicationBoundary, journalHasCustodyFields(version));
2390
2976
  }
2391
2977
  publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
2392
2978
  const revisedFile = { ...file, revision };
@@ -2429,6 +3015,175 @@ function createJournalStoreCore(options, getInputContext) {
2429
3015
  }
2430
3016
  return { file: revisedFile, serializedBytes };
2431
3017
  };
3018
+ const removeCompleted = (updateIds, expectedSources, exact = false, completionInput) => runMutation((readCurrent) => {
3019
+ const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
3020
+ if (completionInput !== undefined && !sourceAccess)
3021
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
3022
+ const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
3023
+ if (exact && expectedSources === undefined)
3024
+ throw createJournalError("invalid", path, "received no exact completion sources");
3025
+ const requestedIds = new Set();
3026
+ for (const updateId of updateIds) {
3027
+ if (!isSafeNonNegativeInteger(updateId))
3028
+ throw createJournalError("invalid", path, "received an invalid removal update id");
3029
+ requestedIds.add(updateId);
3030
+ }
3031
+ if (expectedSources !== undefined) {
3032
+ if (!Array.isArray(expectedSources) || !expectedSources.length)
3033
+ throw createJournalError("invalid", path, "received no exact completion sources");
3034
+ const expectedIds = new Set();
3035
+ for (const expected of expectedSources) {
3036
+ if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
3037
+ !isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
3038
+ typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
3039
+ throw createJournalError("invalid", path, "received an invalid exact completion source");
3040
+ }
3041
+ expectedIds.add(expected.updateId);
3042
+ const entry = current.file.entries.find(value => value.updateId === expected.updateId);
3043
+ if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
3044
+ throw createJournalError("conflict", path, "exact completion source changed or disappeared");
3045
+ }
3046
+ }
3047
+ }
3048
+ if (completions) {
3049
+ const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
3050
+ if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
3051
+ throw createJournalError("invalid", path, "source completion has no matching exact guard");
3052
+ }
3053
+ }
3054
+ const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
3055
+ const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
3056
+ if (protectedEntry)
3057
+ throw createJournalError("conflict", path, protectedEntry.state === "failed"
3058
+ ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
3059
+ : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
3060
+ const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
3061
+ const sourceCompletions = completions
3062
+ ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
3063
+ if (sourceCompletions?.length)
3064
+ validateJournalSourceCompletions(sourceCompletions, path, version);
3065
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
3066
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
3067
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
3068
+ });
3069
+ const completeQueued = (receipts, completionInput, exact = false) => runMutation((readCurrent) => {
3070
+ if (exact && !sourceAccess)
3071
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
3072
+ const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
3073
+ if (!Array.isArray(receipts) || receipts.length === 0)
3074
+ throw createJournalError("invalid", path, "received no queued receipts to complete");
3075
+ const current = readCurrent();
3076
+ const receiptIds = new Set(), requestedUpdateIds = new Set();
3077
+ for (const receipt of receipts) {
3078
+ if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
3079
+ receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
3080
+ throw createJournalError("invalid", path, "received an invalid queued completion receipt");
3081
+ }
3082
+ receiptIds.add(receipt.receiptId);
3083
+ const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
3084
+ if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
3085
+ queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
3086
+ throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
3087
+ }
3088
+ const sourceUpdateIds = new Set();
3089
+ for (const updateId of receipt.sourceUpdateIds) {
3090
+ if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
3091
+ throw createJournalError("invalid", path, "received overlapping queued completion update ids");
3092
+ }
3093
+ sourceUpdateIds.add(updateId);
3094
+ requestedUpdateIds.add(updateId);
3095
+ }
3096
+ const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
3097
+ if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry => !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
3098
+ entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
3099
+ throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
3100
+ }
3101
+ }
3102
+ const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
3103
+ const removedUpdateIds = removedEntries.map(entry => entry.updateId);
3104
+ if (removedUpdateIds.length !== requestedUpdateIds.size)
3105
+ throw createJournalError("conflict", path, "queued completion did not resolve every source update");
3106
+ if (completions) {
3107
+ const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
3108
+ if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
3109
+ throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
3110
+ }
3111
+ }
3112
+ const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
3113
+ : current.file.sourceCompletions;
3114
+ if (sourceCompletions?.length)
3115
+ validateJournalSourceCompletions(sourceCompletions, path, version);
3116
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
3117
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
3118
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
3119
+ });
3120
+ const mutateRoutingInputs = (input, select, publishedAtMs) => runMutation(readCurrent => {
3121
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version))
3122
+ throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
3123
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3124
+ if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
3125
+ typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
3126
+ throw createJournalError("invalid", path, "invalid routing input lifetime authority");
3127
+ }
3128
+ const assertCurrent = () => { if (!input.isCurrent())
3129
+ throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
3130
+ assertCurrent();
3131
+ const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
3132
+ if (new Set(expected.map(entry => entry.updateId)).size !== expected.length)
3133
+ throw createJournalError("invalid", path, "duplicate routing input sources");
3134
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
3135
+ const nowMs = select ? getNowMs() : publishedAtMs;
3136
+ if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs()))
3137
+ throw createJournalError("invalid", path, "invalid routing input lifetime clock");
3138
+ let issued = false;
3139
+ const changed = new Map();
3140
+ const result = expected.map(original => {
3141
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
3142
+ if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
3143
+ throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
3144
+ }
3145
+ const withoutLifetime = (value) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
3146
+ if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
3147
+ (original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
3148
+ throw createJournalError("conflict", path, "routing input source changed");
3149
+ }
3150
+ const lifetime = entry.routingInput;
3151
+ if (lifetime && lifetime.operatorUserId !== input.operatorUserId)
3152
+ throw createJournalError("conflict", path, "routing input belongs to another operator");
3153
+ if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
3154
+ throw createJournalError("conflict", path, "routing input has protected retention");
3155
+ }
3156
+ if (select) {
3157
+ if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs))
3158
+ throw createJournalError("conflict", path, "routing input choice expired or was not armed");
3159
+ if (lifetime.phase === "selected")
3160
+ return cloneEntry(entry);
3161
+ issued = true;
3162
+ const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" } };
3163
+ changed.set(entry.updateId, selected);
3164
+ return cloneEntry(selected);
3165
+ }
3166
+ if (lifetime) {
3167
+ if (lifetime.phase !== "waiting")
3168
+ throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
3169
+ return cloneEntry(entry);
3170
+ }
3171
+ if (nowMs < entry.admittedAtMs)
3172
+ throw createJournalError("conflict", path, "routing input publication predates admission");
3173
+ issued = true;
3174
+ const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
3175
+ publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" } };
3176
+ changed.set(entry.updateId, armed);
3177
+ return cloneEntry(armed);
3178
+ });
3179
+ assertCurrent();
3180
+ if (select && issued && result.some(entry => !changed.has(entry.updateId)))
3181
+ throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
3182
+ if (changed.size)
3183
+ publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
3184
+ assertCurrent();
3185
+ return { issued, entries: result };
3186
+ });
2432
3187
  const journal = {
2433
3188
  read() {
2434
3189
  return runMutation((readCurrent) => {
@@ -2467,9 +3222,12 @@ function createJournalStoreCore(options, getInputContext) {
2467
3222
  const current = readCurrent();
2468
3223
  const entriesById = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
2469
3224
  const previousAcceptedThroughUpdateId = current.file.acceptedThroughUpdateId;
2470
- const discardedUpdateIds = new Set((current.file.operatorDispositions ?? [])
2471
- .filter((disposition) => disposition.action === "discard")
2472
- .map((disposition) => disposition.updateId));
3225
+ const discardedUpdateIds = new Set([
3226
+ ...(current.file.operatorDispositions ?? [])
3227
+ .filter((disposition) => disposition.action === "discard")
3228
+ .map((disposition) => disposition.updateId),
3229
+ ...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
3230
+ ]);
2473
3231
  let admittedAtMs;
2474
3232
  const addedUpdateIds = [];
2475
3233
  const duplicateUpdateIds = [];
@@ -2719,6 +3477,7 @@ function createJournalStoreCore(options, getInputContext) {
2719
3477
  updateId: entry.updateId,
2720
3478
  update: entry.update,
2721
3479
  admittedAtMs: entry.admittedAtMs,
3480
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
2722
3481
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
2723
3482
  state: input.disposition,
2724
3483
  failure,
@@ -2808,8 +3567,8 @@ function createJournalStoreCore(options, getInputContext) {
2808
3567
  applyLegacyCustodyDisposition(authority) {
2809
3568
  return runMutation((readCurrent) => {
2810
3569
  const current = readCurrent();
2811
- if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
2812
- throw createJournalError("conflict", path, "legacy custody disposition requires a v3 journal");
3570
+ if (!journalHasCustodyFields(current.file.version))
3571
+ throw createJournalError("conflict", path, "legacy custody disposition requires a custody journal");
2813
3572
  const existing = current.file.operatorDispositions?.find(candidate => candidate.failureId === authority.dispositionId);
2814
3573
  if (existing) {
2815
3574
  if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
@@ -3018,67 +3777,8 @@ function createJournalStoreCore(options, getInputContext) {
3018
3777
  };
3019
3778
  });
3020
3779
  },
3021
- completeQueued(receipts) {
3022
- return runMutation((readCurrent) => {
3023
- if (!Array.isArray(receipts) || receipts.length === 0) {
3024
- throw createJournalError("invalid", path, "received no queued receipts to complete");
3025
- }
3026
- const current = readCurrent();
3027
- const receiptIds = new Set();
3028
- const requestedUpdateIds = new Set();
3029
- for (const receipt of receipts) {
3030
- if ((receipt.queueKind !== "prompt" &&
3031
- receipt.queueKind !== "control") ||
3032
- !isNonEmptyString(receipt.receiptId) ||
3033
- receiptIds.has(receipt.receiptId) ||
3034
- !Array.isArray(receipt.sourceUpdateIds) ||
3035
- receipt.sourceUpdateIds.length === 0) {
3036
- throw createJournalError("invalid", path, "received an invalid queued completion receipt");
3037
- }
3038
- receiptIds.add(receipt.receiptId);
3039
- const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
3040
- if (queueRuntimeIdentity &&
3041
- (queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
3042
- queueOwner.processId !== queueRuntimeIdentity.processId ||
3043
- queueOwner.processBirthId !==
3044
- queueRuntimeIdentity.processBirthId)) {
3045
- throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
3046
- }
3047
- const sourceUpdateIds = new Set();
3048
- for (const updateId of receipt.sourceUpdateIds) {
3049
- if (!isSafeNonNegativeInteger(updateId) ||
3050
- sourceUpdateIds.has(updateId) ||
3051
- requestedUpdateIds.has(updateId)) {
3052
- throw createJournalError("invalid", path, "received overlapping queued completion update ids");
3053
- }
3054
- sourceUpdateIds.add(updateId);
3055
- requestedUpdateIds.add(updateId);
3056
- }
3057
- const persistedReceiptEntries = current.file.entries.filter((entry) => entry.queueReceiptId === receipt.receiptId);
3058
- if (persistedReceiptEntries.length !== sourceUpdateIds.size ||
3059
- persistedReceiptEntries.some((entry) => !sourceUpdateIds.has(entry.updateId) ||
3060
- entry.state !== "queued" ||
3061
- entry.queueKind !== receipt.queueKind ||
3062
- !entry.queueOwner ||
3063
- entry.queueHandoff !== undefined ||
3064
- !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
3065
- throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
3066
- }
3067
- }
3068
- const removedUpdateIds = current.file.entries
3069
- .filter((entry) => requestedUpdateIds.has(entry.updateId))
3070
- .map((entry) => entry.updateId);
3071
- if (removedUpdateIds.length !== requestedUpdateIds.size) {
3072
- throw createJournalError("conflict", path, "queued completion did not resolve every source update");
3073
- }
3074
- const published = publishMutation(current, current.file.entries.filter((entry) => !requestedUpdateIds.has(entry.updateId)), true);
3075
- return {
3076
- removedUpdateIds,
3077
- entryCount: published.file.entries.length,
3078
- serializedBytes: published.serializedBytes,
3079
- };
3080
- });
3081
- },
3780
+ completeQueued(receipts) { return completeQueued(receipts); },
3781
+ completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
3082
3782
  discardQueued(input) {
3083
3783
  return runMutation((readCurrent) => {
3084
3784
  if ((input.queueKind !== "prompt" &&
@@ -3194,33 +3894,199 @@ function createJournalStoreCore(options, getInputContext) {
3194
3894
  };
3195
3895
  });
3196
3896
  },
3197
- removeCompleted(updateIds) {
3897
+ inspectAbandonedPending(updateId) {
3198
3898
  return runMutation((readCurrent) => {
3199
- const current = readCurrent();
3200
- const requestedIds = new Set();
3201
- for (const updateId of updateIds) {
3202
- if (!isSafeNonNegativeInteger(updateId)) {
3203
- throw createJournalError("invalid", path, "received an invalid removal update id");
3204
- }
3205
- requestedIds.add(updateId);
3899
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3900
+ throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
3206
3901
  }
3207
- const requestedEntries = current.file.entries.filter((entry) => requestedIds.has(entry.updateId));
3208
- const protectedEntry = requestedEntries.find((entry) => entry.state === "failed" || entry.state === "queued");
3209
- if (protectedEntry) {
3210
- throw createJournalError("conflict", path, protectedEntry.state === "failed"
3211
- ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
3212
- : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
3902
+ if (!isSafeNonNegativeInteger(updateId))
3903
+ throw createJournalError("invalid", path, "invalid abandonment update id");
3904
+ // Inspection never repairs or quarantines source evidence.
3905
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
3906
+ const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
3907
+ const disposition = matches[0];
3908
+ if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
3909
+ disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
3910
+ disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
3911
+ current.file.entries.some(entry => entry.updateId === updateId))
3912
+ return undefined;
3913
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3914
+ const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
3915
+ let stat;
3916
+ try {
3917
+ stat = lstatSync(retainedPath);
3213
3918
  }
3214
- const removedUpdateIds = requestedEntries.map((entry) => entry.updateId);
3215
- const published = publishMutation(current, current.file.entries.filter((entry) => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0);
3216
- return {
3217
- removedUpdateIds,
3218
- entryCount: published.file.entries.length,
3219
- serializedBytes: published.serializedBytes,
3919
+ catch (error) {
3920
+ if (error.code === "ENOENT")
3921
+ throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
3922
+ throw error;
3923
+ }
3924
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
3925
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
3926
+ }
3927
+ const parsed = JSON.parse(readFileSync(retainedPath, "utf8"));
3928
+ if (!isRecord(parsed))
3929
+ throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
3930
+ const entry = validateJournalEntry(parsed.entry, retainedPath, version);
3931
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
3932
+ const retained = reference.read();
3933
+ if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
3934
+ !isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
3935
+ throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
3936
+ }
3937
+ return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
3938
+ });
3939
+ },
3940
+ inspectPendingRetention(entry) {
3941
+ return runMutation(() => {
3942
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3943
+ throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
3944
+ }
3945
+ const expected = validateJournalEntry(entry, path, version);
3946
+ if (expected.state !== "pending")
3947
+ throw createJournalError("conflict", path, "retention inspection requires a pending source");
3948
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3949
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
3950
+ const retained = reference.read();
3951
+ return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
3952
+ requestedDisposition: retained.requestedDisposition } : undefined;
3953
+ });
3954
+ },
3955
+ abandonPending(input) {
3956
+ return runMutation((readCurrent) => {
3957
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3958
+ throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
3959
+ }
3960
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3961
+ if (input.journalBindingKey !== journalBindingKey) {
3962
+ throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
3963
+ }
3964
+ if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
3965
+ !input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
3966
+ throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
3967
+ }
3968
+ const assertCurrent = () => {
3969
+ if (!input.isCurrent())
3970
+ throw createJournalError("conflict", path, "pending abandonment lost authority");
3971
+ };
3972
+ assertCurrent();
3973
+ const expected = validateJournalEntry(input.entry, path, version);
3974
+ if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
3975
+ expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
3976
+ throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
3977
+ }
3978
+ // Never repair/quarantine source evidence while deciding an operator cancellation.
3979
+ const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
3980
+ const current = readStrict();
3981
+ const { evidenceSha256, failureId, retainedPath, read: readRetention } = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
3982
+ const existing = current.file.operatorDispositions?.find(item => item.failureId === failureId || item.updateId === expected.updateId);
3983
+ const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
3984
+ const assertRetention = (retained, disposition) => {
3985
+ if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
3986
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
3987
+ }
3220
3988
  };
3989
+ if (existing) {
3990
+ if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
3991
+ existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
3992
+ throw createJournalError("conflict", path, "pending source already has another disposition");
3993
+ }
3994
+ assertRetention(readRetention(), existing);
3995
+ assertCurrent();
3996
+ return { disposition: existing, retainedPath, duplicate: true,
3997
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
3998
+ }
3999
+ if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
4000
+ throw createJournalError("conflict", path, "pending source changed before abandonment");
4001
+ }
4002
+ const nowMs = getNowMs();
4003
+ if (!isSafeNonNegativeInteger(nowMs))
4004
+ throw createJournalError("invalid", path, "invalid abandonment timestamp");
4005
+ const retained = readRetention();
4006
+ let disposition;
4007
+ if (retained) {
4008
+ disposition = retained.requestedDisposition;
4009
+ }
4010
+ else {
4011
+ // Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
4012
+ // This is an operator disposition, not a fabricated execution failure or completion.
4013
+ disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
4014
+ action: "discard", committedAtMs: nowMs, evidenceSha256,
4015
+ operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
4016
+ const copy = { version: 1, kind: "pending-input-retention",
4017
+ journalBindingKey, entry: expected, requestedDisposition: disposition };
4018
+ writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
4019
+ onPublicationBoundary?.(boundary, target);
4020
+ assertCurrent();
4021
+ });
4022
+ }
4023
+ assertCurrent();
4024
+ assertRetention(readRetention(), disposition);
4025
+ const committed = (snapshot) => !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
4026
+ snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
4027
+ try {
4028
+ const published = publishMutation(current, current.file.entries.filter(entry => entry.updateId !== expected.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
4029
+ return { disposition, retainedPath, duplicate: false,
4030
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
4031
+ }
4032
+ catch (error) {
4033
+ // A segment may be durable even if later snapshot compaction failed.
4034
+ // Only exact read-back can turn that unknown acknowledgement into success.
4035
+ let observed;
4036
+ try {
4037
+ observed = readStrict();
4038
+ }
4039
+ catch {
4040
+ throw error;
4041
+ }
4042
+ if (!committed(observed))
4043
+ throw error;
4044
+ assertRetention(readRetention(), disposition);
4045
+ return { disposition, retainedPath, duplicate: false,
4046
+ entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
4047
+ }
4048
+ });
4049
+ },
4050
+ removeCompleted(updateIds) { return removeCompleted(updateIds); },
4051
+ removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
4052
+ inspectQueuedReceipt(expected) {
4053
+ return runMutationCore(readCurrent => {
4054
+ if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
4055
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
4056
+ }
4057
+ const queueOwner = validateQueuedReceiptObservation(expected, path);
4058
+ const current = readCurrent();
4059
+ return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
4060
+ });
4061
+ },
4062
+ inspectSourceCompletion(expected) {
4063
+ return runMutation(readCurrent => {
4064
+ if (!sourceAccess)
4065
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
4066
+ const completion = validateJournalSourceCompletion(expected, path);
4067
+ const current = readCurrent();
4068
+ return inspectJournalSourceCompletion(current.file, completion, path);
3221
4069
  });
3222
4070
  },
3223
4071
  };
4072
+ const abandonRoutingPending = journal.abandonPending.bind(journal);
4073
+ if (isTelegramUpdateJournalLegacyFamilyVersion(version))
4074
+ journal.routingInputs = {
4075
+ arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
4076
+ select(input) { return mutateRoutingInputs(input, true); },
4077
+ expire(input) {
4078
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
4079
+ if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
4080
+ !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
4081
+ throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
4082
+ }
4083
+ const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
4084
+ if (!current())
4085
+ throw createJournalError("conflict", path, "routing input has not expired under current authority");
4086
+ return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
4087
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
4088
+ },
4089
+ };
3224
4090
  if (!getInputContext)
3225
4091
  return { journal };
3226
4092
  // Strict source acquisition already requires this exact stored receipt scope.
@@ -3376,7 +4242,7 @@ function createJournalStoreCore(options, getInputContext) {
3376
4242
  const evidence = readTelegramUpdateJournalSource({
3377
4243
  directory: sourceAccess.directory, path, profile,
3378
4244
  botIdentity: expectedIdentity, limits: sourceAccess.limits,
3379
- version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
4245
+ version,
3380
4246
  });
3381
4247
  return evidence.kind === "present"
3382
4248
  ? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];