@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
package/lib/journal.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  readFileSync,
23
23
  readdirSync,
24
24
  rmdirSync,
25
+ rmSync,
25
26
  statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
@@ -39,6 +40,7 @@ import {
39
40
  type TelegramProcessLiveness,
40
41
  } from "./bus.ts";
41
42
  import {
43
+ decodeTelegramSessionDirectoryName,
42
44
  getTelegramProfilePathSuffix,
43
45
  TELEGRAM_DEFAULT_PROFILE_NAME,
44
46
  } from "./paths.ts";
@@ -47,22 +49,28 @@ import {
47
49
  type TelegramWorkspaceAdmissionLedger,
48
50
  type TelegramWorkspaceAdmissionScope,
49
51
  } from "./workspace-admission.ts";
52
+ import {
53
+ isWireRecord as isRecord,
54
+ hasOnlyWireKeys as hasOnlyKeys,
55
+ isNonNegativeWireInteger as isSafeNonNegativeInteger,
56
+ isNonEmptyWireString as isNonEmptyString,
57
+ } from "./wire.ts";
50
58
 
51
59
  export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
52
60
  export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
53
61
  export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
54
62
  // Receipt and binding identities survive storage-schema upgrades.
55
63
  const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
56
- export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
57
- export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
58
- export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
64
+ const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
65
+ const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
66
+ const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
59
67
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
60
- export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
68
+ const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
61
69
  const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
62
70
  const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
63
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
64
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
65
- export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
71
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
72
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
73
+ const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
66
74
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
67
75
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
68
76
 
@@ -115,7 +123,78 @@ export function discoverTelegramFollowerJournalPaths(input: {
115
123
  }
116
124
  return { paths: Array.from(paths).sort(), complete };
117
125
  }
118
- export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
126
+
127
+ /** Bounded read-only session-family discovery, not writer closure or deletion authority. */
128
+ export function discoverTelegramSessionJournalPaths(input: {
129
+ directory: string;
130
+ profileName?: string;
131
+ maxDirectoryEntries?: number;
132
+ }): TelegramFollowerJournalDiscovery {
133
+ const maxEntries = input.maxDirectoryEntries ?? 10_000;
134
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) return { paths: [], complete: false };
135
+ const root = join(input.directory, "sessions");
136
+ const suffix = getTelegramProfilePathSuffix(input.profileName);
137
+ const paths = new Set<string>();
138
+ let remaining = maxEntries;
139
+ let complete = true;
140
+ const scan = (directory: string): Dirent[] => {
141
+ const entries: Dirent[] = [];
142
+ const handle = opendirSync(directory);
143
+ try {
144
+ for (;;) {
145
+ const entry = handle.readSync();
146
+ if (!entry) break;
147
+ if (--remaining < 0) { complete = false; break; }
148
+ entries.push(entry);
149
+ }
150
+ } finally { handle.closeSync(); }
151
+ return entries;
152
+ };
153
+ try {
154
+ const stat = lstatSync(root);
155
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { paths: [], complete: false };
156
+ } catch (error) {
157
+ return { paths: [], complete: (error as NodeJS.ErrnoException).code === "ENOENT" };
158
+ }
159
+ try {
160
+ for (const session of scan(root)) {
161
+ if (!complete) break;
162
+ if (!session.isDirectory() || decodeTelegramSessionDirectoryName(session.name) === undefined) {
163
+ complete = false; break;
164
+ }
165
+ const directory = join(root, session.name);
166
+ const sessionStat = lstatSync(directory);
167
+ if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) { complete = false; break; }
168
+ for (const entry of scan(directory)) {
169
+ if (!complete) break;
170
+ // The leader polling journal and legacy recovery folders are not recipient custody.
171
+ if (entry.name === "recovery" || /^inbox(?:\.[a-zA-Z0-9._-]+)?\.json(?:\.segments|\.retained)?$/u.test(entry.name)) continue;
172
+ const match = /^journal\.([a-f0-9]{16})(\.[a-zA-Z0-9._-]+)?\.json(\.segments|\.retained)?$/u.exec(entry.name);
173
+ if (!match) { complete = false; break; }
174
+ const stat = lstatSync(join(directory, entry.name));
175
+ if (stat.isSymbolicLink() || (match[3] ? !stat.isDirectory() : !stat.isFile())) {
176
+ complete = false; break;
177
+ }
178
+ if ((match[2] ?? "") !== suffix) continue;
179
+ paths.add(join(directory, entry.name.replace(/\.(?:segments|retained)$/u, "")));
180
+ }
181
+ }
182
+ } catch { complete = false; }
183
+ return { paths: Array.from(paths).sort(), complete };
184
+ }
185
+
186
+ /** Transitional discovery covers both retained flat recipients and session-owned families. */
187
+ export function discoverTelegramRecipientJournalPaths(input: {
188
+ directory: string;
189
+ profileName?: string;
190
+ }): TelegramFollowerJournalDiscovery {
191
+ const legacy = discoverTelegramFollowerJournalPaths(input);
192
+ const sessions = discoverTelegramSessionJournalPaths(input);
193
+ return { paths: Array.from(new Set([...legacy.paths, ...sessions.paths])).sort(),
194
+ complete: legacy.complete && sessions.complete };
195
+ }
196
+
197
+ const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
119
198
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
120
199
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
121
200
 
@@ -329,7 +408,7 @@ export interface TelegramUpdateJournalLegacyCustodyCandidate {
329
408
  evidenceSha256: string;
330
409
  }
331
410
 
332
- export function listTelegramUpdateJournalLegacyCustodyCandidates(
411
+ function listTelegramUpdateJournalLegacyCustodyCandidates(
333
412
  snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
334
413
  ): TelegramUpdateJournalLegacyCustodyCandidate[] {
335
414
  return snapshot.entries.flatMap(entry => {
@@ -409,12 +488,23 @@ export type TelegramUpdateJournalOperatorDisposition =
409
488
  | TelegramUpdateJournalTerminalOperatorDisposition
410
489
  | TelegramUpdateJournalLegacyCustodyDisposition;
411
490
 
491
+ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
492
+
493
+ /** A source-only choice deadline, never a queue lease or a Thread deletion grant. */
494
+ export interface TelegramUpdateJournalRoutingInput {
495
+ operatorUserId: number;
496
+ publishedAtMs: number;
497
+ expiresAtMs: number;
498
+ phase: "waiting" | "selected";
499
+ }
500
+
412
501
  export interface TelegramUpdateJournalEntry {
413
502
  updateId: number;
414
503
  update: TelegramJournaledUpdate;
415
504
  /** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
416
505
  preApprovalExcluded?: boolean;
417
506
  admittedAtMs: number;
507
+ routingInput?: TelegramUpdateJournalRoutingInput;
418
508
  state: TelegramUpdateJournalEntryState;
419
509
  queueKind?: TelegramUpdateJournalQueueKind;
420
510
  queueReceiptId?: string;
@@ -429,6 +519,19 @@ export interface TelegramUpdateJournalEntry {
429
519
  terminalFailureId?: string;
430
520
  }
431
521
 
522
+ /** Exact parsed journal entry identity, checked inside the source owner's mutation transaction. */
523
+ export interface TelegramUpdateJournalEntryDigest {
524
+ updateId: number;
525
+ sourceSha256: string;
526
+ }
527
+ /** Journal-owned removal ACK bound to an opaque, immutable caller acceptance scope. */
528
+ export interface TelegramUpdateJournalSourceCompletion extends TelegramUpdateJournalEntryDigest {
529
+ completionSha256: string;
530
+ }
531
+ export function createTelegramUpdateJournalEntryDigest(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntryDigest {
532
+ return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
533
+ }
534
+
432
535
  export interface TelegramUpdateJournalFile {
433
536
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
434
537
  revision?: number;
@@ -437,6 +540,7 @@ export interface TelegramUpdateJournalFile {
437
540
  botIdentity: TelegramUpdateJournalBotIdentity;
438
541
  entries: TelegramUpdateJournalEntry[];
439
542
  operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
543
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
440
544
  }
441
545
 
442
546
  export interface TelegramUpdateJournalSnapshot
@@ -455,6 +559,7 @@ export interface TelegramUpdateJournalAppendResult {
455
559
  }
456
560
 
457
561
  export interface TelegramUpdateJournalRemoveResult {
562
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
458
563
  removedUpdateIds: number[];
459
564
  entryCount: number;
460
565
  serializedBytes: number;
@@ -475,6 +580,12 @@ export interface TelegramUpdateJournalQueueResult {
475
580
  serializedBytes: number;
476
581
  }
477
582
 
583
+ export interface TelegramUpdateJournalQueuedReceiptEvidence {
584
+ receipt: TelegramUpdateJournalQueuedCompletion;
585
+ sources: TelegramUpdateJournalEntryDigest[];
586
+ queueOwnerSha256: string;
587
+ }
588
+
478
589
  export interface TelegramUpdateJournalQueuedCompletion {
479
590
  queueKind: TelegramUpdateJournalQueueKind;
480
591
  receiptId: string;
@@ -593,8 +704,94 @@ export interface TelegramUpdateJournalOperatorDispositionResult {
593
704
  serializedBytes: number;
594
705
  }
595
706
 
707
+ export interface TelegramUpdateJournalPendingAbandonmentInput {
708
+ journalBindingKey: string;
709
+ entry: TelegramUpdateJournalEntry;
710
+ operatorAuthorityId: string;
711
+ /** Caller proves owner authorization and excludes live chooser/worker dispatch through publication. */
712
+ isCurrent: () => boolean;
713
+ }
714
+
715
+ export interface TelegramUpdateJournalPendingAbandonmentResult {
716
+ disposition: TelegramUpdateJournalLegacyCustodyDisposition;
717
+ retainedPath: string;
718
+ duplicate: boolean;
719
+ entryCount: number;
720
+ serializedBytes: number;
721
+ }
722
+
723
+ /** Private evidence copy, not execution authority or proof that abandonment committed. */
724
+ export interface TelegramUpdateJournalRetainedInput {
725
+ version: 1;
726
+ kind: "pending-input-retention";
727
+ journalBindingKey: string;
728
+ entry: TelegramUpdateJournalEntry;
729
+ requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
730
+ }
731
+
732
+ export interface TelegramUpdateJournalPendingRetentionEvidence {
733
+ journalBindingKey: string;
734
+ retainedPath: string;
735
+ requestedDisposition: TelegramUpdateJournalLegacyCustodyDisposition;
736
+ }
737
+
738
+ /** Durable proof that an owner abandoned this exact pending source; never delivery or completion proof. */
739
+ export interface TelegramUpdateJournalAbandonedPendingEvidence {
740
+ journalBindingKey: string;
741
+ updateId: number;
742
+ retainedPath: string;
743
+ operatorAuthorityId: string;
744
+ }
745
+
746
+ export interface TelegramRoutingInputAuthority {
747
+ journalBindingKey: string;
748
+ entries: readonly TelegramUpdateJournalEntry[];
749
+ operatorUserId: number;
750
+ isCurrent(): boolean;
751
+ }
752
+
753
+ /** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
754
+ export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
755
+ export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
756
+
757
+ function inspectRoutingInputExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateId: number): TelegramRoutingInputExpiryEvidence | undefined {
758
+ const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
759
+ "dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
760
+ value.failureId === `routing-expiry:${value.evidenceSha256}`);
761
+ if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId)) return undefined;
762
+ return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
763
+ }
764
+
765
+ function inspectRoutingGroupExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined {
766
+ if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
767
+ updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1]!))) return undefined;
768
+ if (file.entries.some(entry => updateIds.includes(entry.updateId))) return undefined;
769
+ const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
770
+ if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0]!.operatorAuthorityId)) return undefined;
771
+ // Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
772
+ // not recipient custody; fresh Thread protection still independently gates deletion.
773
+ return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0]!.operatorAuthorityId }));
774
+ }
775
+
776
+ /** Package-private v1 capability; raw input custody does not expose it. */
777
+ export interface TelegramRoutingInputJournal {
778
+ arm(input: TelegramRoutingInputAuthority & { publishedAtMs: number }): TelegramUpdateJournalEntry[];
779
+ select(input: TelegramRoutingInputAuthority): { issued: boolean; entries: TelegramUpdateJournalEntry[] };
780
+ expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramRoutingInputExpiryResult;
781
+ inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
782
+ inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
783
+ }
784
+
596
785
  export interface TelegramUpdateJournalStore {
786
+ routingInputs?: TelegramRoutingInputJournal;
597
787
  read(): TelegramUpdateJournalSnapshot;
788
+ /** Read-only: the committed abandonment tombstone plus its matching private retention, or undefined. */
789
+ inspectAbandonedPending(updateId: number): TelegramUpdateJournalAbandonedPendingEvidence | undefined;
790
+ /** Read-only protection evidence for this snapshot entry, never commit/execution authority. */
791
+ inspectPendingRetention(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalPendingRetentionEvidence | undefined;
792
+ abandonPending(
793
+ input: TelegramUpdateJournalPendingAbandonmentInput,
794
+ ): TelegramUpdateJournalPendingAbandonmentResult;
598
795
  appendBatch<TUpdate extends TelegramUpdateJournalInput>(
599
796
  updates: readonly TUpdate[],
600
797
  acceptedThroughUpdateId?: number,
@@ -623,6 +820,11 @@ export interface TelegramUpdateJournalStore {
623
820
  completeQueued(
624
821
  receipts: readonly TelegramUpdateJournalQueuedCompletion[],
625
822
  ): TelegramUpdateJournalRemoveResult;
823
+ /** Strict v1 whole-receipt disposal co-publishes caller-scoped ACKs; never a readiness grant. */
824
+ completeQueuedExact(
825
+ receipts: readonly TelegramUpdateJournalQueuedCompletion[],
826
+ completions: readonly TelegramUpdateJournalSourceCompletion[],
827
+ ): TelegramUpdateJournalRemoveResult;
626
828
  discardQueued(
627
829
  input: TelegramUpdateJournalQueueDiscardInput,
628
830
  ): TelegramUpdateJournalQueueDiscardResult;
@@ -632,6 +834,15 @@ export interface TelegramUpdateJournalStore {
632
834
  removeCompleted(
633
835
  updateIds: readonly number[],
634
836
  ): TelegramUpdateJournalRemoveResult;
837
+ /** Guarded completion; every supplied source must still match, and absence is a conflict, not an ACK. */
838
+ removeCompletedExact(updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
839
+ completions?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult;
840
+ /** Strict read-only observation; neither source absence nor a different scope is completion evidence. */
841
+ inspectSourceCompletion(expected: TelegramUpdateJournalSourceCompletion): TelegramUpdateJournalSourceCompletion | undefined;
842
+ /** Complete strict observation of a retained, unoffered v1 queue receipt; never acquires readiness. */
843
+ inspectQueuedReceipt(expected: TelegramUpdateJournalQueuedCompletion): TelegramUpdateJournalQueuedReceiptEvidence | undefined;
844
+ isQueueReceiptCurrent?: (receipt: Pick<TelegramUpdateJournalQueuedCompletion, "queueKind" | "receiptId" | "sourceUpdateIds"> &
845
+ { journalBindingKey?: string }, owner: TelegramUpdateJournalQueueOwner) => boolean;
635
846
  }
636
847
 
637
848
  export type TelegramUpdateJournalPublicationBoundary =
@@ -642,7 +853,8 @@ export interface TelegramUpdateJournalRecoveryEvent {
642
853
  kind: "repaired" | "reset";
643
854
  path: string;
644
855
  revision?: number;
645
- quarantinePath?: string;
856
+ /** Damaged snapshot/segment paths deleted or atomically replaced; nothing is retained for recovery. */
857
+ deletedPaths?: string[];
646
858
  reason: string;
647
859
  }
648
860
 
@@ -831,6 +1043,7 @@ interface JournalSourceAcquisition {
831
1043
  evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
832
1044
  snapshotRevision: number;
833
1045
  segments: { name: string; bytes: number; work: number; botId?: number }[];
1046
+ retainedInputs?: TelegramUpdateJournalRetentionInspection["retainedInputs"];
834
1047
  }
835
1048
 
836
1049
  export interface TelegramUpdateJournalSegment {
@@ -843,6 +1056,7 @@ export interface TelegramUpdateJournalSegment {
843
1056
  upsertedEntries: TelegramUpdateJournalEntry[];
844
1057
  removedUpdateIds: number[];
845
1058
  operatorDispositions?: TelegramUpdateJournalOperatorDisposition[];
1059
+ sourceCompletions?: TelegramUpdateJournalSourceCompletion[];
846
1060
  }
847
1061
 
848
1062
  export interface TelegramUpdateJournalSegmentPublicationResult {
@@ -865,30 +1079,10 @@ function createJournalError(
865
1079
  );
866
1080
  }
867
1081
 
868
- function isRecord(value: unknown): value is Record<string, unknown> {
869
- return !!value && typeof value === "object" && !Array.isArray(value);
870
- }
871
-
872
- function hasOnlyKeys(
873
- value: Record<string, unknown>,
874
- allowedKeys: readonly string[],
875
- ): boolean {
876
- const allowed = new Set(allowedKeys);
877
- return Object.keys(value).every((key) => allowed.has(key));
878
- }
879
-
880
- function isSafeNonNegativeInteger(value: unknown): value is number {
881
- return Number.isSafeInteger(value) && (value as number) >= 0;
882
- }
883
-
884
1082
  function isSafePositiveInteger(value: unknown): value is number {
885
1083
  return Number.isSafeInteger(value) && (value as number) > 0;
886
1084
  }
887
1085
 
888
- function isNonEmptyString(value: unknown): value is string {
889
- return typeof value === "string" && value.length > 0;
890
- }
891
-
892
1086
  function isBoundedString(
893
1087
  value: unknown,
894
1088
  maxLength: number,
@@ -1255,8 +1449,16 @@ function validateJournalOperatorDisposition(
1255
1449
  }
1256
1450
 
1257
1451
  function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
1258
- return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
1259
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
1452
+ return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION || journalHasCustodyFields(version);
1453
+ }
1454
+
1455
+ function journalHasCustodyFields(version: TelegramUpdateJournalFile["version"]): boolean {
1456
+ return version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
1457
+ }
1458
+
1459
+ /** Only legacy v1 carries routing-lifetime, scoped-ACK and pairing-free queue-receipt semantics. */
1460
+ export function isTelegramUpdateJournalLegacyFamilyVersion(version: unknown): boolean {
1461
+ return version === TELEGRAM_UPDATE_JOURNAL_VERSION;
1260
1462
  }
1261
1463
 
1262
1464
  function validateJournalInputClaim(
@@ -1328,6 +1530,7 @@ function validateJournalEntry(
1328
1530
  "updateId",
1329
1531
  "update",
1330
1532
  "admittedAtMs",
1533
+ ...(isTelegramUpdateJournalLegacyFamilyVersion(version) ? ["routingInput"] : []),
1331
1534
  "state",
1332
1535
  "queueKind",
1333
1536
  "queueReceiptId",
@@ -1339,7 +1542,7 @@ function validateJournalEntry(
1339
1542
  "terminalReason",
1340
1543
  "terminalFailureId",
1341
1544
  ...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
1342
- ...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
1545
+ ...(journalHasCustodyFields(version)
1343
1546
  ? ["inputClaim", "inputProvenance"] : []),
1344
1547
  ]) ||
1345
1548
  !isSafeNonNegativeInteger(value.updateId) ||
@@ -1359,6 +1562,20 @@ function validateJournalEntry(
1359
1562
  "contains an entry/update id mismatch",
1360
1563
  );
1361
1564
  }
1565
+ let routingInput: TelegramUpdateJournalRoutingInput | undefined;
1566
+ if (value.routingInput !== undefined) {
1567
+ const routing = value.routingInput;
1568
+ if (!isRecord(routing) || !hasOnlyKeys(routing, ["operatorUserId", "publishedAtMs", "expiresAtMs", "phase"]) ||
1569
+ !isSafePositiveInteger(routing.operatorUserId) || !isSafeNonNegativeInteger(routing.publishedAtMs) ||
1570
+ routing.publishedAtMs < value.admittedAtMs || !isSafeNonNegativeInteger(routing.expiresAtMs) ||
1571
+ routing.expiresAtMs !== routing.publishedAtMs + TELEGRAM_ROUTING_INPUT_TTL_MS ||
1572
+ (routing.phase !== "waiting" && routing.phase !== "selected") ||
1573
+ (routing.phase === "waiting" && value.state === "queued")) {
1574
+ throw createJournalError("invalid", path, "contains invalid routing input lifetime");
1575
+ }
1576
+ routingInput = { operatorUserId: routing.operatorUserId, publishedAtMs: routing.publishedAtMs,
1577
+ expiresAtMs: routing.expiresAtMs, phase: routing.phase };
1578
+ }
1362
1579
  const queueKind = value.queueKind;
1363
1580
  const queueReceiptId = value.queueReceiptId;
1364
1581
  const hasQueueMetadata =
@@ -1474,6 +1691,7 @@ function validateJournalEntry(
1474
1691
  ...(inputClaim ? { inputClaim } : {}),
1475
1692
  ...(inputProvenance ? { inputProvenance } : {}),
1476
1693
  admittedAtMs: value.admittedAtMs,
1694
+ ...(routingInput ? { routingInput } : {}),
1477
1695
  state: value.state,
1478
1696
  ...(queueKind === "prompt" || queueKind === "control"
1479
1697
  ? { queueKind }
@@ -1522,6 +1740,32 @@ function assertSupportedJournalVersion(
1522
1740
  }
1523
1741
  }
1524
1742
 
1743
+ function validateJournalSourceCompletion(value: unknown, path: string): TelegramUpdateJournalSourceCompletion {
1744
+ if (!isRecord(value) || !hasOnlyKeys(value, ["updateId", "sourceSha256", "completionSha256"]) ||
1745
+ !isSafeNonNegativeInteger(value.updateId) || typeof value.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sourceSha256) ||
1746
+ typeof value.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.completionSha256)) {
1747
+ throw createJournalError("invalid", path, "has invalid source completion evidence");
1748
+ }
1749
+ return { updateId: value.updateId, sourceSha256: value.sourceSha256, completionSha256: value.completionSha256 };
1750
+ }
1751
+
1752
+ function validateJournalSourceCompletions(value: unknown, path: string, version: TelegramUpdateJournalFile["version"],
1753
+ allowEmpty = false): TelegramUpdateJournalSourceCompletion[] {
1754
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version) || !Array.isArray(value) || !allowEmpty && !value.length) {
1755
+ throw createJournalError("invalid", path, "has invalid source completion collection");
1756
+ }
1757
+ const completions = value.map(item => validateJournalSourceCompletion(item, path));
1758
+ const scopes = new Set<string>();
1759
+ for (let index = 0; index < completions.length; index++) {
1760
+ const completion = completions[index]!;
1761
+ if ((index > 0 && completions[index - 1]!.updateId >= completion.updateId) || scopes.has(completion.completionSha256)) {
1762
+ throw createJournalError("invalid", path, "has duplicate or unordered source completion evidence");
1763
+ }
1764
+ scopes.add(completion.completionSha256);
1765
+ }
1766
+ return completions;
1767
+ }
1768
+
1525
1769
  function parseJournalFile(
1526
1770
  value: unknown,
1527
1771
  path: string,
@@ -1542,6 +1786,7 @@ function parseJournalFile(
1542
1786
  "botIdentity",
1543
1787
  "entries",
1544
1788
  "operatorDispositions",
1789
+ "sourceCompletions",
1545
1790
  ]) ||
1546
1791
  !Number.isSafeInteger(value.version) ||
1547
1792
  (value.revision !== undefined &&
@@ -1635,6 +1880,14 @@ function parseJournalFile(
1635
1880
  ).map((disposition) =>
1636
1881
  validateJournalOperatorDisposition(disposition, path),
1637
1882
  );
1883
+ const sourceCompletions = value.sourceCompletions === undefined ? [] : validateJournalSourceCompletions(value.sourceCompletions, path, version);
1884
+ const botIdentity = validateBotIdentity(value.botIdentity, path);
1885
+ const activeIds = new Set(entries.map(entry => entry.updateId));
1886
+ const discardedIds = new Set(operatorDispositions.filter(disposition => disposition.action === "discard").map(disposition => disposition.updateId));
1887
+ if (sourceCompletions.some(completion => activeIds.has(completion.updateId) || discardedIds.has(completion.updateId) ||
1888
+ value.acceptedThroughUpdateId !== undefined && completion.updateId > (value.acceptedThroughUpdateId as number))) {
1889
+ throw createJournalError("invalid", path, "has contradictory source completion evidence");
1890
+ }
1638
1891
  const dispositionFailureIds = new Set<string>();
1639
1892
  const entriesByUpdateId = new Map(
1640
1893
  entries.map((entry) => [entry.updateId, entry]),
@@ -1669,10 +1922,60 @@ function parseJournalFile(
1669
1922
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1670
1923
  : {}),
1671
1924
  profile: value.profile,
1672
- botIdentity: validateBotIdentity(value.botIdentity, path),
1925
+ botIdentity,
1673
1926
  entries,
1674
1927
  ...(operatorDispositions.length > 0 ? { operatorDispositions } : {}),
1928
+ ...(sourceCompletions.length > 0 ? { sourceCompletions } : {}),
1929
+ };
1930
+ }
1931
+
1932
+ function assertJournalRoutingInputContinuity(file: TelegramUpdateJournalFile, entries: readonly TelegramUpdateJournalEntry[], path: string): void {
1933
+ const previous = new Map(file.entries.map(entry => [entry.updateId, entry.routingInput]));
1934
+ for (const entry of entries) {
1935
+ const retained = previous.get(entry.updateId), next = entry.routingInput;
1936
+ if (retained && (!next || next.operatorUserId !== retained.operatorUserId || next.publishedAtMs !== retained.publishedAtMs ||
1937
+ next.expiresAtMs !== retained.expiresAtMs || (retained.phase === "selected" && next.phase !== "selected"))) {
1938
+ throw createJournalError("invalid", path, "regresses retained routing input lifetime");
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ function assertJournalSourceCompletionContinuity(file: TelegramUpdateJournalFile, segment: TelegramUpdateJournalSegment, path: string): void {
1944
+ assertJournalRoutingInputContinuity(file, segment.upsertedEntries, path);
1945
+ if (segment.sourceCompletions === undefined) return;
1946
+ const previous = new Map((file.sourceCompletions ?? []).map(completion => [completion.updateId, completion]));
1947
+ const next = new Map(segment.sourceCompletions.map(completion => [completion.updateId, completion]));
1948
+ if ([...previous].some(([id, completion]) => !isDeepStrictEqual(completion, next.get(id)))) {
1949
+ throw createJournalError("invalid", path, "regresses retained source completion evidence");
1950
+ }
1951
+ const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1952
+ const removed = new Set(segment.removedUpdateIds), upserted = new Set(segment.upsertedEntries.map(entry => entry.updateId));
1953
+ const upsertedReceipts = new Set(segment.upsertedEntries.map(entry => entry.queueReceiptId));
1954
+ // Grouped markers share one whole-receipt check rather than rescanning the group per source.
1955
+ const queueGroups = new Map<string, TelegramUpdateJournalEntry[]>(), queueRemovals = new Map<string, boolean>();
1956
+ for (const entry of file.entries) {
1957
+ if (!entry.queueReceiptId) continue;
1958
+ const group = queueGroups.get(entry.queueReceiptId) ?? [];
1959
+ group.push(entry); queueGroups.set(entry.queueReceiptId, group);
1960
+ }
1961
+ const queueWasRemoved = (source: TelegramUpdateJournalEntry): boolean => {
1962
+ const receiptId = source.queueReceiptId;
1963
+ if (!receiptId || !source.queueOwner) return false;
1964
+ if (queueRemovals.has(receiptId)) return queueRemovals.get(receiptId)!;
1965
+ const group = queueGroups.get(receiptId);
1966
+ const valid = !!group?.length && !upsertedReceipts.has(receiptId) && group.every(entry =>
1967
+ removed.has(entry.updateId) && !upserted.has(entry.updateId) && entry.state === "queued" && entry.queueKind === source.queueKind &&
1968
+ entry.queueHandoff === undefined && !!entry.queueOwner && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, source.queueOwner!));
1969
+ queueRemovals.set(receiptId, valid); return valid;
1675
1970
  };
1971
+ for (const completion of segment.sourceCompletions) {
1972
+ if (previous.has(completion.updateId)) continue;
1973
+ const source = entries.get(completion.updateId);
1974
+ if (!removed.has(completion.updateId) || !source || (source.state === "queued" && !queueWasRemoved(source)) || source.state === "failed" ||
1975
+ createTelegramUpdateJournalEntryDigest(source).sourceSha256 !== completion.sourceSha256) {
1976
+ throw createJournalError("invalid", path, "source completion does not match its removal revision");
1977
+ }
1978
+ }
1676
1979
  }
1677
1980
 
1678
1981
  function parseJournalSegment(
@@ -1697,6 +2000,7 @@ function parseJournalSegment(
1697
2000
  "upsertedEntries",
1698
2001
  "removedUpdateIds",
1699
2002
  "operatorDispositions",
2003
+ "sourceCompletions",
1700
2004
  ]) ||
1701
2005
  value.version !== version ||
1702
2006
  !isSafePositiveInteger(value.revision) ||
@@ -1751,6 +2055,7 @@ function parseJournalSegment(
1751
2055
  ).map((disposition) =>
1752
2056
  validateJournalOperatorDisposition(disposition, path),
1753
2057
  );
2058
+ const segmentBotIdentity = validateBotIdentity(value.botIdentity, path);
1754
2059
  return {
1755
2060
  version,
1756
2061
  revision: value.revision,
@@ -1759,12 +2064,13 @@ function parseJournalSegment(
1759
2064
  ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1760
2065
  : {}),
1761
2066
  profile: value.profile,
1762
- botIdentity: validateBotIdentity(value.botIdentity, path),
2067
+ botIdentity: segmentBotIdentity,
1763
2068
  upsertedEntries,
1764
2069
  removedUpdateIds,
1765
2070
  ...(value.operatorDispositions !== undefined
1766
2071
  ? { operatorDispositions }
1767
2072
  : {}),
2073
+ ...(value.sourceCompletions !== undefined ? { sourceCompletions: validateJournalSourceCompletions(value.sourceCompletions, path, version, true) } : {}),
1768
2074
  };
1769
2075
  }
1770
2076
 
@@ -1807,6 +2113,19 @@ export function inspectTelegramUpdateJournalFamily(input: {
1807
2113
  return acquireTelegramUpdateJournalFamily(input).evidence;
1808
2114
  }
1809
2115
 
2116
+ export interface TelegramUpdateJournalRetentionInspection {
2117
+ evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
2118
+ retainedInputs: { path: string; journalBindingKey: string; failureId: string; updateId: number; state: "committed" | "uncommitted" }[];
2119
+ }
2120
+
2121
+ /** Classifies private originals against exact discard tombstones; never replay or deletion authority. */
2122
+ export function inspectTelegramUpdateJournalRetention(
2123
+ input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
2124
+ ): TelegramUpdateJournalRetentionInspection {
2125
+ const acquired = acquireTelegramUpdateJournalFamily(input, undefined, true);
2126
+ return { evidence: acquired.evidence, retainedInputs: acquired.retainedInputs ?? [] };
2127
+ }
2128
+
1810
2129
  /**
1811
2130
  * Read-only source evidence for cooperating writers serialized by the caller through
1812
2131
  * consumption; never readiness, recovery, or permission to publish. Ancestors retain
@@ -1821,7 +2140,7 @@ export function readTelegramUpdateJournalSource(
1821
2140
  input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
1822
2141
  ): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
1823
2142
  const version = input.version;
1824
- if (version !== 1 && version !== 2 && version !== 3) {
2143
+ if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
1825
2144
  throw createJournalError("unsupported-version", input.path, "requires a selected source version");
1826
2145
  }
1827
2146
  return acquireTelegramUpdateJournalFamily({
@@ -1834,6 +2153,7 @@ export function readTelegramUpdateJournalSource(
1834
2153
  function acquireTelegramUpdateJournalFamily(
1835
2154
  input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
1836
2155
  selectedVersion?: TelegramUpdateJournalFile["version"],
2156
+ inspectRetention = false,
1837
2157
  ): JournalSourceAcquisition {
1838
2158
  const { path, limits } = input;
1839
2159
  const acquiredSegments: JournalSourceAcquisition["segments"] = [];
@@ -1878,7 +2198,7 @@ function acquireTelegramUpdateJournalFamily(
1878
2198
  };
1879
2199
  const charge = (value: unknown) => {
1880
2200
  if (!isRecord(value)) return;
1881
- for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
2201
+ for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions", "sourceCompletions"]) {
1882
2202
  const collection = value[key];
1883
2203
  if (!Array.isArray(collection)) continue;
1884
2204
  if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
@@ -1910,15 +2230,15 @@ function acquireTelegramUpdateJournalFamily(
1910
2230
  } finally { closeSync(fd); }
1911
2231
  };
1912
2232
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
1913
- const enumerate = (): string[] => {
2233
+ const enumerate = (target = segmentDirectory, pattern = /^\d{16}\.json$/u, usedFiles = files): string[] => {
1914
2234
  const names: string[] = [];
1915
- const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
2235
+ const directory = opendirSync(target, { bufferSize: 1 });
1916
2236
  try {
1917
2237
  for (;;) {
1918
2238
  const entry = directory.readSync();
1919
2239
  if (!entry) break;
1920
- if (names.length >= limits.maxFiles - files) capacity();
1921
- if (!/^\d{16}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected segment entry");
2240
+ if (names.length >= limits.maxFiles - usedFiles) capacity();
2241
+ if (!pattern.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) fail("has an unexpected journal evidence entry");
1922
2242
  names.push(entry.name);
1923
2243
  }
1924
2244
  } finally { directory.closeSync(); }
@@ -1976,6 +2296,7 @@ function acquireTelegramUpdateJournalFamily(
1976
2296
  if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
1977
2297
  if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
1978
2298
  segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
2299
+ assertJournalSourceCompletionContinuity(file, segment, target);
1979
2300
  const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1980
2301
  for (const id of segment.removedUpdateIds) entries.delete(id);
1981
2302
  for (const entry of segment.upsertedEntries) {
@@ -1993,15 +2314,63 @@ function acquireTelegramUpdateJournalFamily(
1993
2314
  ...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
1994
2315
  entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
1995
2316
  operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
2317
+ sourceCompletions: segment.sourceCompletions ?? file.sourceCompletions,
1996
2318
  };
1997
2319
  charge(next);
1998
2320
  file = parseJournalFile(next, target, file.version);
1999
2321
  }
2000
2322
  }
2323
+ const retainedInputs: TelegramUpdateJournalRetentionInspection["retainedInputs"] = [];
2324
+ const retentionDirectory = `${path}.retained`;
2325
+ const retention = inspectRetention ? observe(retentionDirectory, true, true) : undefined;
2326
+ const retentionPattern = /^abandon-[a-f0-9]{64}\.json$/u;
2327
+ const retentionNames = retention ? enumerate(retentionDirectory, retentionPattern) : [];
2328
+ if (retentionNames.length > limits.maxEntries) capacity();
2329
+ if (retention) {
2330
+ if (!file || file.version !== 1) fail("private retention requires its exact v1 journal snapshot");
2331
+ const current = file!;
2332
+ const bindingKeys = new Set([expected, current.botIdentity].map(botIdentity =>
2333
+ createTelegramUpdateJournalBindingKey({ path, profileName: input.profile, botIdentity })));
2334
+ const updateIds = new Set<number>();
2335
+ for (const name of retentionNames) {
2336
+ const target = join(retentionDirectory, name);
2337
+ const before = observe(target, false)!;
2338
+ if (before.nlink !== 1n) fail("has linked private retention evidence");
2339
+ const raw = read(target, before);
2340
+ files += 1;
2341
+ if (limits.maxWork - work < 2) capacity();
2342
+ work += 2;
2343
+ if (!isRecord(raw)) throw createJournalError("invalid", target, "private retention is not an evidence record");
2344
+ if (typeof raw.journalBindingKey !== "string" || !bindingKeys.has(raw.journalBindingKey))
2345
+ throw createJournalError("invalid", target, "has foreign private retention evidence");
2346
+ const entry = validateJournalEntry(raw.entry, target, 1);
2347
+ const disposition = validateJournalOperatorDisposition(raw.requestedDisposition, target);
2348
+ const digest = createHash("sha256").update(JSON.stringify({ journalBindingKey: raw.journalBindingKey, entry })).digest("hex");
2349
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
2350
+ disposition.action !== "discard" || entry.state !== "pending" || disposition.updateId !== entry.updateId ||
2351
+ disposition.evidenceSha256 !== digest || disposition.failureId !== `abandon-${digest}` ||
2352
+ name !== `${disposition.failureId}.json` || !isDeepStrictEqual(raw, { version: 1, kind: "pending-input-retention",
2353
+ journalBindingKey: raw.journalBindingKey, entry, requestedDisposition: disposition })) fail("has inconsistent private retention evidence");
2354
+ if (updateIds.has(entry.updateId)) fail("has duplicate private retention sources");
2355
+ updateIds.add(entry.updateId);
2356
+ const committed = current.operatorDispositions?.find(value => value.failureId === disposition.failureId || value.updateId === entry.updateId);
2357
+ if (committed && (!isDeepStrictEqual(committed, disposition) || current.entries.some(value => value.updateId === entry.updateId)))
2358
+ fail("private retention contradicts its journal disposition");
2359
+ retainedInputs.push({ path: target, journalBindingKey: raw.journalBindingKey,
2360
+ failureId: disposition.failureId, updateId: entry.updateId, state: committed ? "committed" : "uncommitted" });
2361
+ }
2362
+ }
2363
+ if (inspectRetention && file?.operatorDispositions?.some(disposition =>
2364
+ "dispositionKind" in disposition && disposition.dispositionKind === "legacy-custody" &&
2365
+ disposition.action === "discard" && /^abandon-[a-f0-9]{64}$/u.test(disposition.failureId) &&
2366
+ !retainedInputs.some(original => original.updateId === disposition.updateId && original.state === "committed")))
2367
+ fail("committed abandonment lost its private original");
2001
2368
  // Re-enumeration has the same bound, rather than allocating an unbounded census.
2002
2369
  files = snapshot ? 1 : 0;
2003
2370
  if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
2004
2371
  files += names.length;
2372
+ if (retention && !isDeepStrictEqual(retentionNames, enumerate(retentionDirectory, retentionPattern))) fail("changed private retention namespace");
2373
+ files += retentionNames.length;
2005
2374
  for (const [target, before] of observed) {
2006
2375
  const after = status(target);
2007
2376
  const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
@@ -2023,6 +2392,7 @@ function acquireTelegramUpdateJournalFamily(
2023
2392
  evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
2024
2393
  snapshotRevision,
2025
2394
  segments: acquiredSegments,
2395
+ ...(inspectRetention ? { retainedInputs } : {}),
2026
2396
  };
2027
2397
  } catch (error) {
2028
2398
  if (error instanceof TelegramUpdateJournalError) throw error;
@@ -2036,16 +2406,35 @@ function acquireTelegramUpdateJournalFamily(
2036
2406
  * Recensus detects observable changes, not hostile same-user path swaps; arbitrary
2037
2407
  * consumer references and archive consumption require separate audits.
2038
2408
  */
2039
- export function inspectTelegramProfileJournalNamespace(input: {
2409
+ export interface TelegramJournalNamespaceInspectionInput {
2040
2410
  directory: string;
2041
2411
  profile: string;
2042
2412
  botIdentity: TelegramUpdateJournalBotIdentity;
2043
2413
  limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
2044
- }): {
2045
- sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
2414
+ /** Leader polling journal named by owners.json; defaults to the flat root `inbox`. */
2415
+ pollingPath?: string;
2416
+ /** Hold a participating consumer reference during each physical family read. */
2417
+ withSourceReference?: <T>(path: string, operation: () => T) => T;
2418
+ }
2419
+
2420
+ export interface TelegramJournalNamespaceInspection {
2421
+ /** Original preservation facts only, never replay/deletion grants. */
2422
+ retainedInputs?: (TelegramUpdateJournalRetentionInspection["retainedInputs"][number] & { journalPath: string })[];
2423
+ sources: { role: "polling" | "follower" | "session"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
2046
2424
  accounting: { directoryEntries: number; files: number; bytes: number; work: number };
2047
2425
  knownBotId?: number;
2048
- } {
2426
+ }
2427
+
2428
+ export function inspectTelegramProfileJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
2429
+ return inspectTelegramJournalNamespace(input, false);
2430
+ }
2431
+
2432
+ /** Read-only evidence over polling, retained flat recipients and role-neutral session journals. */
2433
+ export function inspectTelegramSessionJournalNamespace(input: TelegramJournalNamespaceInspectionInput): TelegramJournalNamespaceInspection {
2434
+ return inspectTelegramJournalNamespace(input, true);
2435
+ }
2436
+
2437
+ function inspectTelegramJournalNamespace(input: TelegramJournalNamespaceInspectionInput, includeSessions: boolean): TelegramJournalNamespaceInspection {
2049
2438
  const { directory, profile, limits } = input;
2050
2439
  const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
2051
2440
  const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
@@ -2062,46 +2451,99 @@ export function inspectTelegramProfileJournalNamespace(input: {
2062
2451
  size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
2063
2452
  };
2064
2453
  const suffix = profile === "default" ? "" : `.${profile}`;
2065
- const polling = `inbox${suffix}.json`;
2454
+ const pollingPath = input.pollingPath ?? join(directory, `inbox${suffix}.json`);
2455
+ const polling = relative(directory, pollingPath);
2456
+ {
2457
+ const parts = polling.split(sep);
2458
+ if (!isAbsolute(pollingPath) || resolve(pollingPath) !== pollingPath || parts.at(-1) !== `inbox${suffix}.json` ||
2459
+ !(parts.length === 1 || (parts.length === 3 && parts[0] === "sessions" &&
2460
+ decodeTelegramSessionDirectoryName(parts[1]!) !== undefined))) fail("requires a canonical polling journal path");
2461
+ }
2066
2462
  try {
2067
2463
  const root = lstatSync(directory);
2068
2464
  if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
2069
2465
  const beforeRoot = metadata(directory);
2070
2466
  const census = () => {
2071
2467
  const entries = new Map<string, ReturnType<typeof metadata>>();
2072
- const followers = new Set<string>();
2073
- const handle = opendirSync(directory, { bufferSize: 1 });
2074
- try {
2075
- for (;;) {
2076
- const entry = handle.readSync();
2077
- if (!entry) break;
2078
- if (entries.size >= limits.maxDirectoryEntries) capacity();
2079
- if (entry.name.toLowerCase() === "recovery") fail("retains unclassified recovery storage");
2080
- const target = join(directory, entry.name);
2081
- entries.set(entry.name, metadata(target));
2082
- if (!/inbox/iu.test(entry.name)) continue;
2083
- const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.segments)?$/u.exec(entry.name);
2084
- if (!match || match[2] === "default") fail("has a noncanonical journal-like entry");
2085
- const value = lstatSync(target);
2086
- if (value.isSymbolicLink() || !(match![3] ? value.isDirectory() : value.isFile())) fail("has a linked or unexpected journal type");
2087
- if ((match![2] ?? "default") !== profile) continue;
2088
- if (match![1] !== "inbox") followers.add(entry.name.replace(/\.segments$/u, ""));
2468
+ const families = new Map<string, "follower" | "session">(), profiles = new Set<string>();
2469
+ const scan = (parent: string, visit: (name: string, target: string) => void): void => {
2470
+ const handle = opendirSync(parent, { bufferSize: 1 });
2471
+ try {
2472
+ for (;;) {
2473
+ const entry = handle.readSync();
2474
+ if (!entry) break;
2475
+ if (entries.size >= limits.maxDirectoryEntries) capacity();
2476
+ const target = join(parent, entry.name);
2477
+ const key = relative(directory, target);
2478
+ if (entries.has(key)) fail("repeated an observed namespace entry");
2479
+ entries.set(key, metadata(target));
2480
+ visit(entry.name, target);
2481
+ }
2482
+ } finally { handle.closeSync(); }
2483
+ };
2484
+ const requireType = (target: string, isDirectory: boolean): void => {
2485
+ const value = lstatSync(target);
2486
+ if (value.isSymbolicLink() || !(isDirectory ? value.isDirectory() : value.isFile()) ||
2487
+ (!isDirectory && value.nlink !== 1)) fail("has a linked or unexpected journal type");
2488
+ };
2489
+ const evidenceDirectory = (target: string, retained: boolean): void => scan(target, (name, path) => {
2490
+ if (!(retained ? /^abandon-[a-f0-9]{64}\.json$/u : /^\d{16}\.json$/u).test(name)) fail("has an unclassified journal evidence entry");
2491
+ requireType(path, false);
2492
+ });
2493
+ const session = (target: string): void => scan(target, (name, path) => {
2494
+ if (name === "recovery") return;
2495
+ const match = /^(?:journal\.[a-f0-9]{16}|inbox)(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
2496
+ if (!match || match[1] === "default") fail("has an unclassified session journal entry");
2497
+ requireType(path, !!match![2]);
2498
+ if (match![2]) evidenceDirectory(path, match![2] === ".retained");
2499
+ const base = path.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![1] ?? "default";
2500
+ profiles.add(familyProfile);
2501
+ if (familyProfile === profile && base !== pollingPath) families.set(base, "session");
2502
+ });
2503
+ scan(directory, (name, target) => {
2504
+ // Legacy quarantine folders are disposable housekeeping, never journal authority.
2505
+ if (name.toLowerCase() === "recovery") return;
2506
+ if (name.toLowerCase() === "sessions") {
2507
+ if (!includeSessions) fail("requires session-qualified namespace inspection");
2508
+ if (name !== "sessions") fail("has a noncanonical session root");
2509
+ requireType(target, true);
2510
+ scan(target, (sessionName, sessionPath) => {
2511
+ if (decodeTelegramSessionDirectoryName(sessionName) === undefined) fail("has a noncanonical session directory");
2512
+ requireType(sessionPath, true);
2513
+ session(sessionPath);
2514
+ });
2515
+ return;
2089
2516
  }
2090
- } finally { handle.closeSync(); }
2091
- return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
2517
+ if (includeSessions && /^journal\./iu.test(name)) fail("has a misplaced session journal");
2518
+ if (!/inbox/iu.test(name)) return;
2519
+ const match = /^(inbox|follower-inbox-[a-f0-9]{16})(?:\.([a-z0-9]{1,32}))?\.json(\.(?:segments|retained))?$/u.exec(name);
2520
+ if (!match || match[2] === "default" || (!includeSessions && match[3] === ".retained")) fail("has a noncanonical journal-like entry");
2521
+ requireType(target, !!match![3]);
2522
+ if (includeSessions && match![3]) evidenceDirectory(target, match![3] === ".retained");
2523
+ const base = target.replace(/\.(?:segments|retained)$/u, ""), familyProfile = match![2] ?? "default";
2524
+ profiles.add(familyProfile);
2525
+ if (familyProfile === profile && base !== pollingPath)
2526
+ families.set(base, match![1] === "inbox" ? "session" : "follower");
2527
+ });
2528
+ return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
2529
+ families: [...families].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), profiles: [...profiles].sort() };
2092
2530
  };
2093
2531
  const before = census();
2094
- const sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[] = [];
2532
+ const sourceInventory = [[pollingPath, "polling"], ...before.families] as const;
2533
+ const sources: TelegramJournalNamespaceInspection["sources"] = [];
2534
+ const retainedInputs: NonNullable<TelegramJournalNamespaceInspection["retainedInputs"]> = [];
2095
2535
  const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
2096
2536
  let knownBotId = expected.botId;
2097
- for (const name of [polling, ...before.followers]) {
2537
+ for (const [path, role] of sourceInventory) {
2098
2538
  const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
2099
2539
  maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
2100
2540
  if (remaining.maxFiles <= 0 || remaining.maxBytes <= 0 || remaining.maxWork <= 0) capacity();
2101
- const path = join(directory, name);
2102
- const evidence = inspectTelegramUpdateJournalFamily({ directory, path, profile,
2103
- botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining });
2104
- if (evidence.kind === "absent" && (name !== polling || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
2541
+ const acquire = () => acquireTelegramUpdateJournalFamily({ directory, path, profile,
2542
+ botIdentity: { ...expected, ...(knownBotId !== undefined ? { botId: knownBotId } : {}) }, limits: remaining }, undefined, includeSessions);
2543
+ const acquired = input.withSourceReference ? input.withSourceReference(path, acquire) : acquire();
2544
+ const evidence = acquired.evidence;
2545
+ retainedInputs.push(...(acquired.retainedInputs ?? []).map(original => ({ ...original, journalPath: path })));
2546
+ if (evidence.kind === "absent" && (role !== "polling" || before.entries.some(([entry]) => entry === polling || entry === `${polling}.segments`))) {
2105
2547
  fail("lost a discovered journal family");
2106
2548
  }
2107
2549
  if (evidence.kind === "present") {
@@ -2110,16 +2552,46 @@ export function inspectTelegramProfileJournalNamespace(input: {
2110
2552
  accounting.bytes += evidence.accounting.bytes;
2111
2553
  }
2112
2554
  accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
2113
- sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
2555
+ sources.push({ role, path, evidence });
2114
2556
  }
2115
- if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory))) fail("changed observed root namespace or identity");
2116
- return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
2557
+ const namespaceCurrent = (): boolean => {
2558
+ try { return isDeepStrictEqual(beforeRoot, metadata(directory)) && isDeepStrictEqual(before, census()) &&
2559
+ isDeepStrictEqual(beforeRoot, metadata(directory)); }
2560
+ catch { return false; }
2561
+ };
2562
+ if (!namespaceCurrent()) fail("changed observed root namespace or identity");
2563
+ return { sources, accounting, ...(includeSessions ? { retainedInputs } : {}), ...(knownBotId !== undefined ? { knownBotId } : {}) };
2117
2564
  } catch (error) {
2118
2565
  if (error instanceof TelegramUpdateJournalError) throw error;
2119
2566
  throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
2120
2567
  }
2121
2568
  }
2122
2569
 
2570
+ /** Complete-empty protection only; caller holds source serialization through consumption. Never deletion authority. */
2571
+ export function isTelegramThreadCleanupJournalNamespaceClear(input: TelegramJournalNamespaceInspectionInput & {
2572
+ requiredJournalBindingKeys: readonly string[];
2573
+ withSourceReference: NonNullable<TelegramJournalNamespaceInspectionInput["withSourceReference"]>;
2574
+ }): boolean {
2575
+ if (!input.requiredJournalBindingKeys.length || input.requiredJournalBindingKeys.length > input.limits.maxDirectoryEntries)
2576
+ throw new Error("Telegram temporary cleanup requires bounded exact journal references.");
2577
+ const requiredPaths = new Set<string>();
2578
+ for (const key of input.requiredJournalBindingKeys) {
2579
+ const path = getTelegramUpdateJournalBindingPath(key);
2580
+ const identities = [input.botIdentity, { tokenSha256: input.botIdentity.tokenSha256 }];
2581
+ if (!path || !identities.some(botIdentity => key === createTelegramUpdateJournalBindingKey({
2582
+ path, profileName: input.profile, botIdentity,
2583
+ }))) throw new Error("Telegram temporary cleanup has foreign or malformed journal references.");
2584
+ requiredPaths.add(path);
2585
+ }
2586
+ const namespace = inspectTelegramSessionJournalNamespace(input);
2587
+ if (namespace.sources.filter(source => source.role === "polling").length !== 1 ||
2588
+ namespace.retainedInputs?.some(original => original.state !== "committed") ||
2589
+ [...requiredPaths].some(path => !namespace.sources.some(source => source.path === path && source.evidence.kind === "present")))
2590
+ return false;
2591
+ // Original Telegram targets do not certify execution ownership. No nonempty family gets a source exemption.
2592
+ return namespace.sources.every(source => source.evidence.kind === "absent" || source.evidence.file.entries.length === 0);
2593
+ }
2594
+
2123
2595
  function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
2124
2596
  return {
2125
2597
  ...entry,
@@ -2134,6 +2606,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
2134
2606
  ? { queueHandoff: cloneJournalQueueHandoff(entry.queueHandoff) }
2135
2607
  : {}),
2136
2608
  ...(entry.failure ? { failure: { ...entry.failure } } : {}),
2609
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
2137
2610
  };
2138
2611
  }
2139
2612
 
@@ -2147,6 +2620,7 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
2147
2620
  profile: file.profile,
2148
2621
  botIdentity: { ...file.botIdentity },
2149
2622
  entries: file.entries.map(cloneEntry),
2623
+ ...(file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions.map(completion => ({ ...completion })) } : {}),
2150
2624
  ...(file.operatorDispositions?.length
2151
2625
  ? {
2152
2626
  operatorDispositions: file.operatorDispositions.map(
@@ -2288,6 +2762,46 @@ function mergeBotIdentity(
2288
2762
  };
2289
2763
  }
2290
2764
 
2765
+ function createPendingRetentionReference(input: {
2766
+ path: string;
2767
+ journalBindingKey: string;
2768
+ entry: TelegramUpdateJournalEntry;
2769
+ maxBytes: number;
2770
+ }) {
2771
+ const { path, journalBindingKey, entry, maxBytes } = input;
2772
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify({ journalBindingKey, entry })).digest("hex");
2773
+ const failureId = `abandon-${evidenceSha256}`;
2774
+ const retainedDirectory = `${path}.retained`;
2775
+ const retainedPath = join(retainedDirectory, `${failureId}.json`);
2776
+ const read = (): TelegramUpdateJournalRetainedInput | undefined => {
2777
+ let directory;
2778
+ try { directory = lstatSync(retainedDirectory); }
2779
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
2780
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
2781
+ throw createJournalError("invalid", retainedDirectory, "retention directory is not a regular directory");
2782
+ }
2783
+ let stat;
2784
+ try { stat = lstatSync(retainedPath); }
2785
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
2786
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
2787
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
2788
+ }
2789
+ const retained: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
2790
+ if (!isRecord(retained)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
2791
+ const disposition = validateJournalOperatorDisposition(retained.requestedDisposition, retainedPath);
2792
+ if (!("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody" ||
2793
+ disposition.failureId !== failureId || disposition.updateId !== entry.updateId ||
2794
+ disposition.action !== "discard" || disposition.evidenceSha256 !== evidenceSha256 ||
2795
+ !isDeepStrictEqual(retained, { version: 1, kind: "pending-input-retention", journalBindingKey,
2796
+ entry, requestedDisposition: disposition })) {
2797
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
2798
+ }
2799
+ return { version: 1, kind: "pending-input-retention", journalBindingKey, entry,
2800
+ requestedDisposition: disposition };
2801
+ };
2802
+ return { evidenceSha256, failureId, retainedPath, read };
2803
+ }
2804
+
2291
2805
  function writeJournalFile(
2292
2806
  path: string,
2293
2807
  serialized: string,
@@ -2428,7 +2942,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
2428
2942
  runtimeKey: string;
2429
2943
  recoveryKey: string;
2430
2944
  journal: TelegramUpdateJournalStore;
2431
- readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
2945
+ readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[]; exists: boolean };
2432
2946
  }
2433
2947
 
2434
2948
  export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
@@ -2438,11 +2952,14 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
2438
2952
  getJournalPath: (profileName?: string) => string;
2439
2953
  getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
2440
2954
  withWriterAdmission?: <T>(operation: () => T) => T;
2955
+ withSourceSerialization?: TelegramUpdateJournalStoreOptions["withSourceSerialization"];
2441
2956
  getWorkspaceAdmission?: () => Pick<
2442
2957
  TelegramWorkspaceAdmissionLedger,
2443
2958
  "acquireAdmission" | "releaseAdmission"
2444
2959
  > | undefined;
2445
2960
  onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
2961
+ /** Strict no-follow source handles; defaults to platform support (absent on Windows). */
2962
+ strictSourceAccess?: boolean;
2446
2963
  }
2447
2964
 
2448
2965
  export function createTelegramUpdateJournalRuntimeBindingResolver(
@@ -2460,6 +2977,26 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2460
2977
  });
2461
2978
  const path = deps.getJournalPath(configuredProfileName);
2462
2979
  const workspaceAdmission = deps.getWorkspaceAdmission?.();
2980
+ const sourceAccess = { directory: dirname(path), limits: {
2981
+ maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2982
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2983
+ } };
2984
+ const options: TelegramUpdateJournalStoreOptions = { path, profileName, botIdentity,
2985
+ ...(deps.getQueueRuntimeIdentity ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() } : {}),
2986
+ ...(workspaceAdmission ? { workspaceAdmission } : {}),
2987
+ ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
2988
+ ...(deps.withSourceSerialization ? { withSourceSerialization: deps.withSourceSerialization } : {}),
2989
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}) };
2990
+ const journal = createTelegramUpdateJournalStore(options);
2991
+ // Scoped completion and receipt observation use strict private handles where the platform provides no-follow
2992
+ // evidence; elsewhere (Windows) the same capabilities read through the ordinary journal so queue receipts still work.
2993
+ const strict = deps.strictSourceAccess ?? Boolean(constants.O_NOFOLLOW && constants.O_NONBLOCK);
2994
+ const completionJournal = deps.withSourceSerialization
2995
+ ? createTelegramUpdateJournalStore({ ...options, ...(strict ? { sourceAccess } : {}) }) : undefined;
2996
+ // Queue receipt readiness gates every ordinary prompt; without strict handles it uses the ordinary journal read.
2997
+ const observeQueuedReceipt = (expected: TelegramUpdateJournalQueuedCompletion) => strict
2998
+ ? completionJournal!.inspectQueuedReceipt(expected)
2999
+ : inspectJournalQueuedReceipt(journal.read(), { ...expected, queueOwner: validateQueuedReceiptObservation(expected, path) }, path);
2463
3000
  return {
2464
3001
  runtimeKey: JSON.stringify({
2465
3002
  path,
@@ -2474,27 +3011,24 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2474
3011
  readForProtection() {
2475
3012
  // Protection must never turn corruption/recovery into empty-work evidence.
2476
3013
  const evidence = inspectTelegramUpdateJournalFamily({
2477
- directory: dirname(path), path, profile: profileName, botIdentity,
2478
- limits: {
2479
- maxFiles: 1024,
2480
- maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2481
- maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
2482
- maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2483
- },
3014
+ ...sourceAccess, path, profile: profileName, botIdentity,
2484
3015
  });
2485
- return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
3016
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [], exists: evidence.kind === "present" };
2486
3017
  },
2487
- journal: createTelegramUpdateJournalStore({
2488
- path,
2489
- profileName,
2490
- botIdentity,
2491
- ...(deps.getQueueRuntimeIdentity
2492
- ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
2493
- : {}),
2494
- ...(workspaceAdmission ? { workspaceAdmission } : {}),
2495
- ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
2496
- ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
2497
- }),
3018
+ journal: completionJournal ? { ...journal,
3019
+ removeCompletedExact(updateIds, expectedSources, completions) {
3020
+ return completions !== undefined ? completionJournal.removeCompletedExact(updateIds, expectedSources, completions)
3021
+ : journal.removeCompletedExact(updateIds, expectedSources);
3022
+ },
3023
+ completeQueuedExact(receipts, completions) { return completionJournal.completeQueuedExact(receipts, completions); },
3024
+ inspectSourceCompletion(expected) { return completionJournal.inspectSourceCompletion(expected); },
3025
+ inspectQueuedReceipt: observeQueuedReceipt,
3026
+ isQueueReceiptCurrent(receipt, owner) {
3027
+ if (receipt.journalBindingKey !== createTelegramUpdateJournalBindingKey({ path, profileName, botIdentity })) return false;
3028
+ return observeQueuedReceipt({ queueKind: receipt.queueKind, receiptId: receipt.receiptId,
3029
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...owner } }) !== undefined;
3030
+ },
3031
+ } : journal,
2498
3032
  };
2499
3033
  };
2500
3034
  }
@@ -2560,13 +3094,83 @@ export function withTelegramResolvedUpdateJournalReference<T>(input: {
2560
3094
  recoveryKey: binding.recoveryKey }, () => input.operation(binding));
2561
3095
  }
2562
3096
 
3097
+ function validateQueuedReceiptObservation(expected: TelegramUpdateJournalQueuedCompletion, path: string): TelegramUpdateJournalQueueOwner {
3098
+ if (!isRecord(expected) || !hasOnlyKeys(expected, ["queueKind", "receiptId", "sourceUpdateIds", "queueOwner"]) || (expected.queueKind !== "prompt" && expected.queueKind !== "control") ||
3099
+ !isNonEmptyString(expected.receiptId) ||
3100
+ !Array.isArray(expected.sourceUpdateIds) || expected.sourceUpdateIds.length === 0 ||
3101
+ expected.sourceUpdateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
3102
+ expected.sourceUpdateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= expected.sourceUpdateIds[index - 1]!))) {
3103
+ throw createJournalError("invalid", path, "received invalid queue receipt observation authority");
3104
+ }
3105
+ return validateJournalQueueOwner(expected.queueOwner, path);
3106
+ }
3107
+
3108
+ function inspectJournalQueuedReceipt(file: TelegramUpdateJournalFile, expected: TelegramUpdateJournalQueuedCompletion,
3109
+ path: string): TelegramUpdateJournalQueuedReceiptEvidence | undefined {
3110
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
3111
+ const { queueOwner } = expected;
3112
+ const entries = file.entries.filter(entry => entry.queueReceiptId === expected.receiptId);
3113
+ if (entries.length !== expected.sourceUpdateIds.length || entries.some((entry, index) =>
3114
+ entry.updateId !== expected.sourceUpdateIds[index] || entry.state !== "queued" || entry.queueKind !== expected.queueKind ||
3115
+ entry.queueHandoff !== undefined || !isDeepStrictEqual(entry.queueOwner, queueOwner))) return undefined;
3116
+ return { receipt: { queueKind: expected.queueKind, receiptId: expected.receiptId,
3117
+ sourceUpdateIds: [...expected.sourceUpdateIds], queueOwner: { ...queueOwner } },
3118
+ sources: entries.map(createTelegramUpdateJournalEntryDigest),
3119
+ queueOwnerSha256: createHash("sha256").update(JSON.stringify(queueOwner)).digest("hex") };
3120
+ }
3121
+
3122
+ function inspectJournalSourceCompletion(file: TelegramUpdateJournalFile, completion: TelegramUpdateJournalSourceCompletion,
3123
+ path: string): TelegramUpdateJournalSourceCompletion | undefined {
3124
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(file.version)) throw createJournalError("invalid", path, "source completion requires a legacy v1 source");
3125
+ const retained = file.sourceCompletions?.find(value => value.updateId === completion.updateId);
3126
+ if (!retained) return undefined;
3127
+ if (!isDeepStrictEqual(retained, completion)) throw createJournalError("conflict", path, "source completion belongs to another exact scope");
3128
+ return { ...retained };
3129
+ }
3130
+
3131
+ /** Operator-distinct authority: routing never treats it as an owner cancellation. */
3132
+ export const TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX = "session-successor:";
3133
+
3134
+ export interface TelegramSessionPendingAdoptionInput {
3135
+ recipientBindingKey: string;
3136
+ predecessorSessionId: string;
3137
+ successorSessionId: string;
3138
+ /** Caller proves the successor registration is current and no predecessor worker runs. */
3139
+ isCurrent: () => boolean;
3140
+ }
3141
+
3142
+ export interface TelegramSessionPendingAdoptionResult {
3143
+ adoptedUpdateIds: number[];
3144
+ /** Already committed away by an earlier attempt; never re-appended, the private original remains. */
3145
+ retainedUpdateIds: number[];
3146
+ }
3147
+
3148
+ function isAdoptablePendingEntry(entry: TelegramUpdateJournalEntry): boolean {
3149
+ return entry.state === "pending" && !entry.inputClaim && !entry.inputProvenance && !entry.routingInput &&
3150
+ !entry.queueOwner && !entry.queueHandoff && !entry.queueReceiptId && !entry.queueKind && !entry.failure &&
3151
+ entry.preApprovalExcluded !== true;
3152
+ }
3153
+
2563
3154
  export interface TelegramUpdateJournalBindingRuntime {
2564
3155
  resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
2565
3156
  resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
2566
3157
  resolveActive: () => TelegramUpdateJournalRuntimeBinding | undefined;
2567
3158
  getActiveRecoveryKey: () => string | undefined;
3159
+ /** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
3160
+ inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
3161
+ inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
3162
+ inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
3163
+ inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
3164
+ /** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
3165
+ adoptPredecessorPending: (input: TelegramSessionPendingAdoptionInput) => TelegramSessionPendingAdoptionResult;
3166
+ /** Process-local: adopts from the previously prepared session of the same recipient key, then records the active one. */
3167
+ prepareActiveFollowerSuccession: (isCurrent: () => boolean) => TelegramSessionPendingAdoptionResult | undefined;
2568
3168
  createRecipientResolver: (
2569
3169
  recipientBindingKey: string,
3170
+ sessionId?: string,
3171
+ ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
3172
+ createLegacyRecipientResolver: (
3173
+ recipientBindingKey: string,
2570
3174
  ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
2571
3175
  createPathResolver: (
2572
3176
  path: string,
@@ -2576,11 +3180,15 @@ export interface TelegramUpdateJournalBindingRuntime {
2576
3180
  export function createTelegramUpdateJournalBindingRuntime(deps: {
2577
3181
  base: Omit<TelegramUpdateJournalRuntimeBindingResolverDeps, "getJournalPath">;
2578
3182
  getLeaderJournalPath: (profileName?: string) => string;
3183
+ /** Runtime root; defaults to the leader journal's directory for flat layouts. */
3184
+ getRuntimeDir?: () => string;
2579
3185
  getFollowerJournalPath: (
2580
3186
  bindingKey: string,
2581
3187
  profileName?: string,
3188
+ sessionId?: string,
2582
3189
  ) => string;
2583
3190
  getActiveFollowerBindingKey: () => string;
3191
+ getActiveFollowerSessionId?: () => string | undefined;
2584
3192
  isFollowerRegistered: () => boolean;
2585
3193
  }): TelegramUpdateJournalBindingRuntime {
2586
3194
  const resolveLeader = createTelegramUpdateJournalRuntimeBindingResolver({
@@ -2590,8 +3198,12 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2590
3198
  const createFollowerResolver = (
2591
3199
  bindingKey: string,
2592
3200
  includeQueueRuntimeIdentity: boolean,
2593
- ) =>
2594
- createTelegramUpdateJournalRuntimeBindingResolver({
3201
+ sessionId?: string,
3202
+ legacy = false,
3203
+ ) => {
3204
+ // Session-aware composition must not fall back to a process journal before preparation supplies its ID.
3205
+ if (!legacy && deps.getActiveFollowerSessionId && !sessionId) return () => undefined;
3206
+ return createTelegramUpdateJournalRuntimeBindingResolver({
2595
3207
  getProfileName: deps.base.getProfileName,
2596
3208
  getBotToken: deps.base.getBotToken,
2597
3209
  getBotId: deps.base.getBotId,
@@ -2600,25 +3212,132 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2600
3212
  : {}),
2601
3213
  ...(deps.base.withWriterAdmission
2602
3214
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
3215
+ // Follower journals need the same strict exact-receipt ports as the leader; without them queue publication cannot complete.
3216
+ ...(deps.base.withSourceSerialization
3217
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
2603
3218
  ...(deps.base.getWorkspaceAdmission
2604
3219
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2605
3220
  : {}),
2606
3221
  ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
2607
3222
  getJournalPath(profileName) {
2608
- return deps.getFollowerJournalPath(bindingKey, profileName);
3223
+ return deps.getFollowerJournalPath(bindingKey, profileName, sessionId);
2609
3224
  },
2610
3225
  });
3226
+ };
2611
3227
  const resolveFollower = () =>
2612
- createFollowerResolver(deps.getActiveFollowerBindingKey(), true)();
3228
+ createFollowerResolver(deps.getActiveFollowerBindingKey(), true, deps.getActiveFollowerSessionId?.())();
2613
3229
  const resolveActive = () =>
2614
3230
  deps.isFollowerRegistered() ? resolveFollower() : resolveLeader();
2615
- return {
3231
+ const inspectHistoricalSource = <T>(journalBindingKey: string,
3232
+ operation: (file: TelegramUpdateJournalFile, originals: TelegramUpdateJournalRetentionInspection["retainedInputs"], path: string) => T | undefined): T | undefined => {
3233
+ const path = getTelegramUpdateJournalBindingPath(journalBindingKey);
3234
+ const token = deps.base.getBotToken(), botId = deps.base.getBotId();
3235
+ const profile = deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME;
3236
+ if (!path || !token) return undefined;
3237
+ const botIdentity = createTelegramUpdateJournalBotIdentity({ botToken: token, botId });
3238
+ const keys = [botIdentity, { tokenSha256: botIdentity.tokenSha256 }].map(identity =>
3239
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: identity }));
3240
+ if (!keys.includes(journalBindingKey)) return undefined;
3241
+ const getRoot = () => deps.getRuntimeDir?.() ?? dirname(deps.getLeaderJournalPath(profile));
3242
+ const root = getRoot();
3243
+ const parts = relative(root, path).split(sep);
3244
+ const suffix = escapeTelegramJournalPathPattern(getTelegramProfilePathSuffix(profile));
3245
+ const flat = new RegExp(`^(?:inbox|follower-inbox-[a-f0-9]{16})${suffix}\\.json$`, "u");
3246
+ const session = new RegExp(`^(?:journal\\.[a-f0-9]{16}|inbox)${suffix}\\.json$`, "u");
3247
+ if (!isAbsolute(root) || resolve(root) !== root || !isAbsolute(path) || resolve(path) !== path ||
3248
+ !(parts.length === 1 && flat.test(parts[0]!) || parts.length === 3 && parts[0] === "sessions" &&
3249
+ decodeTelegramSessionDirectoryName(parts[1]!) !== undefined && session.test(parts[2]!))) return undefined;
3250
+ const current = () => deps.base.getBotToken() === token && deps.base.getBotId() === botId &&
3251
+ (deps.base.getProfileName() ?? TELEGRAM_DEFAULT_PROFILE_NAME) === profile && getRoot() === root;
3252
+ const inspect = () => {
3253
+ if (!current()) return undefined;
3254
+ const observed = inspectTelegramUpdateJournalRetention({ directory: root, path, profile, botIdentity,
3255
+ limits: { maxFiles: 1024, maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
3256
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES, maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024 } });
3257
+ if (observed.evidence.kind !== "present" || !current()) return undefined;
3258
+ return operation(observed.evidence.file, observed.retainedInputs, path);
3259
+ };
3260
+ const result = deps.base.withSourceSerialization ? deps.base.withSourceSerialization(inspect) : inspect();
3261
+ return current() ? result : undefined;
3262
+ };
3263
+ let preparedFollower: { recipientBindingKey: string; sessionId: string } | undefined;
3264
+ const runtime: TelegramUpdateJournalBindingRuntime = {
2616
3265
  resolveLeader,
2617
3266
  resolveFollower,
2618
3267
  resolveActive,
2619
3268
  getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
2620
- createRecipientResolver: (bindingKey) =>
2621
- createFollowerResolver(bindingKey, false),
3269
+ prepareActiveFollowerSuccession(isCurrent) {
3270
+ const recipientBindingKey = deps.getActiveFollowerBindingKey();
3271
+ const sessionId = deps.getActiveFollowerSessionId?.();
3272
+ if (!sessionId) return undefined;
3273
+ const previous = preparedFollower;
3274
+ const result = previous && previous.recipientBindingKey === recipientBindingKey && previous.sessionId !== sessionId
3275
+ ? runtime.adoptPredecessorPending({ recipientBindingKey, predecessorSessionId: previous.sessionId,
3276
+ successorSessionId: sessionId, isCurrent })
3277
+ : undefined;
3278
+ // Only a completed adoption advances the predecessor; failures retry idempotently on the next preparation.
3279
+ preparedFollower = { recipientBindingKey, sessionId };
3280
+ return result;
3281
+ },
3282
+ inspectSourceAbandonment(journalBindingKey, updateId) {
3283
+ if (!isSafeNonNegativeInteger(updateId)) throw new Error("Telegram abandonment proof requires an exact update ID.");
3284
+ return inspectHistoricalSource(journalBindingKey, (file, originals) => {
3285
+ const original = originals.find(value => value.updateId === updateId && value.state === "committed" &&
3286
+ value.journalBindingKey === journalBindingKey);
3287
+ if (!original) return undefined;
3288
+ const disposition = file.operatorDispositions?.find(value => value.failureId === original.failureId);
3289
+ if (!disposition || !("dispositionKind" in disposition) || disposition.dispositionKind !== "legacy-custody") return undefined;
3290
+ return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
3291
+ });
3292
+ },
3293
+ inspectSourceGroupExpiry(journalBindingKey, updateIds) {
3294
+ return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
3295
+ },
3296
+ inspectSourceCompletion(journalBindingKey, expected) {
3297
+ const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
3298
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
3299
+ },
3300
+ inspectQueuedReceipt(journalBindingKey, expected) {
3301
+ const queueOwner = validateQueuedReceiptObservation(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
3302
+ return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalQueuedReceipt(file, { ...expected, queueOwner }, path));
3303
+ },
3304
+ adoptPredecessorPending(input) {
3305
+ const { recipientBindingKey, predecessorSessionId, successorSessionId, isCurrent } = input;
3306
+ if (!recipientBindingKey || !predecessorSessionId || !successorSessionId ||
3307
+ predecessorSessionId === successorSessionId || typeof isCurrent !== "function") {
3308
+ throw new Error("Telegram session adoption requires distinct exact session identities.");
3309
+ }
3310
+ const assertCurrent = () => {
3311
+ if (!isCurrent()) throw new Error("Telegram session adoption lost successor authority.");
3312
+ };
3313
+ assertCurrent();
3314
+ const predecessor = createFollowerResolver(recipientBindingKey, false, predecessorSessionId)();
3315
+ const successor = createFollowerResolver(recipientBindingKey, false, successorSessionId)();
3316
+ if (!predecessor?.readForProtection || !successor || predecessor.recoveryKey === successor.recoveryKey) {
3317
+ throw new Error("Telegram session adoption requires exact predecessor and successor journals.");
3318
+ }
3319
+ const result: TelegramSessionPendingAdoptionResult = { adoptedUpdateIds: [], retainedUpdateIds: [] };
3320
+ // Non-repairing selection; the abandonment CAS rechecks each exact entry under the source lock.
3321
+ const candidates = predecessor.readForProtection().entries.filter(isAdoptablePendingEntry)
3322
+ .sort((left, right) => left.updateId - right.updateId);
3323
+ for (const entry of candidates) {
3324
+ assertCurrent();
3325
+ // Commit away first: a crash afterwards preserves the private original instead of duplicating execution.
3326
+ const committed = predecessor.journal.abandonPending({ journalBindingKey: predecessor.recoveryKey, entry,
3327
+ operatorAuthorityId: `${TELEGRAM_SESSION_ADOPTION_AUTHORITY_PREFIX}${successorSessionId}`, isCurrent });
3328
+ if (committed.duplicate) {
3329
+ result.retainedUpdateIds.push(entry.updateId);
3330
+ continue;
3331
+ }
3332
+ assertCurrent();
3333
+ successor.journal.appendBatch([entry.update]);
3334
+ result.adoptedUpdateIds.push(entry.updateId);
3335
+ }
3336
+ return result;
3337
+ },
3338
+ createRecipientResolver: (bindingKey, sessionId) =>
3339
+ createFollowerResolver(bindingKey, false, sessionId),
3340
+ createLegacyRecipientResolver: (bindingKey) => createFollowerResolver(bindingKey, false, undefined, true),
2622
3341
  createPathResolver: (path) =>
2623
3342
  createTelegramUpdateJournalRuntimeBindingResolver({
2624
3343
  getProfileName: deps.base.getProfileName,
@@ -2626,6 +3345,8 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2626
3345
  getBotId: deps.base.getBotId,
2627
3346
  ...(deps.base.withWriterAdmission
2628
3347
  ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
3348
+ ...(deps.base.withSourceSerialization
3349
+ ? { withSourceSerialization: deps.base.withSourceSerialization } : {}),
2629
3350
  ...(deps.base.getWorkspaceAdmission
2630
3351
  ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2631
3352
  : {}),
@@ -2633,6 +3354,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
2633
3354
  getJournalPath: () => path,
2634
3355
  }),
2635
3356
  };
3357
+ return runtime;
2636
3358
  }
2637
3359
 
2638
3360
  export function createTelegramUpdateJournalStore(
@@ -2720,7 +3442,6 @@ function createJournalStoreCore(
2720
3442
  };
2721
3443
  const getQueueProcessLiveness =
2722
3444
  options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
2723
-
2724
3445
  const validateQueueHandoffInput = (
2725
3446
  input: TelegramUpdateJournalQueueHandoffInput,
2726
3447
  operation: "offer" | "accept" | "cancel",
@@ -2841,6 +3562,9 @@ function createJournalStoreCore(
2841
3562
  `exceeds its ${maxEntries}-operator-disposition limit`,
2842
3563
  );
2843
3564
  }
3565
+ if ((file.sourceCompletions?.length ?? 0) > maxEntries) {
3566
+ throw createJournalError("capacity", path, `exceeds its ${maxEntries}-source-completion limit`);
3567
+ }
2844
3568
  const serializedBytes = Buffer.byteLength(serialized);
2845
3569
  if (serializedBytes > maxBytes) {
2846
3570
  throw createJournalError(
@@ -2859,7 +3583,7 @@ function createJournalStoreCore(
2859
3583
  entries: [],
2860
3584
  });
2861
3585
 
2862
- const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
3586
+ const readCurrentStrict = (allowReconciliation = true): ReadTelegramUpdateJournalResult => {
2863
3587
  let source: string;
2864
3588
  let recoveringMissingSnapshot = false;
2865
3589
  try {
@@ -2893,6 +3617,7 @@ function createJournalStoreCore(
2893
3617
  );
2894
3618
  }
2895
3619
  if (orphanedSegmentNames.length > 0) {
3620
+ if (!allowReconciliation) throw createJournalError("invalid", path, "requires explicit missing-snapshot reconciliation");
2896
3621
  if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
2897
3622
  recoveringMissingSnapshot = true;
2898
3623
  source = serializeJournalFile(emptyFile());
@@ -3001,6 +3726,7 @@ function createJournalStoreCore(
3001
3726
  "belongs to another journal identity",
3002
3727
  );
3003
3728
  }
3729
+ assertJournalSourceCompletionContinuity(file, segment, segmentPath);
3004
3730
  const entriesById = new Map(
3005
3731
  file.entries.map((entry) => [entry.updateId, entry]),
3006
3732
  );
@@ -3040,6 +3766,8 @@ function createJournalStoreCore(
3040
3766
  : file.operatorDispositions?.length
3041
3767
  ? { operatorDispositions: file.operatorDispositions }
3042
3768
  : {}),
3769
+ ...(segment.sourceCompletions !== undefined ? { sourceCompletions: segment.sourceCompletions }
3770
+ : file.sourceCompletions?.length ? { sourceCompletions: file.sourceCompletions } : {}),
3043
3771
  },
3044
3772
  segmentPath,
3045
3773
  version,
@@ -3049,9 +3777,9 @@ function createJournalStoreCore(
3049
3777
  const identityChanged =
3050
3778
  storedProfile !== profile ||
3051
3779
  !identitiesMatch(file.botIdentity, expectedIdentity) ||
3052
- (withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
3780
+ ((withPairingAdmission || !!file.sourceCompletions?.length) && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
3053
3781
  if (identityChanged) {
3054
- if (file.entries.length > 0 || withPairingAdmission) {
3782
+ if (file.entries.length > 0 || file.sourceCompletions?.length || withPairingAdmission || !allowReconciliation) {
3055
3783
  throw createJournalError(
3056
3784
  "identity-mismatch",
3057
3785
  path,
@@ -3205,66 +3933,35 @@ function createJournalStoreCore(
3205
3933
  }
3206
3934
  }
3207
3935
 
3208
- const recoveryDirectory = join(
3209
- dirname(path),
3210
- "recovery",
3211
- `${getNowMs()}-${process.pid}-${randomUUID()}`,
3212
- );
3213
- mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
3214
- const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
3215
- const segmentQuarantinePath = join(
3216
- recoveryDirectory,
3217
- basename(segmentDirectory),
3218
- );
3219
- if (
3220
- snapshotExists &&
3221
- !renameTelegramPathWithRetry(path, snapshotQuarantinePath)
3222
- ) {
3223
- throw createJournalError(
3224
- "io",
3225
- path,
3226
- "could not quarantine an unrecoverable journal snapshot",
3227
- error,
3228
- );
3229
- }
3230
- if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
3231
- if (snapshotExists) {
3232
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
3936
+ // Approved corruption policy: delete damaged evidence instead of quarantining it under recovery/.
3937
+ // Segments go first so a fresh revisionless snapshot can never replay them; a crash repeats the reset.
3938
+ const deletedPaths: string[] = [];
3939
+ const deleteDamaged = (target: string, label: string): void => {
3940
+ try {
3941
+ lstatSync(target);
3942
+ } catch (statError) {
3943
+ if ((statError as NodeJS.ErrnoException).code === "ENOENT") return;
3944
+ throw createJournalError("io", target, `could not inspect an unrecoverable ${label}`, statError);
3233
3945
  }
3234
- throw createJournalError(
3235
- "io",
3236
- segmentDirectory,
3237
- "could not quarantine an unrecoverable journal segment history",
3238
- error,
3239
- );
3240
- }
3946
+ try {
3947
+ rmSync(target, { recursive: true, force: true });
3948
+ } catch (removeError) {
3949
+ throw createJournalError("io", target, `could not delete an unrecoverable ${label}`, removeError);
3950
+ }
3951
+ deletedPaths.push(target);
3952
+ };
3953
+ deleteDamaged(segmentDirectory, "journal segment history");
3954
+ // A regular snapshot is replaced atomically below; anything else cannot be a rename target.
3955
+ if (snapshotExists && !lstatSync(path).isFile()) deleteDamaged(path, "journal snapshot");
3241
3956
  const reset = emptyFile();
3242
3957
  const serialized = serializeJournalFile(reset);
3243
3958
  const serializedBytes = assertCapacity(reset, serialized);
3244
- try {
3245
- writeJournalFile(path, serialized, onPublicationBoundary);
3246
- } catch (publicationError) {
3247
- const segmentsRestored = renameTelegramPathWithRetry(
3248
- segmentQuarantinePath,
3249
- segmentDirectory,
3250
- );
3251
- const snapshotRestored =
3252
- !snapshotExists ||
3253
- renameTelegramPathWithRetry(snapshotQuarantinePath, path);
3254
- if (!segmentsRestored || !snapshotRestored) {
3255
- throw createJournalError(
3256
- "io",
3257
- path,
3258
- "reset publication failed after journal evidence was quarantined",
3259
- publicationError,
3260
- );
3261
- }
3262
- throw publicationError;
3263
- }
3959
+ writeJournalFile(path, serialized, onPublicationBoundary);
3960
+ if (snapshotExists && !deletedPaths.includes(path)) deletedPaths.push(path);
3264
3961
  notifyRecovery({
3265
3962
  kind: "reset",
3266
3963
  path,
3267
- quarantinePath: recoveryDirectory,
3964
+ deletedPaths,
3268
3965
  reason: error.message,
3269
3966
  });
3270
3967
  return { file: reset, exists: true, serializedBytes };
@@ -3315,7 +4012,7 @@ function createJournalStoreCore(
3315
4012
  const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
3316
4013
  const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
3317
4014
  const getFileWork = (file: TelegramUpdateJournalFile): number =>
3318
- file.entries.length + (file.operatorDispositions?.length ?? 0);
4015
+ file.entries.length + (file.operatorDispositions?.length ?? 0) + (file.sourceCompletions?.length ?? 0);
3319
4016
  const advanceInputHeadroomState = (state: InputHeadroomState,
3320
4017
  entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
3321
4018
  const revision = (state.file.revision ?? 0) + 1;
@@ -3491,7 +4188,7 @@ function createJournalStoreCore(
3491
4188
  ): TelegramUpdateJournalSegment => {
3492
4189
  const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
3493
4190
  const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
3494
- return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
4191
+ return { version,
3495
4192
  revision: next.revision!, previousRevision: next.revision! - 1,
3496
4193
  profile: next.profile, botIdentity: next.botIdentity,
3497
4194
  upsertedEntries: next.entries.filter(entry =>
@@ -3521,7 +4218,7 @@ function createJournalStoreCore(
3521
4218
  : { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
3522
4219
  if (publishedSegment) {
3523
4220
  const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
3524
- (publishedSegment.operatorDispositions?.length ?? 0);
4221
+ (publishedSegment.operatorDispositions?.length ?? 0) + (publishedSegment.sourceCompletions?.length ?? 0);
3525
4222
  const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
3526
4223
  baseRetainedCount += 1;
3527
4224
  baseRetainedBytes += segmentBytes;
@@ -3580,9 +4277,10 @@ function createJournalStoreCore(
3580
4277
  const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
3581
4278
  const segmentBytes = Buffer.byteLength(segmentText);
3582
4279
  const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
3583
- segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
3584
- const segmentWork = collections[0]! + collections[1]! + collections[2]!;
3585
- const fileWork = collections[3]! + collections[4]!;
4280
+ segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0,
4281
+ segment.sourceCompletions?.length ?? 0, file.sourceCompletions?.length ?? 0];
4282
+ const segmentWork = collections[0]! + collections[1]! + collections[2]! + collections[5]!;
4283
+ const fileWork = collections[3]! + collections[4]! + collections[6]!;
3586
4284
  const accounting = source.evidence.accounting;
3587
4285
  // Both the segment-first state and snapshot-first cleanup residue must fit.
3588
4286
  assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
@@ -3625,7 +4323,8 @@ function createJournalStoreCore(
3625
4323
  acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
3626
4324
  publicationBoundary = onPublicationBoundary,
3627
4325
  inputHeadroom: number | readonly number[] | "all" | false =
3628
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false,
4326
+ journalHasCustodyFields(version) ? "all" : false,
4327
+ sourceCompletions = current.file.sourceCompletions,
3629
4328
  ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
3630
4329
  const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
3631
4330
  current.file.botIdentity,
@@ -3638,7 +4337,8 @@ function createJournalStoreCore(
3638
4337
  current.file.operatorDispositions ?? [],
3639
4338
  operatorDispositions ?? [],
3640
4339
  ) &&
3641
- current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
4340
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId &&
4341
+ isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? [])
3642
4342
  ) {
3643
4343
  return { file: current.file, serializedBytes: current.serializedBytes };
3644
4344
  }
@@ -3653,21 +4353,24 @@ function createJournalStoreCore(
3653
4353
  ...(operatorDispositions?.length
3654
4354
  ? { operatorDispositions }
3655
4355
  : {}),
4356
+ ...(sourceCompletions?.length ? { sourceCompletions } : {}),
3656
4357
  };
4358
+ assertJournalRoutingInputContinuity(current.file, entries, path);
3657
4359
  if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
3658
4360
  const serialized = serializeJournalFile(file);
3659
4361
  const serializedBytes = assertCapacity(file, serialized);
3660
4362
  const changed =
3661
4363
  contentChanged ||
3662
4364
  current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
3663
- !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
4365
+ !isDeepStrictEqual(current.file.botIdentity, file.botIdentity) ||
4366
+ !isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []);
3664
4367
  if (!changed) {
3665
4368
  return { file, serializedBytes: current.serializedBytes };
3666
4369
  }
3667
4370
  if (!current.exists) {
3668
4371
  if (current.source) {
3669
- const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
3670
- assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
4372
+ const collections = [file.entries.length, file.operatorDispositions?.length ?? 0, file.sourceCompletions?.length ?? 0];
4373
+ assertSourceResources(1, serializedBytes, collections[0]! + collections[1]! + collections[2]!, collections);
3671
4374
  }
3672
4375
  if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
3673
4376
  writeJournalFile(path, serialized, publicationBoundary);
@@ -3709,13 +4412,14 @@ function createJournalStoreCore(
3709
4412
  )
3710
4413
  ? { operatorDispositions: operatorDispositions ?? [] }
3711
4414
  : {}),
4415
+ ...(!isDeepStrictEqual(current.file.sourceCompletions ?? [], sourceCompletions ?? []) ? { sourceCompletions: sourceCompletions ?? [] } : {}),
3712
4416
  };
3713
4417
  if (current.source) {
3714
4418
  if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
3715
4419
  const revisedFile = { ...file, revision };
3716
4420
  if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
3717
4421
  return publishSourceSegment(current, file, segment, publicationBoundary,
3718
- version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
4422
+ journalHasCustodyFields(version));
3719
4423
  }
3720
4424
  publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
3721
4425
  const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
@@ -3768,6 +4472,160 @@ function createJournalStoreCore(
3768
4472
  return { file: revisedFile, serializedBytes };
3769
4473
  };
3770
4474
 
4475
+ const removeCompleted = (updateIds: readonly number[], expectedSources?: readonly TelegramUpdateJournalEntryDigest[], exact = false,
4476
+ completionInput?: readonly TelegramUpdateJournalSourceCompletion[]): TelegramUpdateJournalRemoveResult =>
4477
+ runMutation((readCurrent) => {
4478
+ const current = exact && !sourceAccess ? readCurrentStrict(false) : readCurrent();
4479
+ if (completionInput !== undefined && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
4480
+ const completions = completionInput === undefined ? undefined : validateJournalSourceCompletions(completionInput, path, version);
4481
+ if (exact && expectedSources === undefined) throw createJournalError("invalid", path, "received no exact completion sources");
4482
+ const requestedIds = new Set<number>();
4483
+ for (const updateId of updateIds) {
4484
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "received an invalid removal update id");
4485
+ requestedIds.add(updateId);
4486
+ }
4487
+ if (expectedSources !== undefined) {
4488
+ if (!Array.isArray(expectedSources) || !expectedSources.length) throw createJournalError("invalid", path, "received no exact completion sources");
4489
+ const expectedIds = new Set<number>();
4490
+ for (const expected of expectedSources) {
4491
+ if (!isRecord(expected) || Object.keys(expected).some(key => !["updateId", "sourceSha256"].includes(key)) ||
4492
+ !isSafeNonNegativeInteger(expected.updateId) || !requestedIds.has(expected.updateId) || expectedIds.has(expected.updateId) ||
4493
+ typeof expected.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(expected.sourceSha256)) {
4494
+ throw createJournalError("invalid", path, "received an invalid exact completion source");
4495
+ }
4496
+ expectedIds.add(expected.updateId);
4497
+ const entry = current.file.entries.find(value => value.updateId === expected.updateId);
4498
+ if (!entry || createTelegramUpdateJournalEntryDigest(entry).sourceSha256 !== expected.sourceSha256) {
4499
+ throw createJournalError("conflict", path, "exact completion source changed or disappeared");
4500
+ }
4501
+ }
4502
+ }
4503
+ if (completions) {
4504
+ const guards = new Map((expectedSources ?? []).map(source => [source.updateId, source.sourceSha256]));
4505
+ if (completions.some(completion => guards.get(completion.updateId) !== completion.sourceSha256)) {
4506
+ throw createJournalError("invalid", path, "source completion has no matching exact guard");
4507
+ }
4508
+ }
4509
+ const requestedEntries = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
4510
+ const protectedEntry = requestedEntries.find(entry => entry.state === "failed" || entry.state === "queued");
4511
+ if (protectedEntry) throw createJournalError("conflict", path, protectedEntry.state === "failed"
4512
+ ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
4513
+ : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`);
4514
+ const removedUpdateIds = requestedEntries.map(entry => entry.updateId);
4515
+ const sourceCompletions = completions
4516
+ ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId) : current.file.sourceCompletions;
4517
+ if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
4518
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0,
4519
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, false, sourceCompletions);
4520
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
4521
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
4522
+ });
4523
+ const completeQueued = (receipts: readonly TelegramUpdateJournalQueuedCompletion[],
4524
+ completionInput?: readonly TelegramUpdateJournalSourceCompletion[], exact = false): TelegramUpdateJournalRemoveResult =>
4525
+ runMutation((readCurrent) => {
4526
+ if (exact && !sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
4527
+ const completions = exact ? validateJournalSourceCompletions(completionInput, path, version) : undefined;
4528
+ if (!Array.isArray(receipts) || receipts.length === 0) throw createJournalError("invalid", path, "received no queued receipts to complete");
4529
+ const current = readCurrent();
4530
+ const receiptIds = new Set<string>(), requestedUpdateIds = new Set<number>();
4531
+ for (const receipt of receipts) {
4532
+ if ((receipt.queueKind !== "prompt" && receipt.queueKind !== "control") || !isNonEmptyString(receipt.receiptId) ||
4533
+ receiptIds.has(receipt.receiptId) || !Array.isArray(receipt.sourceUpdateIds) || receipt.sourceUpdateIds.length === 0) {
4534
+ throw createJournalError("invalid", path, "received an invalid queued completion receipt");
4535
+ }
4536
+ receiptIds.add(receipt.receiptId);
4537
+ const queueOwner = validateJournalQueueOwner(receipt.queueOwner, path);
4538
+ if (queueRuntimeIdentity && (queueOwner.instanceId !== queueRuntimeIdentity.instanceId || queueOwner.processId !== queueRuntimeIdentity.processId ||
4539
+ queueOwner.processBirthId !== queueRuntimeIdentity.processBirthId)) {
4540
+ throw createJournalError("conflict", path, `cannot complete foreign queue receipt ${receipt.receiptId}`);
4541
+ }
4542
+ const sourceUpdateIds = new Set<number>();
4543
+ for (const updateId of receipt.sourceUpdateIds) {
4544
+ if (!isSafeNonNegativeInteger(updateId) || sourceUpdateIds.has(updateId) || requestedUpdateIds.has(updateId)) {
4545
+ throw createJournalError("invalid", path, "received overlapping queued completion update ids");
4546
+ }
4547
+ sourceUpdateIds.add(updateId); requestedUpdateIds.add(updateId);
4548
+ }
4549
+ const persistedReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
4550
+ if (persistedReceiptEntries.length !== sourceUpdateIds.size || persistedReceiptEntries.some(entry =>
4551
+ !sourceUpdateIds.has(entry.updateId) || entry.state !== "queued" || entry.queueKind !== receipt.queueKind || !entry.queueOwner ||
4552
+ entry.queueHandoff !== undefined || !areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, queueOwner))) {
4553
+ throw createJournalError("conflict", path, `cannot complete stale or foreign queue receipt ${receipt.receiptId}`);
4554
+ }
4555
+ }
4556
+ const removedEntries = current.file.entries.filter(entry => requestedUpdateIds.has(entry.updateId));
4557
+ const removedUpdateIds = removedEntries.map(entry => entry.updateId);
4558
+ if (removedUpdateIds.length !== requestedUpdateIds.size) throw createJournalError("conflict", path, "queued completion did not resolve every source update");
4559
+ if (completions) {
4560
+ const sources = new Map(removedEntries.map(entry => [entry.updateId, createTelegramUpdateJournalEntryDigest(entry).sourceSha256]));
4561
+ if (completions.some(completion => sources.get(completion.updateId) !== completion.sourceSha256)) {
4562
+ throw createJournalError("conflict", path, "queued completion source changed or belongs to another receipt");
4563
+ }
4564
+ }
4565
+ const sourceCompletions = completions ? [...(current.file.sourceCompletions ?? []), ...completions].sort((a, b) => a.updateId - b.updateId)
4566
+ : current.file.sourceCompletions;
4567
+ if (sourceCompletions?.length) validateJournalSourceCompletions(sourceCompletions, path, version);
4568
+ const published = publishMutation(current, current.file.entries.filter(entry => !requestedUpdateIds.has(entry.updateId)), true,
4569
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId, onPublicationBoundary, exact ? false : undefined, sourceCompletions);
4570
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes,
4571
+ ...(completions ? { sourceCompletions: completions.map(completion => ({ ...completion })) } : {}) };
4572
+ });
4573
+ const mutateRoutingInputs = (input: TelegramRoutingInputAuthority, select: boolean, publishedAtMs?: number) => runMutation(readCurrent => {
4574
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) throw createJournalError("unsupported-version", path, "routing input lifetime requires a private v1 source");
4575
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
4576
+ if (input.journalBindingKey !== binding || !isSafePositiveInteger(input.operatorUserId) ||
4577
+ typeof input.isCurrent !== "function" || !Array.isArray(input.entries) || !input.entries.length || input.entries.length > maxEntries) {
4578
+ throw createJournalError("invalid", path, "invalid routing input lifetime authority");
4579
+ }
4580
+ const assertCurrent = () => { if (!input.isCurrent()) throw createJournalError("conflict", path, "routing input lifetime authority ended"); };
4581
+ assertCurrent();
4582
+ const expected = input.entries.map(entry => validateJournalEntry(entry, path, version));
4583
+ if (new Set(expected.map(entry => entry.updateId)).size !== expected.length) throw createJournalError("invalid", path, "duplicate routing input sources");
4584
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
4585
+ const nowMs = select ? getNowMs() : publishedAtMs;
4586
+ if (!isSafeNonNegativeInteger(nowMs) || !Number.isSafeInteger(nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS) || (!select && nowMs > getNowMs())) throw createJournalError("invalid", path, "invalid routing input lifetime clock");
4587
+ let issued = false;
4588
+ const changed = new Map<number, TelegramUpdateJournalEntry>();
4589
+ const result = expected.map(original => {
4590
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
4591
+ if (!entry || entry.state !== "pending" || original.state !== "pending" || entry.inputClaim || entry.inputProvenance || entry.queueOwner) {
4592
+ throw createJournalError("conflict", path, "routing input source is not unclaimed pending work");
4593
+ }
4594
+ const withoutLifetime = (value: TelegramUpdateJournalEntry) => { const copy = cloneEntry(value); delete copy.routingInput; return copy; };
4595
+ if (!isDeepStrictEqual(entry, original) && (!isDeepStrictEqual(withoutLifetime(entry), withoutLifetime(original)) ||
4596
+ (original.routingInput && !isDeepStrictEqual(original.routingInput, entry.routingInput)))) {
4597
+ throw createJournalError("conflict", path, "routing input source changed");
4598
+ }
4599
+ const lifetime = entry.routingInput;
4600
+ if (lifetime && lifetime.operatorUserId !== input.operatorUserId) throw createJournalError("conflict", path, "routing input belongs to another operator");
4601
+ if (createPendingRetentionReference({ path, journalBindingKey: binding, entry, maxBytes }).read()) {
4602
+ throw createJournalError("conflict", path, "routing input has protected retention");
4603
+ }
4604
+ if (select) {
4605
+ if (!lifetime || (lifetime.phase === "waiting" && nowMs >= lifetime.expiresAtMs)) throw createJournalError("conflict", path, "routing input choice expired or was not armed");
4606
+ if (lifetime.phase === "selected") return cloneEntry(entry);
4607
+ issued = true;
4608
+ const selected = { ...cloneEntry(entry), routingInput: { ...lifetime, phase: "selected" as const } };
4609
+ changed.set(entry.updateId, selected); return cloneEntry(selected);
4610
+ }
4611
+ if (lifetime) {
4612
+ if (lifetime.phase !== "waiting") throw createJournalError("conflict", path, "selected routing input cannot renew its choice");
4613
+ return cloneEntry(entry);
4614
+ }
4615
+ if (nowMs < entry.admittedAtMs) throw createJournalError("conflict", path, "routing input publication predates admission");
4616
+ issued = true;
4617
+ const armed = { ...cloneEntry(entry), routingInput: { operatorUserId: input.operatorUserId,
4618
+ publishedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_ROUTING_INPUT_TTL_MS, phase: "waiting" as const } };
4619
+ changed.set(entry.updateId, armed); return cloneEntry(armed);
4620
+ });
4621
+ assertCurrent();
4622
+ if (select && issued && result.some(entry => !changed.has(entry.updateId))) throw createJournalError("conflict", path, "routing input selection has mixed prior phases");
4623
+ if (changed.size) publishMutation(current, current.file.entries.map(entry => changed.get(entry.updateId) ?? entry), true,
4624
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
4625
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
4626
+ assertCurrent();
4627
+ return { issued, entries: result };
4628
+ });
3771
4629
  const journal: TelegramUpdateJournalStore = {
3772
4630
  read() {
3773
4631
  return runMutation((readCurrent) => {
@@ -3815,11 +4673,12 @@ function createJournalStoreCore(
3815
4673
  );
3816
4674
  const previousAcceptedThroughUpdateId =
3817
4675
  current.file.acceptedThroughUpdateId;
3818
- const discardedUpdateIds = new Set(
3819
- (current.file.operatorDispositions ?? [])
4676
+ const discardedUpdateIds = new Set([
4677
+ ...(current.file.operatorDispositions ?? [])
3820
4678
  .filter((disposition) => disposition.action === "discard")
3821
4679
  .map((disposition) => disposition.updateId),
3822
- );
4680
+ ...(current.file.sourceCompletions ?? []).map(completion => completion.updateId),
4681
+ ]);
3823
4682
  let admittedAtMs: number | undefined;
3824
4683
  const addedUpdateIds: number[] = [];
3825
4684
  const duplicateUpdateIds: number[] = [];
@@ -4069,6 +4928,7 @@ function createJournalStoreCore(
4069
4928
  update: entry.update,
4070
4929
  admittedAtMs: entry.admittedAtMs,
4071
4930
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
4931
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
4072
4932
  state: "queued",
4073
4933
  queueKind: receipt.queueKind,
4074
4934
  queueReceiptId: receipt.receiptId,
@@ -4193,6 +5053,7 @@ function createJournalStoreCore(
4193
5053
  updateId: entry.updateId,
4194
5054
  update: entry.update,
4195
5055
  admittedAtMs: entry.admittedAtMs,
5056
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
4196
5057
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
4197
5058
  state: input.disposition,
4198
5059
  failure,
@@ -4328,9 +5189,9 @@ function createJournalStoreCore(
4328
5189
  applyLegacyCustodyDisposition(authority) {
4329
5190
  return runMutation((readCurrent) => {
4330
5191
  const current = readCurrent();
4331
- if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
5192
+ if (!journalHasCustodyFields(current.file.version))
4332
5193
  throw createJournalError("conflict", path,
4333
- "legacy custody disposition requires a v3 journal");
5194
+ "legacy custody disposition requires a custody journal");
4334
5195
  const existing = current.file.operatorDispositions?.find(
4335
5196
  candidate => candidate.failureId === authority.dispositionId);
4336
5197
  if (existing) {
@@ -4647,116 +5508,8 @@ function createJournalStoreCore(
4647
5508
  };
4648
5509
  });
4649
5510
  },
4650
- completeQueued(receipts) {
4651
- return runMutation((readCurrent) => {
4652
- if (!Array.isArray(receipts) || receipts.length === 0) {
4653
- throw createJournalError(
4654
- "invalid",
4655
- path,
4656
- "received no queued receipts to complete",
4657
- );
4658
- }
4659
- const current = readCurrent();
4660
- const receiptIds = new Set<string>();
4661
- const requestedUpdateIds = new Set<number>();
4662
- for (const receipt of receipts) {
4663
- if (
4664
- (receipt.queueKind !== "prompt" &&
4665
- receipt.queueKind !== "control") ||
4666
- !isNonEmptyString(receipt.receiptId) ||
4667
- receiptIds.has(receipt.receiptId) ||
4668
- !Array.isArray(receipt.sourceUpdateIds) ||
4669
- receipt.sourceUpdateIds.length === 0
4670
- ) {
4671
- throw createJournalError(
4672
- "invalid",
4673
- path,
4674
- "received an invalid queued completion receipt",
4675
- );
4676
- }
4677
- receiptIds.add(receipt.receiptId);
4678
- const queueOwner = validateJournalQueueOwner(
4679
- receipt.queueOwner,
4680
- path,
4681
- );
4682
- if (
4683
- queueRuntimeIdentity &&
4684
- (queueOwner.instanceId !== queueRuntimeIdentity.instanceId ||
4685
- queueOwner.processId !== queueRuntimeIdentity.processId ||
4686
- queueOwner.processBirthId !==
4687
- queueRuntimeIdentity.processBirthId)
4688
- ) {
4689
- throw createJournalError(
4690
- "conflict",
4691
- path,
4692
- `cannot complete foreign queue receipt ${receipt.receiptId}`,
4693
- );
4694
- }
4695
- const sourceUpdateIds = new Set<number>();
4696
- for (const updateId of receipt.sourceUpdateIds) {
4697
- if (
4698
- !isSafeNonNegativeInteger(updateId) ||
4699
- sourceUpdateIds.has(updateId) ||
4700
- requestedUpdateIds.has(updateId)
4701
- ) {
4702
- throw createJournalError(
4703
- "invalid",
4704
- path,
4705
- "received overlapping queued completion update ids",
4706
- );
4707
- }
4708
- sourceUpdateIds.add(updateId);
4709
- requestedUpdateIds.add(updateId);
4710
- }
4711
- const persistedReceiptEntries = current.file.entries.filter(
4712
- (entry) => entry.queueReceiptId === receipt.receiptId,
4713
- );
4714
- if (
4715
- persistedReceiptEntries.length !== sourceUpdateIds.size ||
4716
- persistedReceiptEntries.some(
4717
- (entry) =>
4718
- !sourceUpdateIds.has(entry.updateId) ||
4719
- entry.state !== "queued" ||
4720
- entry.queueKind !== receipt.queueKind ||
4721
- !entry.queueOwner ||
4722
- entry.queueHandoff !== undefined ||
4723
- !areTelegramUpdateJournalQueueOwnersEqual(
4724
- entry.queueOwner,
4725
- queueOwner,
4726
- ),
4727
- )
4728
- ) {
4729
- throw createJournalError(
4730
- "conflict",
4731
- path,
4732
- `cannot complete stale or foreign queue receipt ${receipt.receiptId}`,
4733
- );
4734
- }
4735
- }
4736
- const removedUpdateIds = current.file.entries
4737
- .filter((entry) => requestedUpdateIds.has(entry.updateId))
4738
- .map((entry) => entry.updateId);
4739
- if (removedUpdateIds.length !== requestedUpdateIds.size) {
4740
- throw createJournalError(
4741
- "conflict",
4742
- path,
4743
- "queued completion did not resolve every source update",
4744
- );
4745
- }
4746
- const published = publishMutation(
4747
- current,
4748
- current.file.entries.filter(
4749
- (entry) => !requestedUpdateIds.has(entry.updateId),
4750
- ),
4751
- true,
4752
- );
4753
- return {
4754
- removedUpdateIds,
4755
- entryCount: published.file.entries.length,
4756
- serializedBytes: published.serializedBytes,
4757
- };
4758
- });
4759
- },
5511
+ completeQueued(receipts) { return completeQueued(receipts); },
5512
+ completeQueuedExact(receipts, completions) { return completeQueued(receipts, completions, true); },
4760
5513
  discardQueued(input) {
4761
5514
  return runMutation((readCurrent) => {
4762
5515
  if (
@@ -4957,50 +5710,222 @@ function createJournalStoreCore(
4957
5710
  };
4958
5711
  });
4959
5712
  },
4960
- removeCompleted(updateIds) {
5713
+ inspectAbandonedPending(updateId) {
4961
5714
  return runMutation((readCurrent) => {
4962
- const current = readCurrent();
4963
- const requestedIds = new Set<number>();
4964
- for (const updateId of updateIds) {
4965
- if (!isSafeNonNegativeInteger(updateId)) {
4966
- throw createJournalError(
4967
- "invalid",
4968
- path,
4969
- "received an invalid removal update id",
4970
- );
5715
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5716
+ throw createJournalError("unsupported-version", path, "abandonment inspection requires the legacy v1 source");
5717
+ }
5718
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid abandonment update id");
5719
+ // Inspection never repairs or quarantines source evidence.
5720
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
5721
+ const matches = (current.file.operatorDispositions ?? []).filter(item => item.updateId === updateId);
5722
+ const disposition = matches[0];
5723
+ if (matches.length !== 1 || !disposition || !("dispositionKind" in disposition) ||
5724
+ disposition.dispositionKind !== "legacy-custody" || disposition.action !== "discard" ||
5725
+ disposition.failureId !== `abandon-${disposition.evidenceSha256}` ||
5726
+ current.file.entries.some(entry => entry.updateId === updateId)) return undefined;
5727
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5728
+ const retainedPath = join(`${path}.retained`, `${disposition.failureId}.json`);
5729
+ let stat;
5730
+ try { stat = lstatSync(retainedPath); }
5731
+ catch (error) {
5732
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw createJournalError("conflict", retainedPath, "committed abandonment lost its retained input");
5733
+ throw error;
5734
+ }
5735
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + 4096) {
5736
+ throw createJournalError("invalid", retainedPath, "retained input is not a bounded regular file");
5737
+ }
5738
+ const parsed: unknown = JSON.parse(readFileSync(retainedPath, "utf8"));
5739
+ if (!isRecord(parsed)) throw createJournalError("invalid", retainedPath, "retained input is not an evidence record");
5740
+ const entry = validateJournalEntry(parsed.entry, retainedPath, version);
5741
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry, maxBytes });
5742
+ const retained = reference.read();
5743
+ if (entry.updateId !== updateId || reference.retainedPath !== retainedPath ||
5744
+ !isDeepStrictEqual(retained?.requestedDisposition, disposition)) {
5745
+ throw createJournalError("conflict", retainedPath, "retained input does not match committed abandonment");
5746
+ }
5747
+ return { journalBindingKey, updateId, retainedPath, operatorAuthorityId: disposition.operatorAuthorityId };
5748
+ });
5749
+ },
5750
+ inspectPendingRetention(entry) {
5751
+ return runMutation(() => {
5752
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5753
+ throw createJournalError("unsupported-version", path, "pending retention requires the legacy v1 source");
5754
+ }
5755
+ const expected = validateJournalEntry(entry, path, version);
5756
+ if (expected.state !== "pending") throw createJournalError("conflict", path, "retention inspection requires a pending source");
5757
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5758
+ const reference = createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
5759
+ const retained = reference.read();
5760
+ return retained ? { journalBindingKey, retainedPath: reference.retainedPath,
5761
+ requestedDisposition: retained.requestedDisposition } : undefined;
5762
+ });
5763
+ },
5764
+ abandonPending(input) {
5765
+ return runMutation((readCurrent) => {
5766
+ if (!isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5767
+ throw createJournalError("unsupported-version", path, "pending abandonment requires the legacy v1 source");
5768
+ }
5769
+ const journalBindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5770
+ if (input.journalBindingKey !== journalBindingKey) {
5771
+ throw createJournalError("identity-mismatch", path, "pending abandonment belongs to another source");
5772
+ }
5773
+ if (!isBoundedString(input.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
5774
+ !input.operatorAuthorityId.trim() || typeof input.isCurrent !== "function") {
5775
+ throw createJournalError("invalid", path, "pending abandonment requires explicit operator authority");
5776
+ }
5777
+ const assertCurrent = () => {
5778
+ if (!input.isCurrent()) throw createJournalError("conflict", path, "pending abandonment lost authority");
5779
+ };
5780
+ assertCurrent();
5781
+ const expected = validateJournalEntry(input.entry, path, version);
5782
+ if (expected.state !== "pending" || expected.inputClaim || expected.inputProvenance ||
5783
+ expected.queueOwner || expected.queueHandoff || expected.queueReceiptId || expected.failure) {
5784
+ throw createJournalError("conflict", path, "only an exact unclaimed pending source may be abandoned");
5785
+ }
5786
+ // Never repair/quarantine source evidence while deciding an operator cancellation.
5787
+ const readStrict = sourceAccess ? readCurrent : () => readCurrentStrict(false);
5788
+ const current = readStrict();
5789
+ const { evidenceSha256, failureId, retainedPath, read: readRetention } =
5790
+ createPendingRetentionReference({ path, journalBindingKey, entry: expected, maxBytes });
5791
+ const existing = current.file.operatorDispositions?.find(item =>
5792
+ item.failureId === failureId || item.updateId === expected.updateId);
5793
+ const currentEntry = current.file.entries.find(entry => entry.updateId === expected.updateId);
5794
+ const assertRetention = (retained: TelegramUpdateJournalRetainedInput | undefined,
5795
+ disposition: TelegramUpdateJournalLegacyCustodyDisposition): void => {
5796
+ if (!retained || !isDeepStrictEqual(retained.requestedDisposition, disposition)) {
5797
+ throw createJournalError("conflict", retainedPath, "retained input does not match abandonment evidence");
4971
5798
  }
4972
- requestedIds.add(updateId);
5799
+ };
5800
+ if (existing) {
5801
+ if (currentEntry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" ||
5802
+ existing.failureId !== failureId || existing.action !== "discard" || existing.evidenceSha256 !== evidenceSha256) {
5803
+ throw createJournalError("conflict", path, "pending source already has another disposition");
5804
+ }
5805
+ assertRetention(readRetention(), existing);
5806
+ assertCurrent();
5807
+ return { disposition: existing, retainedPath, duplicate: true,
5808
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
4973
5809
  }
4974
- const requestedEntries = current.file.entries.filter((entry) =>
4975
- requestedIds.has(entry.updateId),
4976
- );
4977
- const protectedEntry = requestedEntries.find(
4978
- (entry) => entry.state === "failed" || entry.state === "queued",
4979
- );
4980
- if (protectedEntry) {
4981
- throw createJournalError(
4982
- "conflict",
4983
- path,
4984
- protectedEntry.state === "failed"
4985
- ? `cannot complete terminal update ${protectedEntry.updateId} without an operator disposition`
4986
- : `cannot complete queued update ${protectedEntry.updateId} without its exact owner receipt`,
4987
- );
5810
+ if (!currentEntry || !isDeepStrictEqual(currentEntry, expected)) {
5811
+ throw createJournalError("conflict", path, "pending source changed before abandonment");
4988
5812
  }
4989
- const removedUpdateIds = requestedEntries.map(
4990
- (entry) => entry.updateId,
4991
- );
4992
- const published = publishMutation(
4993
- current,
4994
- current.file.entries.filter(
4995
- (entry) => !requestedIds.has(entry.updateId),
4996
- ),
4997
- removedUpdateIds.length > 0,
4998
- );
4999
- return {
5000
- removedUpdateIds,
5001
- entryCount: published.file.entries.length,
5002
- serializedBytes: published.serializedBytes,
5813
+ const nowMs = getNowMs();
5814
+ if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path, "invalid abandonment timestamp");
5815
+ const retained = readRetention();
5816
+ let disposition: TelegramUpdateJournalLegacyCustodyDisposition;
5817
+ if (retained) {
5818
+ disposition = retained.requestedDisposition;
5819
+ } else {
5820
+ // Reuse the existing legacy discard tombstone so older v1 readers also refuse replay.
5821
+ // This is an operator disposition, not a fabricated execution failure or completion.
5822
+ disposition = { dispositionKind: "legacy-custody", failureId, updateId: expected.updateId,
5823
+ action: "discard", committedAtMs: nowMs, evidenceSha256,
5824
+ operatorAuthorityId: input.operatorAuthorityId, authorizedAtMs: nowMs };
5825
+ const copy: TelegramUpdateJournalRetainedInput = { version: 1, kind: "pending-input-retention",
5826
+ journalBindingKey, entry: expected, requestedDisposition: disposition };
5827
+ writeJournalFile(retainedPath, `${JSON.stringify(copy)}\n`, (boundary, target) => {
5828
+ onPublicationBoundary?.(boundary, target);
5829
+ assertCurrent();
5830
+ });
5831
+ }
5832
+ assertCurrent();
5833
+ assertRetention(readRetention(), disposition);
5834
+ const committed = (snapshot: ReadTelegramUpdateJournalResult) =>
5835
+ !snapshot.file.entries.some(entry => entry.updateId === expected.updateId) &&
5836
+ snapshot.file.operatorDispositions?.some(item => isDeepStrictEqual(item, disposition));
5837
+ try {
5838
+ const published = publishMutation(current,
5839
+ current.file.entries.filter(entry => entry.updateId !== expected.updateId), true,
5840
+ [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
5841
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
5842
+ return { disposition, retainedPath, duplicate: false,
5843
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5844
+ } catch (error) {
5845
+ // A segment may be durable even if later snapshot compaction failed.
5846
+ // Only exact read-back can turn that unknown acknowledgement into success.
5847
+ let observed: ReadTelegramUpdateJournalResult;
5848
+ try { observed = readStrict(); } catch { throw error; }
5849
+ if (!committed(observed)) throw error;
5850
+ assertRetention(readRetention(), disposition);
5851
+ return { disposition, retainedPath, duplicate: false,
5852
+ entryCount: observed.file.entries.length, serializedBytes: observed.serializedBytes };
5853
+ }
5854
+ });
5855
+ },
5856
+ removeCompleted(updateIds) { return removeCompleted(updateIds); },
5857
+ removeCompletedExact(updateIds, expectedSources, completions) { return removeCompleted(updateIds, expectedSources, true, completions); },
5858
+ inspectQueuedReceipt(expected) {
5859
+ return runMutationCore(readCurrent => {
5860
+ if (!sourceAccess || !isTelegramUpdateJournalLegacyFamilyVersion(version)) {
5861
+ throw createJournalError("invalid", path, "queue receipt observation requires an exact legacy v1 source handle");
5862
+ }
5863
+ const queueOwner = validateQueuedReceiptObservation(expected, path);
5864
+ const current = readCurrent();
5865
+ return inspectJournalQueuedReceipt(current.file, { ...expected, queueOwner }, path);
5866
+ });
5867
+ },
5868
+ inspectSourceCompletion(expected) {
5869
+ return runMutation(readCurrent => {
5870
+ if (!sourceAccess) throw createJournalError("invalid", path, "source completion requires an exact source handle");
5871
+ const completion = validateJournalSourceCompletion(expected, path);
5872
+ const current = readCurrent();
5873
+ return inspectJournalSourceCompletion(current.file, completion, path);
5874
+ });
5875
+ },
5876
+ };
5877
+ if (isTelegramUpdateJournalLegacyFamilyVersion(version)) journal.routingInputs = {
5878
+ arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
5879
+ select(input) { return mutateRoutingInputs(input, true); },
5880
+ inspectExpiry(updateId) {
5881
+ return runMutation(readCurrent => {
5882
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid routing expiry update ID");
5883
+ return inspectRoutingInputExpiry(readCurrent().file,
5884
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
5885
+ });
5886
+ },
5887
+ inspectGroupExpiry(updateIds) {
5888
+ return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file,
5889
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
5890
+ },
5891
+ expire(input) {
5892
+ return runMutation(readCurrent => {
5893
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
5894
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5895
+ if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
5896
+ original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
5897
+ lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
5898
+ throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
5899
+ }
5900
+ const assertCurrent = () => {
5901
+ const now = getNowMs();
5902
+ if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
5903
+ throw createJournalError("conflict", path, "routing source has not expired under current authority");
5003
5904
  };
5905
+ assertCurrent();
5906
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
5907
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
5908
+ const failureId = `routing-expiry:${evidenceSha256}`;
5909
+ const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
5910
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
5911
+ if (existing) {
5912
+ if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
5913
+ existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
5914
+ throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
5915
+ assertCurrent();
5916
+ return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5917
+ }
5918
+ if (!entry || !isDeepStrictEqual(entry, original)) throw createJournalError("conflict", path, "routing source changed before expiry");
5919
+ // A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
5920
+ const now = getNowMs();
5921
+ const disposition: TelegramUpdateJournalLegacyCustodyDisposition = { dispositionKind: "legacy-custody", failureId,
5922
+ updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
5923
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
5924
+ const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true,
5925
+ [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
5926
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
5927
+ assertCurrent();
5928
+ return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5004
5929
  });
5005
5930
  },
5006
5931
  };
@@ -5178,7 +6103,7 @@ function createJournalStoreCore(
5178
6103
  const evidence = readTelegramUpdateJournalSource({
5179
6104
  directory: sourceAccess.directory, path, profile,
5180
6105
  botIdentity: expectedIdentity, limits: sourceAccess.limits,
5181
- version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
6106
+ version,
5182
6107
  });
5183
6108
  return evidence.kind === "present"
5184
6109
  ? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];