@llblab/pi-telegram 0.51.6 → 0.52.1

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 (178) hide show
  1. package/AGENTS.md +15 -12
  2. package/BACKLOG.md +16 -9
  3. package/CHANGELOG.md +19 -0
  4. package/LICENSE +21 -0
  5. package/README.md +17 -6
  6. package/dist/lib/activity-verbosity.d.ts +1 -5
  7. package/dist/lib/activity-verbosity.js +5 -7
  8. package/dist/lib/bindings.d.ts +5 -1
  9. package/dist/lib/bindings.js +7 -1
  10. package/dist/lib/bus-api.js +8 -5
  11. package/dist/lib/bus-follower.d.ts +54 -56
  12. package/dist/lib/bus-follower.js +206 -139
  13. package/dist/lib/bus-leader.d.ts +21 -2
  14. package/dist/lib/bus-leader.js +181 -25
  15. package/dist/lib/bus-transport.d.ts +4 -1
  16. package/dist/lib/bus-transport.js +28 -4
  17. package/dist/lib/bus.d.ts +52 -56
  18. package/dist/lib/bus.js +78 -246
  19. package/dist/lib/channel-posts.d.ts +1 -3
  20. package/dist/lib/channel-posts.js +23 -26
  21. package/dist/lib/command-templates.d.ts +0 -5
  22. package/dist/lib/command-templates.js +5 -5
  23. package/dist/lib/commands.d.ts +0 -23
  24. package/dist/lib/commands.js +15 -15
  25. package/dist/lib/config.d.ts +0 -17
  26. package/dist/lib/config.js +12 -14
  27. package/dist/lib/delivery.d.ts +0 -4
  28. package/dist/lib/delivery.js +2 -5
  29. package/dist/lib/extension.js +258 -61
  30. package/dist/lib/generative-apps.d.ts +0 -19
  31. package/dist/lib/inbound.d.ts +0 -2
  32. package/dist/lib/inbound.js +2 -2
  33. package/dist/lib/journal.d.ts +183 -16
  34. package/dist/lib/journal.js +1175 -248
  35. package/dist/lib/keyboard.d.ts +0 -2
  36. package/dist/lib/keyboard.js +2 -2
  37. package/dist/lib/lifecycle.js +1 -1
  38. package/dist/lib/locks.d.ts +100 -12
  39. package/dist/lib/locks.js +380 -14
  40. package/dist/lib/logging.d.ts +30 -12
  41. package/dist/lib/logging.js +129 -72
  42. package/dist/lib/media.d.ts +28 -5
  43. package/dist/lib/media.js +26 -8
  44. package/dist/lib/menu-model.d.ts +0 -11
  45. package/dist/lib/menu-model.js +8 -8
  46. package/dist/lib/menu-settings.d.ts +0 -18
  47. package/dist/lib/menu-settings.js +18 -18
  48. package/dist/lib/menu-status.d.ts +4 -4
  49. package/dist/lib/menu-status.js +12 -7
  50. package/dist/lib/menu-thinking.d.ts +1 -1
  51. package/dist/lib/menu-thinking.js +1 -1
  52. package/dist/lib/menu.d.ts +1 -0
  53. package/dist/lib/menu.js +4 -3
  54. package/dist/lib/outbound-attachments.d.ts +0 -1
  55. package/dist/lib/outbound-attachments.js +1 -1
  56. package/dist/lib/outbound-buttons.js +1 -8
  57. package/dist/lib/outbound-markup.d.ts +1 -6
  58. package/dist/lib/outbound-markup.js +4 -21
  59. package/dist/lib/outbound.d.ts +0 -8
  60. package/dist/lib/outbound.js +5 -5
  61. package/dist/lib/paths.d.ts +50 -7
  62. package/dist/lib/paths.js +155 -17
  63. package/dist/lib/pi.d.ts +1 -0
  64. package/dist/lib/pi.js +2 -1
  65. package/dist/lib/polling.d.ts +0 -5
  66. package/dist/lib/polling.js +5 -5
  67. package/dist/lib/preview.d.ts +0 -3
  68. package/dist/lib/preview.js +3 -3
  69. package/dist/lib/prompt-templates.d.ts +0 -1
  70. package/dist/lib/prompt-templates.js +1 -1
  71. package/dist/lib/queue.d.ts +5 -15
  72. package/dist/lib/queue.js +94 -11
  73. package/dist/lib/recovery.d.ts +29 -9
  74. package/dist/lib/recovery.js +104 -33
  75. package/dist/lib/replies.d.ts +0 -4
  76. package/dist/lib/replies.js +1 -1
  77. package/dist/lib/routing.d.ts +74 -8
  78. package/dist/lib/routing.js +2280 -307
  79. package/dist/lib/sections.d.ts +0 -2
  80. package/dist/lib/sections.js +1 -1
  81. package/dist/lib/status.d.ts +44 -9
  82. package/dist/lib/status.js +149 -27
  83. package/dist/lib/sync.d.ts +0 -5
  84. package/dist/lib/sync.js +5 -4
  85. package/dist/lib/telegram-api.d.ts +13 -3
  86. package/dist/lib/telegram-api.js +38 -23
  87. package/dist/lib/thread-cleanup-manager.d.ts +1 -0
  88. package/dist/lib/thread-cleanup-manager.js +18 -21
  89. package/dist/lib/thread-naming.d.ts +32 -5
  90. package/dist/lib/thread-naming.js +191 -7
  91. package/dist/lib/thread-reconciler.d.ts +2 -0
  92. package/dist/lib/thread-reconciler.js +1 -1
  93. package/dist/lib/threads.d.ts +293 -59
  94. package/dist/lib/threads.js +1689 -369
  95. package/dist/lib/turns.d.ts +0 -1
  96. package/dist/lib/turns.js +1 -1
  97. package/dist/lib/updates.d.ts +191 -28
  98. package/dist/lib/updates.js +1048 -114
  99. package/dist/lib/wire.d.ts +12 -0
  100. package/dist/lib/wire.js +21 -0
  101. package/dist/lib/workspace-admission.d.ts +25 -17
  102. package/dist/lib/workspace-admission.js +95 -20
  103. package/dist/lib/workspace-identity.d.ts +20 -0
  104. package/dist/lib/workspace-identity.js +103 -0
  105. package/dist/lib/workspace-retirement.d.ts +43 -8
  106. package/dist/lib/workspace-retirement.js +148 -52
  107. package/dist/package.json +6 -6
  108. package/dist/skills/generated-control-surface/SKILL.md +3 -3
  109. package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  110. package/docs/README.md +1 -1
  111. package/docs/activity.md +1 -1
  112. package/docs/architecture.md +156 -36
  113. package/docs/callback-namespaces.md +1 -1
  114. package/docs/delivery.md +1 -1
  115. package/docs/generative-apps.md +2 -2
  116. package/docs/inbound.md +1 -1
  117. package/docs/multi-instance-bus.md +125 -19
  118. package/docs/public-api.md +3 -1
  119. package/docs/ui-style.md +23 -9
  120. package/docs/updates.md +1 -1
  121. package/lib/activity-verbosity.ts +5 -9
  122. package/lib/bindings.ts +7 -0
  123. package/lib/bus-api.ts +9 -4
  124. package/lib/bus-follower.ts +243 -224
  125. package/lib/bus-leader.ts +178 -31
  126. package/lib/bus-transport.ts +30 -8
  127. package/lib/bus.ts +110 -334
  128. package/lib/channel-posts.ts +29 -29
  129. package/lib/command-templates.ts +5 -5
  130. package/lib/commands.ts +15 -15
  131. package/lib/config.ts +12 -17
  132. package/lib/delivery.ts +2 -8
  133. package/lib/extension.ts +261 -72
  134. package/lib/generative-apps.ts +0 -22
  135. package/lib/inbound.ts +2 -2
  136. package/lib/journal.ts +1250 -325
  137. package/lib/keyboard.ts +2 -2
  138. package/lib/lifecycle.ts +1 -1
  139. package/lib/locks.ts +402 -23
  140. package/lib/logging.ts +151 -101
  141. package/lib/media.ts +51 -9
  142. package/lib/menu-model.ts +8 -8
  143. package/lib/menu-settings.ts +18 -18
  144. package/lib/menu-status.ts +11 -0
  145. package/lib/menu-thinking.ts +2 -2
  146. package/lib/menu.ts +5 -1
  147. package/lib/outbound-attachments.ts +1 -1
  148. package/lib/outbound-buttons.ts +1 -10
  149. package/lib/outbound-markup.ts +5 -24
  150. package/lib/outbound.ts +5 -5
  151. package/lib/paths.ts +177 -17
  152. package/lib/pi.ts +5 -2
  153. package/lib/polling.ts +5 -5
  154. package/lib/preview.ts +3 -3
  155. package/lib/prompt-templates.ts +1 -1
  156. package/lib/queue.ts +93 -12
  157. package/lib/recovery.ts +104 -44
  158. package/lib/replies.ts +1 -1
  159. package/lib/routing.ts +2010 -375
  160. package/lib/sections.ts +1 -1
  161. package/lib/status.ts +156 -36
  162. package/lib/sync.ts +4 -4
  163. package/lib/telegram-api.ts +53 -33
  164. package/lib/thread-cleanup-manager.ts +24 -21
  165. package/lib/thread-naming.ts +267 -9
  166. package/lib/thread-reconciler.ts +3 -1
  167. package/lib/threads.ts +1736 -488
  168. package/lib/turns.ts +1 -1
  169. package/lib/updates.ts +1094 -151
  170. package/lib/wire.ts +28 -0
  171. package/lib/workspace-admission.ts +97 -47
  172. package/lib/workspace-identity.ts +147 -0
  173. package/lib/workspace-retirement.ts +188 -89
  174. package/package.json +6 -6
  175. package/scripts/audit-exports.mjs +100 -0
  176. package/scripts/check-downgrade.mjs +80 -43
  177. package/skills/generated-control-surface/SKILL.md +3 -3
  178. package/skills/telegram-bridge/references/diagnosis.md +3 -3
@@ -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,37 @@ export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
194
281
  evidenceSha256: expected.evidenceSha256, action: value.action,
195
282
  operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
196
283
  }
197
- function createJournalError(code, path, detail, cause) {
198
- return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
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") };
199
287
  }
200
- function isRecord(value) {
201
- return !!value && typeof value === "object" && !Array.isArray(value);
288
+ function inspectRoutingInputExpiry(file, journalBindingKey, updateId) {
289
+ const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
290
+ "dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
291
+ value.failureId === `routing-expiry:${value.evidenceSha256}`);
292
+ if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId))
293
+ return undefined;
294
+ return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
202
295
  }
203
- function hasOnlyKeys(value, allowedKeys) {
204
- const allowed = new Set(allowedKeys);
205
- return Object.keys(value).every((key) => allowed.has(key));
296
+ function inspectRoutingGroupExpiry(file, journalBindingKey, updateIds) {
297
+ if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
298
+ updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1])))
299
+ return undefined;
300
+ if (file.entries.some(entry => updateIds.includes(entry.updateId)))
301
+ return undefined;
302
+ const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
303
+ if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0].operatorAuthorityId))
304
+ return undefined;
305
+ // Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
306
+ // not recipient custody; fresh Thread protection still independently gates deletion.
307
+ return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0].operatorAuthorityId }));
206
308
  }
207
- function isSafeNonNegativeInteger(value) {
208
- return Number.isSafeInteger(value) && value >= 0;
309
+ function createJournalError(code, path, detail, cause) {
310
+ return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
209
311
  }
210
312
  function isSafePositiveInteger(value) {
211
313
  return Number.isSafeInteger(value) && value > 0;
212
314
  }
213
- function isNonEmptyString(value) {
214
- return typeof value === "string" && value.length > 0;
215
- }
216
315
  function isBoundedString(value, maxLength) {
217
316
  return isNonEmptyString(value) && value.length <= maxLength;
218
317
  }
@@ -433,8 +532,14 @@ function validateJournalOperatorDisposition(value, path) {
433
532
  terminalReason: value.terminalReason };
434
533
  }
435
534
  function journalRequiresExclusion(version) {
436
- return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
437
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
535
+ return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
536
+ }
537
+ function journalHasCustodyFields(version) {
538
+ return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
539
+ }
540
+ /** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
541
+ export function isTelegramUpdateJournalLegacyFamilyVersion(version) {
542
+ return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
438
543
  }
439
544
  function validateJournalInputClaim(value, path, updateId) {
440
545
  if (!isRecord(value) ||
@@ -494,6 +599,7 @@ function validateJournalEntry(value, path, version) {
494
599
  "updateId",
495
600
  "update",
496
601
  "admittedAtMs",
602
+ ...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
497
603
  "state",
498
604
  "queueKind",
499
605
  "queueReceiptId",
@@ -505,7 +611,7 @@ function validateJournalEntry(value, path, version) {
505
611
  "terminalReason",
506
612
  "terminalFailureId",
507
613
  ...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
508
- ...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
614
+ ...(journalHasCustodyFields(version)
509
615
  ? ["inputClaim", "inputProvenance"] : []),
510
616
  ]) ||
511
617
  !isSafeNonNegativeInteger(value.updateId) ||
@@ -520,6 +626,20 @@ function validateJournalEntry(value, path, version) {
520
626
  if (update.update_id !== value.updateId) {
521
627
  throw createJournalError("invalid", path, "contains an entry/update id mismatch");
522
628
  }
629
+ let routingInput;
630
+ if (value.routingInput !== undefined) {
631
+ const routing = value.routingInput;
632
+ if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
633
+ !isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
634
+ routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
635
+ routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
636
+ (routing.phase !== "waiting" && routing.phase !== "selected") ||
637
+ (routing.phase === "waiting" && value.state === "queued")) {
638
+ throw createJournalError("invalid", path, "contains invalid routing input lifetime");
639
+ }
640
+ routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
641
+ expiresAtMs: routing.expiresAtMs, phase: routing.phase };
642
+ }
523
643
  const queueKind = value.queueKind;
524
644
  const queueReceiptId = value.queueReceiptId;
525
645
  const hasQueueMetadata = queueKind !== undefined ||
@@ -595,6 +715,7 @@ function validateJournalEntry(value, path, version) {
595
715
  ...(inputClaim ? { inputClaim } : {}),
596
716
  ...(inputProvenance ? { inputProvenance } : {}),
597
717
  admittedAtMs: value.admittedAtMs,
718
+ ...(routingInput ? { routingInput } : {}),
598
719
  state: value.state,
599
720
  ...(queueKind === "prompt" || queueKind === "control"
600
721
  ? { queueKind }
@@ -634,6 +755,29 @@ function assertSupportedJournalVersion(value, path, version = TELEGRAM_UPDATE_JO
634
755
  throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
635
756
  }
636
757
  }
758
+ function validateJournalSourceCompletion(value, path) {
759
+ if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
760
+ !isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
761
+ typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
762
+ throw createJournalError("invalid", path, "has invalid source completion evidence");
763
+ }
764
+ return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
765
+ }
766
+ function validateJournalSourceCompletions(value, path, version, allowEmpty = false) {
767
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
768
+ throw createJournalError("invalid", path, "has invalid source completion collection");
769
+ }
770
+ const completions = value.map(item => validateJournalSourceCompletion(item, path));
771
+ const scopes = new Set();
772
+ for (let index = 0; index < completions.length; index++) {
773
+ const completion = completions[index];
774
+ if ((index > 0 && completions[index - 1].updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
775
+ throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
776
+ }
777
+ scopes.add(completion.completionSha256);
778
+ }
779
+ return completions;
780
+ }
637
781
  function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
638
782
  assertSupportedJournalVersion(value, path, version);
639
783
  if (journalRequiresExclusion(version) &&
@@ -649,6 +793,7 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
649
793
  "botIdentity",
650
794
  "entries",
651
795
  "operatorDispositions",
796
+ "sourceCompletions",
652
797
  ]) ||
653
798
  !Number.isSafeInteger(value.version) ||
654
799
  (value.revision !== undefined &&
@@ -707,6 +852,14 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
707
852
  }
708
853
  }
709
854
  const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
855
+ const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
856
+ const botIdentity = validateBotIdentity(value.botIdentity, path);
857
+ const activeIds = new Set(entries.map(entry => entry.updateId));
858
+ const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
859
+ if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
860
+ value.acceptedThroughUpdateId !== undefined && completion.updateId > value.acceptedThroughUpdateId)) {
861
+ throw createJournalError("invalid", path, "has contradictory source completion evidence");
862
+ }
710
863
  const dispositionFailureIds = new Set();
711
864
  const entriesByUpdateId = new Map(entries.map((entry) => [entry.updateId, entry]));
712
865
  for (const disposition of operatorDispositions) {
@@ -729,10 +882,64 @@ function parseJournalFile(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION
729
882
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
730
883
  : {}),
731
884
  profile: value.profile,
732
- botIdentity: validateBotIdentity(value.botIdentity, path),
885
+ botIdentity,
733
886
  entries,
734
887
  ...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
888
+ ...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
889
+ };
890
+ }
891
+ function assertJournalRoutingInputContinuity(file, entries, path) {
892
+ const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
893
+ for (const entry of entries) {
894
+ const retained = previous.get(entry.updateId), next = entry.routingInput;
895
+ if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
896
+ next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
897
+ throw createJournalError("invalid", path, "regresses retained routing input lifetime");
898
+ }
899
+ }
900
+ }
901
+ function assertJournalSourceCompletionContinuity(file, segment, path) {
902
+ assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
903
+ if (segment.sourceCompletions === undefined)
904
+ return;
905
+ const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
906
+ const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
907
+ if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
908
+ throw createJournalError("invalid", path, "regresses retained source completion evidence");
909
+ }
910
+ const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
911
+ const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
912
+ const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
913
+ // Grouped markers share one whole-receipt check rather than rescanning the group per source.
914
+ const queueGroups = new Map(), queueRemovals = new Map();
915
+ for (const entry of file.entries) {
916
+ if (!entry.queueReceiptId)
917
+ continue;
918
+ const group = queueGroups.get(entry.queueReceiptId) ?? [];
919
+ group.push(entry);
920
+ queueGroups.set(entry.queueReceiptId, group);
921
+ }
922
+ const queueWasRemoved = (source) => {
923
+ const receiptId = source.queueReceiptId;
924
+ if (!receiptId || !source.queueOwner)
925
+ return false;
926
+ if (queueRemovals.has(receiptId))
927
+ return queueRemovals.get(receiptId);
928
+ const group = queueGroups.get(receiptId);
929
+ 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 &&
930
+ entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner));
931
+ queueRemovals.set(receiptId, valid);
932
+ return valid;
735
933
  };
934
+ for (const completion of segment.sourceCompletions) {
935
+ if (previous.has(completion.updateId))
936
+ continue;
937
+ const source = entries.get(completion.updateId);
938
+ if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
939
+ createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
940
+ throw createJournalError("invalid", path, "source completion does not match its removal revision");
941
+ }
942
+ }
736
943
  }
737
944
  function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERSION) {
738
945
  assertSupportedJournalVersion(value, path, version);
@@ -751,6 +958,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
751
958
  "upsertedEntries",
752
959
  "removedUpdateIds",
753
960
  "operatorDispositions",
961
+ "sourceCompletions",
754
962
  ]) ||
755
963
  value.version !== version ||
756
964
  !isSafePositiveInteger(value.revision) ||
@@ -790,6 +998,7 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
790
998
  removedUpdateIds.push(updateId);
791
999
  }
792
1000
  const operatorDispositions = (value.operatorDispositions ?? []).map((disposition) => validateJournalOperatorDisposition(disposition, path));
1001
+ const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
793
1002
  return {
794
1003
  version,
795
1004
  revision: value.revision,
@@ -798,12 +1007,13 @@ function parseJournalSegment(value, path, version = TELEGRAM_UPDATE_JOURNAL_VERS
798
1007
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId }
799
1008
  : {}),
800
1009
  profile: value.profile,
801
- botIdentity: validateBotIdentity(value.botIdentity, path),
1010
+ botIdentity: segmentBotIdentity,
802
1011
  upsertedEntries,
803
1012
  removedUpdateIds,
804
1013
  ...(value.operatorDispositions !== undefined
805
1014
  ? { operatorDispositions }
806
1015
  : {}),
1016
+ ...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
807
1017
  };
808
1018
  }
809
1019
  /**
@@ -833,6 +1043,11 @@ export function inspectTelegramInputCustodySourceStatus(input) {
833
1043
  export function inspectTelegramUpdateJournalFamily(input) {
834
1044
  return acquireTelegramUpdateJournalFamily(input).evidence;
835
1045
  }
1046
+ /** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
1047
+ export function inspectTelegramUpdateJournalRetention(input) {
1048
+ const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
1049
+ return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
1050
+ }
836
1051
  /**
837
1052
  * Read-only source evidence for cooperating writers serialized by the caller through
838
1053
  * consumption; never readiness, recovery, or permission to publish. Ancestors retain
@@ -845,7 +1060,7 @@ export function inspectTelegramUpdateJournalFamily(input) {
845
1060
  */
846
1061
  export function readTelegramUpdateJournalSource(input) {
847
1062
  const version = input.version;
848
- if (version !== 1 && version !== 2 && version !== 3) {
1063
+ if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
849
1064
  throw createJournalError("unsupported-version", input.path, "requires a selected source version");
850
1065
  }
851
1066
  return acquireTelegramUpdateJournalFamily({
@@ -854,7 +1069,7 @@ export function readTelegramUpdateJournalSource(input) {
854
1069
  limits: { ...input.limits },
855
1070
  }, version).evidence;
856
1071
  }
857
- function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1072
+ function acquireTelegramUpdateJournalFamily(input, selectedVersion, inspectRetention = false) {
858
1073
  const { path, limits } = input;
859
1074
  const acquiredSegments = [];
860
1075
  let snapshotRevision = 0;
@@ -906,7 +1121,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
906
1121
  const charge = (value) => {
907
1122
  if (!isRecord(value))
908
1123
  return;
909
- for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
1124
+ for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
910
1125
  const collection = value[key];
911
1126
  if (!Array.isArray(collection))
912
1127
  continue;
@@ -948,18 +1163,18 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
948
1163
  }
949
1164
  };
950
1165
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
951
- const enumerate = () => {
1166
+ const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files) => {
952
1167
  const names = [];
953
- const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
1168
+ const directory = opendirSync(target, { bufferSize: 1 });
954
1169
  try {
955
1170
  for (;;) {
956
1171
  const entry = directory.readSync();
957
1172
  if (!entry)
958
1173
  break;
959
- if (names.length >= limits.maxFiles - files)
1174
+ if (names.length >= limits.maxFiles - usedFiles)
960
1175
  capacity();
961
- if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
962
- fail("has an unexpected segment entry");
1176
+ if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
1177
+ fail("has an unexpected journal evidence entry");
963
1178
  names.push(entry.name);
964
1179
  }
965
1180
  }
@@ -1027,6 +1242,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1027
1242
  if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
1028
1243
  segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId)
1029
1244
  fail("regresses the admission cursor");
1245
+ assertJournalSourceCompletionContinuity(file, segment, target);
1030
1246
  const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1031
1247
  for (const id of segment.removedUpdateIds)
1032
1248
  entries.delete(id);
@@ -1046,16 +1262,70 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1046
1262
  ...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
1047
1263
  entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
1048
1264
  operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
1265
+ sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
1049
1266
  };
1050
1267
  charge(next);
1051
1268
  file = parseJournalFile(next, target, file.version);
1052
1269
  }
1053
1270
  }
1271
+ const retainedInputs = [];
1272
+ const retentionDirectory = `${path}.retained`;
1273
+ const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
1274
+ const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
1275
+ const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
1276
+ if (retentionNames.length > limits.maxEntries)
1277
+ capacity();
1278
+ if (retention) {
1279
+ if (!file || file.version !== 1)
1280
+ fail("private retention requires its exact v1 journal snapshot");
1281
+ const current = file;
1282
+ const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity => createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
1283
+ const updateIds = new Set();
1284
+ for (const name of retentionNames) {
1285
+ const target = join(retentionDirectory, name);
1286
+ const before = observe(target, false);
1287
+ if (before.nlink !== 1n)
1288
+ fail("has linked private retention evidence");
1289
+ const raw = read(target, before);
1290
+ files += 1;
1291
+ if (limits.maxWork - work < 2)
1292
+ capacity();
1293
+ work += 2;
1294
+ if (!isRecord(raw))
1295
+ throw createJournalError("invalid", target, "private retention is not an evidence record");
1296
+ if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
1297
+ throw createJournalError("invalid", target, "has foreign private retention evidence");
1298
+ const entry = validateJournalEntry(raw.entry, target, 1);
1299
+ const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
1300
+ const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
1301
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
1302
+ disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
1303
+ disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
1304
+ name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
1305
+ journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition }))
1306
+ fail("has inconsistent private retention evidence");
1307
+ if (updateIds.has(entry.updateId))
1308
+ fail("has duplicate private retention sources");
1309
+ updateIds.add(entry.updateId);
1310
+ const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
1311
+ if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
1312
+ fail("private retention contradicts its journal disposition");
1313
+ retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
1314
+ failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
1315
+ }
1316
+ }
1317
+ if (inspectRetention && file?.operatorDispositions?.some(disposition => "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
1318
+ disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
1319
+ !retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
1320
+ fail("committed abandonment lost its private original");
1054
1321
  // Re-enumeration has the same bound, rather than allocating an unbounded census.
1055
1322
  files = snapshot ? 1 : 0;
1056
1323
  if (segments && !isDeepStrictEqual(names, enumerate()))
1057
1324
  fail("changed segment namespace");
1058
1325
  files += names.length;
1326
+ if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern)))
1327
+ fail("changed private retention namespace");
1328
+ files += retentionNames.length;
1059
1329
  for (const [target, before] of observed) {
1060
1330
  const after = status(target);
1061
1331
  const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
@@ -1079,6 +1349,7 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1079
1349
  evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
1080
1350
  snapshotRevision,
1081
1351
  segments: acquiredSegments,
1352
+ ...(inspectRetention ? { retainedInputs } : {}),
1082
1353
  };
1083
1354
  }
1084
1355
  catch (error) {
@@ -1087,13 +1358,14 @@ function acquireTelegramUpdateJournalFamily(input, selectedVersion) {
1087
1358
  throw createJournalError("io", path, "could not establish strict journal evidence", error);
1088
1359
  }
1089
1360
  }
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
1361
  export function inspectTelegramProfileJournalNamespace(input) {
1362
+ return inspectTelegramJournalNamespace(input, false);
1363
+ }
1364
+ /** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
1365
+ export function inspectTelegramSessionJournalNamespace(input) {
1366
+ return inspectTelegramJournalNamespace(input, true);
1367
+ }
1368
+ function inspectTelegramJournalNamespace(input, includeSessions) {
1097
1369
  const { directory, profile, limits } = input;
1098
1370
  const fail = (message) => { throw createJournalError("invalid", directory, message); };
1099
1371
  const capacity = () => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
@@ -1114,7 +1386,15 @@ export function inspectTelegramProfileJournalNamespace(input) {
1114
1386
  size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
1115
1387
  };
1116
1388
  const suffix = profile === "default" ? "" : `.${profile}`;
1117
- const polling = `inbox${suffix}.json`;
1389
+ const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
1390
+ const polling = relative(directory, pollingPath);
1391
+ {
1392
+ const parts = polling.split(sep);
1393
+ if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
1394
+ !(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
1395
+ decodeTelegramSessionDirectoryName(parts[1]) !== undefined)))
1396
+ fail("requires a canonical polling journal path");
1397
+ }
1118
1398
  try {
1119
1399
  const root = lstatSync(directory);
1120
1400
  if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory)
@@ -1122,51 +1402,106 @@ export function inspectTelegramProfileJournalNamespace(input) {
1122
1402
  const beforeRoot = metadata(directory);
1123
1403
  const census = () => {
1124
1404
  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, ""));
1405
+ const families = new Map(), profiles = new Set();
1406
+ const scan = (parent, visit) => {
1407
+ const handle = opendirSync(parent, { bufferSize: 1 });
1408
+ try {
1409
+ for (;;) {
1410
+ const entry = handle.readSync();
1411
+ if (!entry)
1412
+ break;
1413
+ if (entries.size >= limits.maxDirectoryEntries)
1414
+ capacity();
1415
+ const target = join(parent, entry.name);
1416
+ const key = relative(directory, target);
1417
+ if (entries.has(key))
1418
+ fail("repeated an observed namespace entry");
1419
+ entries.set(key, metadata(target));
1420
+ visit(entry.name, target);
1421
+ }
1150
1422
  }
1151
- }
1152
- finally {
1153
- handle.closeSync();
1154
- }
1155
- return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
1423
+ finally {
1424
+ handle.closeSync();
1425
+ }
1426
+ };
1427
+ const requireType = (target, isDirectory) => {
1428
+ const value = lstatSync(target);
1429
+ if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
1430
+ (!isDirectory && value.nlink !== 1))
1431
+ fail("has a linked or unexpected journal type");
1432
+ };
1433
+ const evidenceDirectory = (target, retained) => scan(target, (name, path) => {
1434
+ if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name))
1435
+ fail("has an unclassified journal evidence entry");
1436
+ requireType(path, false);
1437
+ });
1438
+ const session = (target) => scan(target, (name, path) => {
1439
+ if (name === "recovery")
1440
+ return;
1441
+ const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
1442
+ if (!match || match[1] === "default")
1443
+ fail("has an unclassified session journal entry");
1444
+ requireType(path, !!match[2]);
1445
+ if (match[2])
1446
+ evidenceDirectory(path, match[2] === ".retained");
1447
+ const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[1] ?? "default";
1448
+ profiles.add(familyProfile);
1449
+ if (familyProfile === profile && base !== pollingPath)
1450
+ families.set(base, "session");
1451
+ });
1452
+ scan(directory, (name, target) => {
1453
+ // Legacy quarantine folders are disposable housekeeping, never journal authority.
1454
+ if (name.toLowerCase() === "recovery")
1455
+ return;
1456
+ if (name.toLowerCase() === "sessions") {
1457
+ if (!includeSessions)
1458
+ fail("requires session-qualified namespace inspection");
1459
+ if (name !== "sessions")
1460
+ fail("has a noncanonical session root");
1461
+ requireType(target, true);
1462
+ scan(target, (sessionName, sessionPath) => {
1463
+ if (decodeTelegramSessionDirectoryName(sessionName) === undefined)
1464
+ fail("has a noncanonical session directory");
1465
+ requireType(sessionPath, true);
1466
+ session(sessionPath);
1467
+ });
1468
+ return;
1469
+ }
1470
+ if (includeSessions && /^journal\./iu.test(name))
1471
+ fail("has a misplaced session journal");
1472
+ if (!/inbox/iu.test(name))
1473
+ return;
1474
+ const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
1475
+ if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained"))
1476
+ fail("has a noncanonical journal-like entry");
1477
+ requireType(target, !!match[3]);
1478
+ if (includeSessions && match[3])
1479
+ evidenceDirectory(target, match[3] === ".retained");
1480
+ const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match[2] ?? "default";
1481
+ profiles.add(familyProfile);
1482
+ if (familyProfile === profile && base !== pollingPath)
1483
+ families.set(base, match[1] === "inbox" ? "session" : "follower");
1484
+ });
1485
+ return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
1486
+ families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
1156
1487
  };
1157
1488
  const before = census();
1489
+ const sourceInventory = [[pollingPath, "polling"], ...before.families];
1158
1490
  const sources = [];
1491
+ const retainedInputs = [];
1159
1492
  const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
1160
1493
  let knownBotId = expected.botId;
1161
- for (const name of [polling, ...before.followers]) {
1494
+ for (const [path, role] of sourceInventory) {
1162
1495
  const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
1163
1496
  maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
1164
1497
  if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0)
1165
1498
  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`))) {
1499
+ const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
1500
+ botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
1501
+ const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
1502
+ const evidence = acquired.evidence;
1503
+ retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
1504
+ if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
1170
1505
  fail("lost a discovered journal family");
1171
1506
  }
1172
1507
  if (evidence.kind === "present") {
@@ -1175,11 +1510,20 @@ export function inspectTelegramProfileJournalNamespace(input) {
1175
1510
  accounting.bytes += evidence.accounting.bytes;
1176
1511
  }
1177
1512
  accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
1178
- sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
1513
+ sources.push({ role, path, evidence });
1179
1514
  }
1180
- if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory)))
1515
+ const namespaceCurrent = () => {
1516
+ try {
1517
+ return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
1518
+ isDeepStrictEqual(beforeRoot, metadata(directory));
1519
+ }
1520
+ catch {
1521
+ return false;
1522
+ }
1523
+ };
1524
+ if (!namespaceCurrent())
1181
1525
  fail("changed observed root namespace or identity");
1182
- return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
1526
+ return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
1183
1527
  }
1184
1528
  catch (error) {
1185
1529
  if (error instanceof TelegramUpdateJournalError)
@@ -1187,6 +1531,28 @@ export function inspectTelegramProfileJournalNamespace(input) {
1187
1531
  throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
1188
1532
  }
1189
1533
  }
1534
+ /** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
1535
+ export function isTelegramThreadCleanupJournalNamespaceClear(input) {
1536
+ if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
1537
+ throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
1538
+ const requiredPaths = new Set();
1539
+ for (const key of input.requiredJournalBindingKeys) {
1540
+ const path = getTelegramUpdateJournalBindingPath(key);
1541
+ const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
1542
+ if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
1543
+ path, profileName: input.profile, botIdentity,
1544
+ })))
1545
+ throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
1546
+ requiredPaths.add(path);
1547
+ }
1548
+ const namespace = inspectTelegramSessionJournalNamespace(input);
1549
+ if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
1550
+ namespace.retainedInputs?.some(original => original.state !== "committed") ||
1551
+ [...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
1552
+ return false;
1553
+ // Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
1554
+ return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
1555
+ }
1190
1556
  function cloneEntry(entry) {
1191
1557
  return {
1192
1558
  ...entry,
@@ -1201,6 +1567,7 @@ function cloneEntry(entry) {
1201
1567
  ? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
1202
1568
  : {}),
1203
1569
  ...(entry.failure ? { failure: { ...entry.failure } } : {}),
1570
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
1204
1571
  };
1205
1572
  }
1206
1573
  function cloneFile(file) {
@@ -1213,6 +1580,7 @@ function cloneFile(file) {
1213
1580
  profile: file.profile,
1214
1581
  botIdentity: { ...file.botIdentity },
1215
1582
  entries: file.entries.map(cloneEntry),
1583
+ ...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
1216
1584
  ...(file.operatorDispositions?.length
1217
1585
  ? {
1218
1586
  operatorDispositions: file.operatorDispositions.map((disposition) => ({ ...disposition })),
@@ -1312,6 +1680,53 @@ function mergeBotIdentity(stored, current) {
1312
1680
  tokenSha256: current.tokenSha256,
1313
1681
  };
1314
1682
  }
1683
+ function createPendingRetentionReference(input) {
1684
+ const { path, journalBindingKey, entry, maxBytes } = input;
1685
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
1686
+ const failureId = `abandon-${evidenceSha256}`;
1687
+ const retainedDirectory = `${path}.retained`;
1688
+ const retainedPath = join(retainedDirectory, `${failureId}.json`);
1689
+ const read = () => {
1690
+ let directory;
1691
+ try {
1692
+ directory = lstatSync(retainedDirectory);
1693
+ }
1694
+ catch (error) {
1695
+ if (error.code === "ENOENT")
1696
+ return undefined;
1697
+ throw error;
1698
+ }
1699
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
1700
+ throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
1701
+ }
1702
+ let stat;
1703
+ try {
1704
+ stat = lstatSync(retainedPath);
1705
+ }
1706
+ catch (error) {
1707
+ if (error.code === "ENOENT")
1708
+ return undefined;
1709
+ throw error;
1710
+ }
1711
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
1712
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
1713
+ }
1714
+ const retained = JSON.parse(readFileSync(retainedPath, "utf8"));
1715
+ if (!isRecord(retained))
1716
+ throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
1717
+ const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
1718
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
1719
+ disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
1720
+ disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
1721
+ !isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
1722
+ entry, requestedDisposition: disposition })) {
1723
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
1724
+ }
1725
+ return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
1726
+ requestedDisposition: disposition };
1727
+ };
1728
+ return { evidenceSha256, failureId, retainedPath, read };
1729
+ }
1315
1730
  function writeJournalFile(path, serialized, onPublicationBoundary, stagingPath = path) {
1316
1731
  const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
1317
1732
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -1432,6 +1847,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
1432
1847
  });
1433
1848
  const path = deps.getJournalPath(configuredProfileName);
1434
1849
  const workspaceAdmission = deps.getWorkspaceAdmission?.();
1850
+ const sourceAccess = { directory: dirname(path), limits: {
1851
+ maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
1852
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
1853
+ } };
1854
+ const options = { path, profileName, botIdentity,
1855
+ ...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
1856
+ ...(workspaceAdmission ? { workspaceAdmission } : {}),
1857
+ ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
1858
+ ...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
1859
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
1860
+ const journal = createTelegramUpdateJournalStore(options);
1861
+ // Scoped completion and receipt observation use strict private handles where the platform provides no-follow
1862
+ // evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
1863
+ const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
1864
+ const completionJournal = deps.withSourceSerialization
1865
+ ? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
1866
+ // Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
1867
+ const observeQueuedReceipt = (expected) => strict
1868
+ ? completionJournal.inspectQueuedReceipt(expected)
1869
+ : inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
1435
1870
  return {
1436
1871
  runtimeKey: JSON.stringify({
1437
1872
  path,
@@ -1446,27 +1881,25 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
1446
1881
  readForProtection() {
1447
1882
  // Protection must never turn corruption/recovery into empty-work evidence.
1448
1883
  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
- },
1884
+ ...sourceAccess, path, profile: profileName, botIdentity,
1456
1885
  });
1457
- return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
1886
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
1458
1887
  },
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
- }),
1888
+ journal: completionJournal ? { ...journal,
1889
+ removeCompletedExact(updateIds, expectedSources, completions) {
1890
+ return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
1891
+ : journal.removeCompletedExact(updateIds, expectedSources);
1892
+ },
1893
+ completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
1894
+ inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
1895
+ inspectQueuedReceipt: observeQueuedReceipt,
1896
+ isQueueReceiptCurrent(receipt, owner) {
1897
+ if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity }))
1898
+ return false;
1899
+ return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
1900
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
1901
+ },
1902
+ } : journal,
1470
1903
  };
1471
1904
  };
1472
1905
  }
@@ -1511,42 +1944,203 @@ export function withTelegramResolvedUpdateJournalReference(input) {
1511
1944
  return input.registry.withReference({ referenceClass: input.referenceClass,
1512
1945
  recoveryKey: binding.recoveryKey }, () => input.operation(binding));
1513
1946
  }
1947
+ function validateQueuedReceiptObservation(expected, path) {
1948
+ if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
1949
+ !isNonEmptyString(expected.receiptId) ||
1950
+ !Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
1951
+ expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
1952
+ expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]))) {
1953
+ throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
1954
+ }
1955
+ return validateJournalQueueOwner(expected.queueOwner, path);
1956
+ }
1957
+ function inspectJournalQueuedReceipt(file, expected, path) {
1958
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
1959
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
1960
+ const { queueOwner } = expected;
1961
+ const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
1962
+ if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) => entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
1963
+ entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner)))
1964
+ return undefined;
1965
+ return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
1966
+ sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
1967
+ sources: entries.map(createTelegramUpdateJournalEntryDigest),
1968
+ queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
1969
+ }
1970
+ function inspectJournalSourceCompletion(file, completion, path) {
1971
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version))
1972
+ throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
1973
+ const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
1974
+ if (!retained)
1975
+ return undefined;
1976
+ if (!isDeepStrictEqual(retained, completion))
1977
+ throw createJournalError("conflict", path, "source completion belongs to another exact scope");
1978
+ return { ...retained };
1979
+ }
1980
+ /** Operator-distinct authority: routing never treats it as an owner cancellation. */
1981
+ export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
1982
+ function isAdoptablePendingEntry(entry) {
1983
+ return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
1984
+ !entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
1985
+ entry.preApprovalExcluded !== true;
1986
+ }
1514
1987
  export function createTelegramUpdateJournalBindingRuntime(deps) {
1515
1988
  const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
1516
1989
  ...deps.base,
1517
1990
  getJournalPath: deps.getLeaderJournalPath,
1518
1991
  });
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)();
1992
+ const createFollowerResolver = (bindingKey, includeQueueRuntimeIdentity, sessionId, legacy = false) => {
1993
+ // Session-aware composition must not fall back to a process journal before preparation supplies its ID.
1994
+ if (!legacy && deps.getActiveFollowerSessionId && !sessionId)
1995
+ return () => undefined;
1996
+ return createTelegramUpdateJournalRuntimeBindingResolver({
1997
+ getProfileName: deps.base.getProfileName,
1998
+ getBotToken: deps.base.getBotToken,
1999
+ getBotId: deps.base.getBotId,
2000
+ ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
2001
+ ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
2002
+ : {}),
2003
+ ...(deps.base.withWriterAdmission
2004
+ ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
2005
+ // Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
2006
+ ...(deps.base.withSourceSerialization
2007
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
2008
+ ...(deps.base.getWorkspaceAdmission
2009
+ ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2010
+ : {}),
2011
+ ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
2012
+ getJournalPath(profileName) {
2013
+ return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
2014
+ },
2015
+ });
2016
+ };
2017
+ const resolveFollower = () => createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
1537
2018
  const resolveActive = () => deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
1538
- return {
2019
+ const inspectHistoricalSource = (journalBindingKey, operation) => {
2020
+ const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
2021
+ const token = deps.base.getBotToken(), botId = deps.base.getBotId();
2022
+ const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
2023
+ if (!path || !token)
2024
+ return undefined;
2025
+ const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
2026
+ const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity => createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
2027
+ if (!keys.includes(journalBindingKey))
2028
+ return undefined;
2029
+ const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
2030
+ const root = getRoot();
2031
+ const parts = relative(root, path).split(sep);
2032
+ const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
2033
+ const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
2034
+ const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
2035
+ if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
2036
+ !(parts.length === 1 && flat.test(parts[0]) || parts.length === 3 && parts[0] === "sessions" &&
2037
+ decodeTelegramSessionDirectoryName(parts[1]) !== undefined && session.test(parts[2])))
2038
+ return undefined;
2039
+ const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
2040
+ (deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
2041
+ const inspect = () => {
2042
+ if (!current())
2043
+ return undefined;
2044
+ const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
2045
+ limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2046
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
2047
+ if (observed.evidence.kind !== "present" || !current())
2048
+ return undefined;
2049
+ return operation(observed.evidence.file, observed.retainedInputs, path);
2050
+ };
2051
+ const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
2052
+ return current() ? result : undefined;
2053
+ };
2054
+ let preparedFollower;
2055
+ const runtime = {
1539
2056
  resolveLeader,
1540
2057
  resolveFollower,
1541
2058
  resolveActive,
1542
2059
  getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
1543
- createRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false),
2060
+ prepareActiveFollowerSuccession(isCurrent) {
2061
+ const recipientBindingKey = deps.getActiveFollowerBindingKey();
2062
+ const sessionId = deps.getActiveFollowerSessionId?.();
2063
+ if (!sessionId)
2064
+ return undefined;
2065
+ const previous = preparedFollower;
2066
+ const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
2067
+ ? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
2068
+ successorSessionId: sessionId, isCurrent })
2069
+ : undefined;
2070
+ // Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
2071
+ preparedFollower = { recipientBindingKey, sessionId };
2072
+ return result;
2073
+ },
2074
+ inspectSourceAbandonment(journalBindingKey, updateId) {
2075
+ if (!isSafeNonNegativeInteger(updateId))
2076
+ throw new Error("Telegram abandonment proof requires an exact update ID.");
2077
+ return inspectHistoricalSource(journalBindingKey, (file, originals) => {
2078
+ const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
2079
+ value.journalBindingKey === journalBindingKey);
2080
+ if (!original)
2081
+ return undefined;
2082
+ const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
2083
+ if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody")
2084
+ return undefined;
2085
+ return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
2086
+ });
2087
+ },
2088
+ inspectSourceGroupExpiry(journalBindingKey, updateIds) {
2089
+ return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
2090
+ },
2091
+ inspectSourceCompletion(journalBindingKey, expected) {
2092
+ const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
2093
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
2094
+ },
2095
+ inspectQueuedReceipt(journalBindingKey, expected) {
2096
+ const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
2097
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
2098
+ },
2099
+ adoptPredecessorPending(input) {
2100
+ const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
2101
+ if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
2102
+ predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
2103
+ throw new Error("Telegram session adoption requires distinct exact session identities.");
2104
+ }
2105
+ const assertCurrent = () => {
2106
+ if (!isCurrent())
2107
+ throw new Error("Telegram session adoption lost successor authority.");
2108
+ };
2109
+ assertCurrent();
2110
+ const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
2111
+ const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
2112
+ if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
2113
+ throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
2114
+ }
2115
+ const result = { adoptedUpdateIds: [], retainedUpdateIds: [] };
2116
+ // Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
2117
+ const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
2118
+ .sort((left, right) => left.updateId - right.updateId);
2119
+ for (const entry of candidates) {
2120
+ assertCurrent();
2121
+ // Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
2122
+ const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
2123
+ operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
2124
+ if (committed.duplicate) {
2125
+ result.retainedUpdateIds.push(entry.updateId);
2126
+ continue;
2127
+ }
2128
+ assertCurrent();
2129
+ successor.journal.appendBatch([entry.update]);
2130
+ result.adoptedUpdateIds.push(entry.updateId);
2131
+ }
2132
+ return result;
2133
+ },
2134
+ createRecipientResolver: (bindingKey, sessionId) => createFollowerResolver(bindingKey, false, sessionId),
2135
+ createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
1544
2136
  createPathResolver: (path) => createTelegramUpdateJournalRuntimeBindingResolver({
1545
2137
  getProfileName: deps.base.getProfileName,
1546
2138
  getBotToken: deps.base.getBotToken,
1547
2139
  getBotId: deps.base.getBotId,
1548
2140
  ...(deps.base.withWriterAdmission
1549
2141
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
2142
+ ...(deps.base.withSourceSerialization
2143
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
1550
2144
  ...(deps.base.getWorkspaceAdmission
1551
2145
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
1552
2146
  : {}),
@@ -1554,6 +2148,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
1554
2148
  getJournalPath: () => path,
1555
2149
  }),
1556
2150
  };
2151
+ return runtime;
1557
2152
  }
1558
2153
  export function createTelegramUpdateJournalStore(options) {
1559
2154
  return createJournalStoreCore(options).journal;
@@ -1673,6 +2268,9 @@ function createJournalStoreCore(options, getInputContext) {
1673
2268
  if ((file.operatorDispositions?.length ?? 0) > maxEntries) {
1674
2269
  throw createJournalError("capacity", path, `exceeds its ${maxEntries}-operator-disposition limit`);
1675
2270
  }
2271
+ if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
2272
+ throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
2273
+ }
1676
2274
  const serializedBytes = Buffer.byteLength(serialized);
1677
2275
  if (serializedBytes > maxBytes) {
1678
2276
  throw createJournalError("capacity", path, `exceeds its ${maxBytes}-byte limit`);
@@ -1685,7 +2283,7 @@ function createJournalStoreCore(options, getInputContext) {
1685
2283
  botIdentity: { ...expectedIdentity },
1686
2284
  entries: [],
1687
2285
  });
1688
- const readCurrentStrict = () => {
2286
+ const readCurrentStrict = (allowReconciliation = true) => {
1689
2287
  let source;
1690
2288
  let recoveringMissingSnapshot = false;
1691
2289
  try {
@@ -1711,6 +2309,8 @@ function createJournalStoreCore(options, getInputContext) {
1711
2309
  throw createJournalError("io", segmentDirectory, "could not be read while the journal snapshot is missing", segmentError);
1712
2310
  }
1713
2311
  if (orphanedSegmentNames.length > 0) {
2312
+ if (!allowReconciliation)
2313
+ throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
1714
2314
  if (withPairingAdmission)
1715
2315
  throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
1716
2316
  recoveringMissingSnapshot = true;
@@ -1788,6 +2388,7 @@ function createJournalStoreCore(options, getInputContext) {
1788
2388
  (withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)) {
1789
2389
  throw createJournalError("identity-mismatch", segmentPath, "belongs to another journal identity");
1790
2390
  }
2391
+ assertJournalSourceCompletionContinuity(file, segment, segmentPath);
1791
2392
  const entriesById = new Map(file.entries.map((entry) => [entry.updateId, entry]));
1792
2393
  for (const updateId of segment.removedUpdateIds) {
1793
2394
  if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
@@ -1824,14 +2425,16 @@ function createJournalStoreCore(options, getInputContext) {
1824
2425
  : file.operatorDispositions?.length
1825
2426
  ? { operatorDispositions: file.operatorDispositions }
1826
2427
  : {}),
2428
+ ...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
2429
+ : file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
1827
2430
  }, segmentPath, version);
1828
2431
  revision = segment.revision;
1829
2432
  }
1830
2433
  const identityChanged = storedProfile !== profile ||
1831
2434
  !identitiesMatch(file.botIdentity, expectedIdentity) ||
1832
- (withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
2435
+ ((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
1833
2436
  if (identityChanged) {
1834
- if (file.entries.length > 0 || withPairingAdmission) {
2437
+ if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
1835
2438
  throw createJournalError("identity-mismatch", path, storedProfile !== profile
1836
2439
  ? `belongs to profile ${storedProfile}, not ${profile}`
1837
2440
  : "belongs to another Telegram bot identity");
@@ -1970,39 +2573,40 @@ function createJournalStoreCore(options, getInputContext) {
1970
2573
  // Only known schema/JSON corruption may fall through to reset.
1971
2574
  }
1972
2575
  }
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);
2576
+ // Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
2577
+ // Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
2578
+ const deletedPaths = [];
2579
+ const deleteDamaged = (target, label) => {
2580
+ try {
2581
+ lstatSync(target);
1984
2582
  }
1985
- throw createJournalError("io", segmentDirectory, "could not quarantine an unrecoverable journal segment history", error);
1986
- }
2583
+ catch (statError) {
2584
+ if (statError.code === "ENOENT")
2585
+ return;
2586
+ throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
2587
+ }
2588
+ try {
2589
+ rmSync(target, { recursive: true, force: true });
2590
+ }
2591
+ catch (removeError) {
2592
+ throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
2593
+ }
2594
+ deletedPaths.push(target);
2595
+ };
2596
+ deleteDamaged(segmentDirectory, "journal segment history");
2597
+ // A regular snapshot is replaced atomically below; anything else cannot be a rename target.
2598
+ if (snapshotExists && !lstatSync(path).isFile())
2599
+ deleteDamaged(path, "journal snapshot");
1987
2600
  const reset = emptyFile();
1988
2601
  const serialized = serializeJournalFile(reset);
1989
2602
  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
- }
2603
+ writeJournalFile(path, serialized, onPublicationBoundary);
2604
+ if (snapshotExists && !deletedPaths.includes(path))
2605
+ deletedPaths.push(path);
2002
2606
  notifyRecovery({
2003
2607
  kind: "reset",
2004
2608
  path,
2005
- quarantinePath: recoveryDirectory,
2609
+ deletedPaths,
2006
2610
  reason: error.message,
2007
2611
  });
2008
2612
  return { file: reset, exists: true, serializedBytes };
@@ -2048,7 +2652,7 @@ function createJournalStoreCore(options, getInputContext) {
2048
2652
  const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
2049
2653
  const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
2050
2654
  const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
2051
- const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0);
2655
+ const getFileWork = (file) => file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
2052
2656
  const advanceInputHeadroomState = (state, entries, projectionSlack) => {
2053
2657
  const revision = (state.file.revision ?? 0) + 1;
2054
2658
  if (!isSafePositiveInteger(revision))
@@ -2190,7 +2794,7 @@ function createJournalStoreCore(options, getInputContext) {
2190
2794
  const createInputHeadroomSegment = (previous, next) => {
2191
2795
  const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
2192
2796
  const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
2193
- return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
2797
+ return { version,
2194
2798
  revision: next.revision, previousRevision: next.revision - 1,
2195
2799
  profile: next.profile, botIdentity: next.botIdentity,
2196
2800
  upsertedEntries: next.entries.filter(entry => !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
@@ -2218,7 +2822,7 @@ function createJournalStoreCore(options, getInputContext) {
2218
2822
  : { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
2219
2823
  if (publishedSegment) {
2220
2824
  const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
2221
- (publishedSegment.operatorDispositions?.length ?? 0);
2825
+ (publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
2222
2826
  const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
2223
2827
  baseRetainedCount += 1;
2224
2828
  baseRetainedBytes += segmentBytes;
@@ -2271,9 +2875,10 @@ function createJournalStoreCore(options, getInputContext) {
2271
2875
  const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
2272
2876
  const segmentBytes = Buffer.byteLength(segmentText);
2273
2877
  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];
2878
+ segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
2879
+ segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
2880
+ const segmentWork = collections[0] + collections[1] + collections[2] + collections[5];
2881
+ const fileWork = collections[3] + collections[4] + collections[6];
2277
2882
  const accounting = source.evidence.accounting;
2278
2883
  // Both the segment-first state and snapshot-first cleanup residue must fit.
2279
2884
  assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes, accounting.work + segmentWork + fileWork, collections);
@@ -2311,12 +2916,13 @@ function createJournalStoreCore(options, getInputContext) {
2311
2916
  }
2312
2917
  return { file: revisedFile, serializedBytes };
2313
2918
  };
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) => {
2919
+ 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
2920
  const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(current.file.botIdentity, expectedIdentity);
2316
2921
  if (!contentChanged &&
2317
2922
  isDeepStrictEqual(current.file.botIdentity, botIdentity) &&
2318
2923
  isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? []) &&
2319
- current.file.acceptedThroughUpdateId === acceptedThroughUpdateId) {
2924
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
2925
+ isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])) {
2320
2926
  return { file: current.file, serializedBytes: current.serializedBytes };
2321
2927
  }
2322
2928
  const file = {
@@ -2330,21 +2936,24 @@ function createJournalStoreCore(options, getInputContext) {
2330
2936
  ...(operatorDispositions?.length
2331
2937
  ? { operatorDispositions }
2332
2938
  : {}),
2939
+ ...(sourceCompletions?.length ? { sourceCompletions } : {}),
2333
2940
  };
2941
+ assertJournalRoutingInputContinuity(current.file, entries, path);
2334
2942
  if (withPairingAdmission || current.source)
2335
2943
  parseJournalFile(file, path, version);
2336
2944
  const serialized = serializeJournalFile(file);
2337
2945
  const serializedBytes = assertCapacity(file, serialized);
2338
2946
  const changed = contentChanged ||
2339
2947
  current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
2340
- !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
2948
+ !isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
2949
+ !isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
2341
2950
  if (!changed) {
2342
2951
  return { file, serializedBytes: current.serializedBytes };
2343
2952
  }
2344
2953
  if (!current.exists) {
2345
2954
  if (current.source) {
2346
- const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
2347
- assertSourceResources(1, serializedBytes, collections[0] + collections[1], collections);
2955
+ const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
2956
+ assertSourceResources(1, serializedBytes, collections[0] + collections[1] + collections[2], collections);
2348
2957
  }
2349
2958
  if (inputHeadroom !== false)
2350
2959
  assertInputProgressHeadroom(current, file, inputHeadroom);
@@ -2379,6 +2988,7 @@ function createJournalStoreCore(options, getInputContext) {
2379
2988
  ...(!isDeepStrictEqual(current.file.operatorDispositions ?? [], operatorDispositions ?? [])
2380
2989
  ? { operatorDispositions: operatorDispositions ?? [] }
2381
2990
  : {}),
2991
+ ...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
2382
2992
  };
2383
2993
  if (current.source) {
2384
2994
  if (!isSafePositiveInteger(revision))
@@ -2386,7 +2996,7 @@ function createJournalStoreCore(options, getInputContext) {
2386
2996
  const revisedFile = { ...file, revision };
2387
2997
  if (inputHeadroom !== false)
2388
2998
  assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
2389
- return publishSourceSegment(current, file, segment, publicationBoundary, version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
2999
+ return publishSourceSegment(current, file, segment, publicationBoundary, journalHasCustodyFields(version));
2390
3000
  }
2391
3001
  publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
2392
3002
  const revisedFile = { ...file, revision };
@@ -2429,6 +3039,175 @@ function createJournalStoreCore(options, getInputContext) {
2429
3039
  }
2430
3040
  return { file: revisedFile, serializedBytes };
2431
3041
  };
3042
+ const removeCompleted = (updateIds, expectedSources, exact = false, completionInput) => runMutation((readCurrent) => {
3043
+ const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
3044
+ if (completionInput !== undefined && !sourceAccess)
3045
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
3046
+ const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
3047
+ if (exact && expectedSources === undefined)
3048
+ throw createJournalError("invalid", path, "received no exact completion sources");
3049
+ const requestedIds = new Set();
3050
+ for (const updateId of updateIds) {
3051
+ if (!isSafeNonNegativeInteger(updateId))
3052
+ throw createJournalError("invalid", path, "received an invalid removal update id");
3053
+ requestedIds.add(updateId);
3054
+ }
3055
+ if (expectedSources !== undefined) {
3056
+ if (!Array.isArray(expectedSources) || !expectedSources.length)
3057
+ throw createJournalError("invalid", path, "received no exact completion sources");
3058
+ const expectedIds = new Set();
3059
+ for (const expected of expectedSources) {
3060
+ if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
3061
+ !isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
3062
+ typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
3063
+ throw createJournalError("invalid", path, "received an invalid exact completion source");
3064
+ }
3065
+ expectedIds.add(expected.updateId);
3066
+ const entry = current.file.entries.find(value => value.updateId === expected.updateId);
3067
+ if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
3068
+ throw createJournalError("conflict", path, "exact completion source changed or disappeared");
3069
+ }
3070
+ }
3071
+ }
3072
+ if (completions) {
3073
+ const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
3074
+ if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
3075
+ throw createJournalError("invalid", path, "source completion has no matching exact guard");
3076
+ }
3077
+ }
3078
+ const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
3079
+ const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
3080
+ if (protectedEntry)
3081
+ throw createJournalError("conflict", path, protectedEntry.state === "failed"
3082
+ ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
3083
+ : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
3084
+ const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
3085
+ const sourceCompletions = completions
3086
+ ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
3087
+ if (sourceCompletions?.length)
3088
+ validateJournalSourceCompletions(sourceCompletions, path, version);
3089
+ 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);
3090
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
3091
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
3092
+ });
3093
+ const completeQueued = (receipts, completionInput, exact = false) => runMutation((readCurrent) => {
3094
+ if (exact && !sourceAccess)
3095
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
3096
+ const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
3097
+ if (!Array.isArray(receipts) || receipts.length === 0)
3098
+ throw createJournalError("invalid", path, "received no queued receipts to complete");
3099
+ const current = readCurrent();
3100
+ const receiptIds = new Set(), requestedUpdateIds = new Set();
3101
+ for (const receipt of receipts) {
3102
+ if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
3103
+ receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
3104
+ throw createJournalError("invalid", path, "received an invalid queued completion receipt");
3105
+ }
3106
+ receiptIds.add(receipt.receiptId);
3107
+ const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
3108
+ if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
3109
+ queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
3110
+ throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
3111
+ }
3112
+ const sourceUpdateIds = new Set();
3113
+ for (const updateId of receipt.sourceUpdateIds) {
3114
+ if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
3115
+ throw createJournalError("invalid", path, "received overlapping queued completion update ids");
3116
+ }
3117
+ sourceUpdateIds.add(updateId);
3118
+ requestedUpdateIds.add(updateId);
3119
+ }
3120
+ const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
3121
+ if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry => !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
3122
+ entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
3123
+ throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
3124
+ }
3125
+ }
3126
+ const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
3127
+ const removedUpdateIds = removedEntries.map(entry => entry.updateId);
3128
+ if (removedUpdateIds.length !== requestedUpdateIds.size)
3129
+ throw createJournalError("conflict", path, "queued completion did not resolve every source update");
3130
+ if (completions) {
3131
+ const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
3132
+ if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
3133
+ throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
3134
+ }
3135
+ }
3136
+ const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
3137
+ : current.file.sourceCompletions;
3138
+ if (sourceCompletions?.length)
3139
+ validateJournalSourceCompletions(sourceCompletions, path, version);
3140
+ 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);
3141
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
3142
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
3143
+ });
3144
+ const mutateRoutingInputs = (input, select, publishedAtMs) => runMutation(readCurrent => {
3145
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version))
3146
+ throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
3147
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3148
+ if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
3149
+ typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
3150
+ throw createJournalError("invalid", path, "invalid routing input lifetime authority");
3151
+ }
3152
+ const assertCurrent = () => { if (!input.isCurrent())
3153
+ throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
3154
+ assertCurrent();
3155
+ const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
3156
+ if (new Set(expected.map(entry => entry.updateId)).size !== expected.length)
3157
+ throw createJournalError("invalid", path, "duplicate routing input sources");
3158
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
3159
+ const nowMs = select ? getNowMs() : publishedAtMs;
3160
+ if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs()))
3161
+ throw createJournalError("invalid", path, "invalid routing input lifetime clock");
3162
+ let issued = false;
3163
+ const changed = new Map();
3164
+ const result = expected.map(original => {
3165
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
3166
+ if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
3167
+ throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
3168
+ }
3169
+ const withoutLifetime = (value) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
3170
+ if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
3171
+ (original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
3172
+ throw createJournalError("conflict", path, "routing input source changed");
3173
+ }
3174
+ const lifetime = entry.routingInput;
3175
+ if (lifetime && lifetime.operatorUserId !== input.operatorUserId)
3176
+ throw createJournalError("conflict", path, "routing input belongs to another operator");
3177
+ if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
3178
+ throw createJournalError("conflict", path, "routing input has protected retention");
3179
+ }
3180
+ if (select) {
3181
+ if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs))
3182
+ throw createJournalError("conflict", path, "routing input choice expired or was not armed");
3183
+ if (lifetime.phase === "selected")
3184
+ return cloneEntry(entry);
3185
+ issued = true;
3186
+ const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" } };
3187
+ changed.set(entry.updateId, selected);
3188
+ return cloneEntry(selected);
3189
+ }
3190
+ if (lifetime) {
3191
+ if (lifetime.phase !== "waiting")
3192
+ throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
3193
+ return cloneEntry(entry);
3194
+ }
3195
+ if (nowMs < entry.admittedAtMs)
3196
+ throw createJournalError("conflict", path, "routing input publication predates admission");
3197
+ issued = true;
3198
+ const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
3199
+ publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" } };
3200
+ changed.set(entry.updateId, armed);
3201
+ return cloneEntry(armed);
3202
+ });
3203
+ assertCurrent();
3204
+ if (select && issued && result.some(entry => !changed.has(entry.updateId)))
3205
+ throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
3206
+ if (changed.size)
3207
+ 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(); });
3208
+ assertCurrent();
3209
+ return { issued, entries: result };
3210
+ });
2432
3211
  const journal = {
2433
3212
  read() {
2434
3213
  return runMutation((readCurrent) => {
@@ -2467,9 +3246,12 @@ function createJournalStoreCore(options, getInputContext) {
2467
3246
  const current = readCurrent();
2468
3247
  const entriesById = new Map(current.file.entries.map((entry) => [entry.updateId, entry]));
2469
3248
  const previousAcceptedThroughUpdateId = current.file.acceptedThroughUpdateId;
2470
- const discardedUpdateIds = new Set((current.file.operatorDispositions ?? [])
2471
- .filter((disposition) => disposition.action === "discard")
2472
- .map((disposition) => disposition.updateId));
3249
+ const discardedUpdateIds = new Set([
3250
+ ...(current.file.operatorDispositions ?? [])
3251
+ .filter((disposition) => disposition.action === "discard")
3252
+ .map((disposition) => disposition.updateId),
3253
+ ...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
3254
+ ]);
2473
3255
  let admittedAtMs;
2474
3256
  const addedUpdateIds = [];
2475
3257
  const duplicateUpdateIds = [];
@@ -2643,6 +3425,7 @@ function createJournalStoreCore(options, getInputContext) {
2643
3425
  update: entry.update,
2644
3426
  admittedAtMs: entry.admittedAtMs,
2645
3427
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
3428
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
2646
3429
  state: "queued",
2647
3430
  queueKind: receipt.queueKind,
2648
3431
  queueReceiptId: receipt.receiptId,
@@ -2719,6 +3502,7 @@ function createJournalStoreCore(options, getInputContext) {
2719
3502
  updateId: entry.updateId,
2720
3503
  update: entry.update,
2721
3504
  admittedAtMs: entry.admittedAtMs,
3505
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
2722
3506
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
2723
3507
  state: input.disposition,
2724
3508
  failure,
@@ -2808,8 +3592,8 @@ function createJournalStoreCore(options, getInputContext) {
2808
3592
  applyLegacyCustodyDisposition(authority) {
2809
3593
  return runMutation((readCurrent) => {
2810
3594
  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");
3595
+ if (!journalHasCustodyFields(current.file.version))
3596
+ throw createJournalError("conflict", path, "legacy custody disposition requires a custody journal");
2813
3597
  const existing = current.file.operatorDispositions?.find(candidate => candidate.failureId === authority.dispositionId);
2814
3598
  if (existing) {
2815
3599
  if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
@@ -3018,67 +3802,8 @@ function createJournalStoreCore(options, getInputContext) {
3018
3802
  };
3019
3803
  });
3020
3804
  },
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
- },
3805
+ completeQueued(receipts) { return completeQueued(receipts); },
3806
+ completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
3082
3807
  discardQueued(input) {
3083
3808
  return runMutation((readCurrent) => {
3084
3809
  if ((input.queueKind !== "prompt" &&
@@ -3194,33 +3919,235 @@ function createJournalStoreCore(options, getInputContext) {
3194
3919
  };
3195
3920
  });
3196
3921
  },
3197
- removeCompleted(updateIds) {
3922
+ inspectAbandonedPending(updateId) {
3198
3923
  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);
3924
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3925
+ throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
3206
3926
  }
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`);
3927
+ if (!isSafeNonNegativeInteger(updateId))
3928
+ throw createJournalError("invalid", path, "invalid abandonment update id");
3929
+ // Inspection never repairs or quarantines source evidence.
3930
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
3931
+ const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
3932
+ const disposition = matches[0];
3933
+ if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
3934
+ disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
3935
+ disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
3936
+ current.file.entries.some(entry => entry.updateId === updateId))
3937
+ return undefined;
3938
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3939
+ const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
3940
+ let stat;
3941
+ try {
3942
+ stat = lstatSync(retainedPath);
3213
3943
  }
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,
3944
+ catch (error) {
3945
+ if (error.code === "ENOENT")
3946
+ throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
3947
+ throw error;
3948
+ }
3949
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
3950
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
3951
+ }
3952
+ const parsed = JSON.parse(readFileSync(retainedPath, "utf8"));
3953
+ if (!isRecord(parsed))
3954
+ throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
3955
+ const entry = validateJournalEntry(parsed.entry, retainedPath, version);
3956
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
3957
+ const retained = reference.read();
3958
+ if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
3959
+ !isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
3960
+ throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
3961
+ }
3962
+ return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
3963
+ });
3964
+ },
3965
+ inspectPendingRetention(entry) {
3966
+ return runMutation(() => {
3967
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3968
+ throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
3969
+ }
3970
+ const expected = validateJournalEntry(entry, path, version);
3971
+ if (expected.state !== "pending")
3972
+ throw createJournalError("conflict", path, "retention inspection requires a pending source");
3973
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3974
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
3975
+ const retained = reference.read();
3976
+ return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
3977
+ requestedDisposition: retained.requestedDisposition } : undefined;
3978
+ });
3979
+ },
3980
+ abandonPending(input) {
3981
+ return runMutation((readCurrent) => {
3982
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
3983
+ throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
3984
+ }
3985
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
3986
+ if (input.journalBindingKey !== journalBindingKey) {
3987
+ throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
3988
+ }
3989
+ if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
3990
+ !input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
3991
+ throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
3992
+ }
3993
+ const assertCurrent = () => {
3994
+ if (!input.isCurrent())
3995
+ throw createJournalError("conflict", path, "pending abandonment lost authority");
3996
+ };
3997
+ assertCurrent();
3998
+ const expected = validateJournalEntry(input.entry, path, version);
3999
+ if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
4000
+ expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
4001
+ throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
4002
+ }
4003
+ // Never repair/quarantine source evidence while deciding an operator cancellation.
4004
+ const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
4005
+ const current = readStrict();
4006
+ const { evidenceSha256, failureId, retainedPath, read: readRetention } = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
4007
+ const existing = current.file.operatorDispositions?.find(item => item.failureId === failureId || item.updateId === expected.updateId);
4008
+ const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
4009
+ const assertRetention = (retained, disposition) => {
4010
+ if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
4011
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
4012
+ }
3220
4013
  };
4014
+ if (existing) {
4015
+ if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
4016
+ existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
4017
+ throw createJournalError("conflict", path, "pending source already has another disposition");
4018
+ }
4019
+ assertRetention(readRetention(), existing);
4020
+ assertCurrent();
4021
+ return { disposition: existing, retainedPath, duplicate: true,
4022
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
4023
+ }
4024
+ if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
4025
+ throw createJournalError("conflict", path, "pending source changed before abandonment");
4026
+ }
4027
+ const nowMs = getNowMs();
4028
+ if (!isSafeNonNegativeInteger(nowMs))
4029
+ throw createJournalError("invalid", path, "invalid abandonment timestamp");
4030
+ const retained = readRetention();
4031
+ let disposition;
4032
+ if (retained) {
4033
+ disposition = retained.requestedDisposition;
4034
+ }
4035
+ else {
4036
+ // Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
4037
+ // This is an operator disposition, not a fabricated execution failure or completion.
4038
+ disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
4039
+ action: "discard", committedAtMs: nowMs, evidenceSha256,
4040
+ operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
4041
+ const copy = { version: 1, kind: "pending-input-retention",
4042
+ journalBindingKey, entry: expected, requestedDisposition: disposition };
4043
+ writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
4044
+ onPublicationBoundary?.(boundary, target);
4045
+ assertCurrent();
4046
+ });
4047
+ }
4048
+ assertCurrent();
4049
+ assertRetention(readRetention(), disposition);
4050
+ const committed = (snapshot) => !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
4051
+ snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
4052
+ try {
4053
+ 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(); });
4054
+ return { disposition, retainedPath, duplicate: false,
4055
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
4056
+ }
4057
+ catch (error) {
4058
+ // A segment may be durable even if later snapshot compaction failed.
4059
+ // Only exact read-back can turn that unknown acknowledgement into success.
4060
+ let observed;
4061
+ try {
4062
+ observed = readStrict();
4063
+ }
4064
+ catch {
4065
+ throw error;
4066
+ }
4067
+ if (!committed(observed))
4068
+ throw error;
4069
+ assertRetention(readRetention(), disposition);
4070
+ return { disposition, retainedPath, duplicate: false,
4071
+ entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
4072
+ }
4073
+ });
4074
+ },
4075
+ removeCompleted(updateIds) { return removeCompleted(updateIds); },
4076
+ removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
4077
+ inspectQueuedReceipt(expected) {
4078
+ return runMutationCore(readCurrent => {
4079
+ if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
4080
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
4081
+ }
4082
+ const queueOwner = validateQueuedReceiptObservation(expected, path);
4083
+ const current = readCurrent();
4084
+ return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
4085
+ });
4086
+ },
4087
+ inspectSourceCompletion(expected) {
4088
+ return runMutation(readCurrent => {
4089
+ if (!sourceAccess)
4090
+ throw createJournalError("invalid", path, "source completion requires an exact source handle");
4091
+ const completion = validateJournalSourceCompletion(expected, path);
4092
+ const current = readCurrent();
4093
+ return inspectJournalSourceCompletion(current.file, completion, path);
3221
4094
  });
3222
4095
  },
3223
4096
  };
4097
+ if (isTelegramUpdateJournalLegacyFamilyVersion(version))
4098
+ journal.routingInputs = {
4099
+ arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
4100
+ select(input) { return mutateRoutingInputs(input, true); },
4101
+ inspectExpiry(updateId) {
4102
+ return runMutation(readCurrent => {
4103
+ if (!isSafeNonNegativeInteger(updateId))
4104
+ throw createJournalError("invalid", path, "invalid routing expiry update ID");
4105
+ return inspectRoutingInputExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
4106
+ });
4107
+ },
4108
+ inspectGroupExpiry(updateIds) {
4109
+ return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
4110
+ },
4111
+ expire(input) {
4112
+ return runMutation(readCurrent => {
4113
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
4114
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
4115
+ if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
4116
+ original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
4117
+ lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
4118
+ throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
4119
+ }
4120
+ const assertCurrent = () => {
4121
+ const now = getNowMs();
4122
+ if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
4123
+ throw createJournalError("conflict", path, "routing source has not expired under current authority");
4124
+ };
4125
+ assertCurrent();
4126
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
4127
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
4128
+ const failureId = `routing-expiry:${evidenceSha256}`;
4129
+ const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
4130
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
4131
+ if (existing) {
4132
+ if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
4133
+ existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
4134
+ throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
4135
+ assertCurrent();
4136
+ return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
4137
+ }
4138
+ if (!entry || !isDeepStrictEqual(entry, original))
4139
+ throw createJournalError("conflict", path, "routing source changed before expiry");
4140
+ // A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
4141
+ const now = getNowMs();
4142
+ const disposition = { dispositionKind: "legacy-custody", failureId,
4143
+ updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
4144
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
4145
+ const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
4146
+ assertCurrent();
4147
+ return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
4148
+ });
4149
+ },
4150
+ };
3224
4151
  if (!getInputContext)
3225
4152
  return { journal };
3226
4153
  // Strict source acquisition already requires this exact stored receipt scope.
@@ -3376,7 +4303,7 @@ function createJournalStoreCore(options, getInputContext) {
3376
4303
  const evidence = readTelegramUpdateJournalSource({
3377
4304
  directory: sourceAccess.directory, path, profile,
3378
4305
  botIdentity: expectedIdentity, limits: sourceAccess.limits,
3379
- version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
4306
+ version,
3380
4307
  });
3381
4308
  return evidence.kind === "present"
3382
4309
  ? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];