@llblab/pi-kit 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -5
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  35. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  36. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  37. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  38. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  39. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  40. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  41. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  42. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  43. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  44. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  45. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  46. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  47. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  50. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  51. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  55. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  56. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  59. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  63. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  64. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  68. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  69. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  70. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  71. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  72. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  73. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  74. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  75. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  76. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  77. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  78. package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -4
  79. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -5
  80. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  81. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  82. package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
  83. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  84. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  85. package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -7
  86. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -5
  87. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  88. package/node_modules/@llblab/pi-telegram/index.ts +4 -1415
  89. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  90. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  91. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +37 -2
  92. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  93. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  94. package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
  95. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
  96. package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
  97. package/node_modules/@llblab/pi-telegram/lib/config.ts +178 -25
  98. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  99. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
  100. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  101. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  102. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  103. package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
  104. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +154 -15
  105. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +74 -40
  106. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  107. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  108. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  110. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  111. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
  112. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -22
  113. package/node_modules/@llblab/pi-telegram/lib/replies.ts +47 -39
  114. package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
  115. package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
  116. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  117. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +315 -7
  118. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  119. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  120. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  121. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  122. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  123. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  124. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  125. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  128. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  129. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  130. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  131. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  132. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  133. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  134. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  135. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  136. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  137. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  138. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  139. package/node_modules/@llblab/skills/package.json +2 -3
  140. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  141. package/package.json +7 -6
@@ -9,6 +9,15 @@
9
9
  import { createHash, randomBytes, randomUUID } from "node:crypto";
10
10
  import {
11
11
  chmodSync,
12
+ constants,
13
+ closeSync,
14
+ fstatSync,
15
+ lstatSync,
16
+ openSync,
17
+ opendirSync,
18
+ readSync,
19
+ realpathSync,
20
+ type BigIntStats,
12
21
  mkdirSync,
13
22
  readFileSync,
14
23
  readdirSync,
@@ -16,8 +25,9 @@ import {
16
25
  statSync,
17
26
  unlinkSync,
18
27
  writeFileSync,
28
+ type Dirent,
19
29
  } from "node:fs";
20
- import { basename, dirname, join } from "node:path";
30
+ import { basename, dirname, join, resolve, relative, isAbsolute, sep } from "node:path";
21
31
  import { isDeepStrictEqual } from "node:util";
22
32
 
23
33
  import {
@@ -28,18 +38,83 @@ import {
28
38
  getTelegramProcessLiveness,
29
39
  type TelegramProcessLiveness,
30
40
  } from "./bus.ts";
31
- import { TELEGRAM_DEFAULT_PROFILE_NAME } from "./paths.ts";
41
+ import {
42
+ getTelegramProfilePathSuffix,
43
+ TELEGRAM_DEFAULT_PROFILE_NAME,
44
+ } from "./paths.ts";
45
+ import {
46
+ runWithTelegramWorkspaceAdmissions,
47
+ type TelegramWorkspaceAdmissionLedger,
48
+ type TelegramWorkspaceAdmissionScope,
49
+ } from "./workspace-admission.ts";
32
50
 
33
51
  export const TELEGRAM_UPDATE_JOURNAL_VERSION = 1 as const;
52
+ export const TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION = 2 as const;
53
+ export const TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION = 3 as const;
54
+ // Receipt and binding identities survive storage-schema upgrades.
55
+ const TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION = 1 as const;
34
56
  export const TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES = 10_000;
35
57
  export const TELEGRAM_UPDATE_JOURNAL_MAX_BYTES = 32 * 1024 * 1024;
36
58
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH = 128;
37
59
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH = 256;
60
+ export const TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH = 256;
61
+ const TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES = 4 * 1024;
62
+ const TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES = 64;
38
63
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH = 128;
39
64
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH = 32;
40
65
  export const TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH = 256;
41
66
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH = 128;
42
67
  export const TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH = 512;
68
+
69
+ export interface TelegramFollowerJournalDiscovery {
70
+ paths: string[];
71
+ complete: boolean;
72
+ }
73
+
74
+ function escapeTelegramJournalPathPattern(value: string): string {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
76
+ }
77
+
78
+ /** Read-only discovery for canonical follower journal snapshots and segment roots. */
79
+ export function discoverTelegramFollowerJournalPaths(input: {
80
+ directory: string;
81
+ profileName?: string;
82
+ }): TelegramFollowerJournalDiscovery {
83
+ const suffix = escapeTelegramJournalPathPattern(
84
+ getTelegramProfilePathSuffix(input.profileName),
85
+ );
86
+ const pattern = new RegExp(
87
+ `^(follower-inbox-[a-f0-9]{16}${suffix}\\.json)(?:\\.segments)?$`,
88
+ "u",
89
+ );
90
+ let entries: Dirent[];
91
+ try {
92
+ entries = readdirSync(input.directory, { withFileTypes: true });
93
+ } catch (error) {
94
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
95
+ ? { paths: [], complete: true }
96
+ : { paths: [], complete: false };
97
+ }
98
+ const paths = new Set<string>();
99
+ let complete = true;
100
+ for (const entry of entries) {
101
+ const match = pattern.exec(entry.name);
102
+ if (!match) continue;
103
+ const candidatePath = join(input.directory, entry.name);
104
+ const expectsDirectory = entry.name.endsWith(".segments");
105
+ try {
106
+ const stat = statSync(candidatePath);
107
+ if (expectsDirectory ? !stat.isDirectory() : !stat.isFile()) {
108
+ complete = false;
109
+ continue;
110
+ }
111
+ paths.add(join(input.directory, match[1]!));
112
+ } catch {
113
+ complete = false;
114
+ }
115
+ }
116
+ return { paths: Array.from(paths).sort(), complete };
117
+ }
43
118
  export const TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH = 256;
44
119
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT = 256;
45
120
  export const TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES = 4 * 1024 * 1024;
@@ -50,7 +125,9 @@ export type TelegramUpdateJournalErrorCode =
50
125
  | "identity-mismatch"
51
126
  | "invalid"
52
127
  | "io"
53
- | "unsupported-version";
128
+ | "unsupported-version"
129
+ | "pairing-evidence"
130
+ | "sender-denied";
54
131
 
55
132
  export class TelegramUpdateJournalError extends Error {
56
133
  readonly code: TelegramUpdateJournalErrorCode;
@@ -81,6 +158,89 @@ export interface TelegramUpdateJournalInput {
81
158
  export type TelegramJournaledUpdate = TelegramUpdateJournalInput &
82
159
  Record<string, unknown>;
83
160
 
161
+ const TELEGRAM_UPDATE_CHAT_CARRIERS = new Set([
162
+ "message",
163
+ "edited_message",
164
+ "channel_post",
165
+ "edited_channel_post",
166
+ "business_message",
167
+ "edited_business_message",
168
+ "message_reaction",
169
+ "message_reaction_count",
170
+ "my_chat_member",
171
+ "chat_member",
172
+ "chat_join_request",
173
+ "chat_boost",
174
+ "removed_chat_boost",
175
+ "deleted_business_messages",
176
+ ]);
177
+
178
+ function getUpdateCarrierScope(
179
+ value: unknown,
180
+ ): TelegramWorkspaceAdmissionScope | undefined {
181
+ if (!isRecord(value) || !isRecord(value.chat)) return undefined;
182
+ const chatId = value.chat.id;
183
+ if (!Number.isSafeInteger(chatId) || chatId === 0) return undefined;
184
+ const threadId = value.message_thread_id;
185
+ if (threadId === undefined) return { kind: "chat", chatId: chatId as number };
186
+ if (!Number.isSafeInteger(threadId) || (threadId as number) <= 0) {
187
+ return undefined;
188
+ }
189
+ return {
190
+ kind: "target",
191
+ target: { chatId: chatId as number, threadId: threadId as number },
192
+ };
193
+ }
194
+
195
+ function getUpdateAdmissionScope(
196
+ update: TelegramUpdateJournalInput & Record<string, unknown>,
197
+ ): TelegramWorkspaceAdmissionScope {
198
+ const payloadKeys = Object.keys(update).filter((key) => key !== "update_id");
199
+ if (payloadKeys.length !== 1) return { kind: "profile" };
200
+ const payloadKey = payloadKeys[0];
201
+ if (TELEGRAM_UPDATE_CHAT_CARRIERS.has(payloadKey)) {
202
+ return getUpdateCarrierScope(update[payloadKey]) ?? { kind: "profile" };
203
+ }
204
+ if (payloadKey === "callback_query") {
205
+ const query = update.callback_query;
206
+ return isRecord(query)
207
+ ? getUpdateCarrierScope(query.message) ?? { kind: "profile" }
208
+ : { kind: "profile" };
209
+ }
210
+ return { kind: "profile" };
211
+ }
212
+
213
+ function getWorkspaceAdmissionScopeKey(
214
+ scope: TelegramWorkspaceAdmissionScope,
215
+ ): string {
216
+ if (scope.kind === "profile") return "profile";
217
+ if (scope.kind === "chat") return `chat:${scope.chatId}`;
218
+ return `target:${scope.target.chatId}:${scope.target.threadId}`;
219
+ }
220
+
221
+ export function getTelegramUpdateJournalAdmissionScopes(
222
+ updates: readonly (TelegramUpdateJournalInput & Record<string, unknown>)[],
223
+ ): TelegramWorkspaceAdmissionScope[] {
224
+ if (updates.length === 0) return [{ kind: "profile" }];
225
+ const scopes = updates.map(getUpdateAdmissionScope);
226
+ if (scopes.some((scope) => scope.kind === "profile")) {
227
+ return [{ kind: "profile" }];
228
+ }
229
+ const chatIds = new Set(
230
+ scopes
231
+ .filter((scope): scope is { kind: "chat"; chatId: number } => scope.kind === "chat")
232
+ .map((scope) => scope.chatId),
233
+ );
234
+ const unique = new Map<string, TelegramWorkspaceAdmissionScope>();
235
+ for (const scope of scopes) {
236
+ if (scope.kind === "target" && chatIds.has(scope.target.chatId)) continue;
237
+ unique.set(getWorkspaceAdmissionScopeKey(scope), scope);
238
+ }
239
+ return Array.from(unique.entries())
240
+ .sort(([left], [right]) => left.localeCompare(right))
241
+ .map(([, scope]) => scope);
242
+ }
243
+
84
244
  export type TelegramUpdateJournalEntryState =
85
245
  | "pending"
86
246
  | "retry-wait"
@@ -116,6 +276,26 @@ export interface TelegramUpdateJournalQueueHandoff {
116
276
  recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
117
277
  }
118
278
 
279
+ export type TelegramUpdateJournalInputHandoff = TelegramUpdateJournalQueueHandoff;
280
+
281
+ /** V3 evidence; decoding it grants neither live execution nor Pi queue authority. */
282
+ export interface TelegramUpdateJournalInputClaim {
283
+ phase: "ready" | "running";
284
+ owner: TelegramUpdateJournalQueueOwner;
285
+ recipientBindingKey: string;
286
+ /** Present freezes ready donor execution until exact acceptance or cancellation. */
287
+ handoff?: TelegramUpdateJournalInputHandoff;
288
+ /** Absent means the original update; present preserves an exact routed projection. */
289
+ executionUpdate?: TelegramJournaledUpdate;
290
+ }
291
+
292
+ /** Immutable transition evidence, never concurrent raw-input execution authority. */
293
+ export interface TelegramUpdateJournalInputProvenance {
294
+ owner: TelegramUpdateJournalQueueOwner;
295
+ recipientBindingKey: string;
296
+ executionUpdate?: TelegramJournaledUpdate;
297
+ }
298
+
119
299
  export interface TelegramUpdateJournalFailure {
120
300
  attemptCount: number;
121
301
  failedAtMs: number;
@@ -127,7 +307,83 @@ export type TelegramUpdateJournalOperatorDispositionAction =
127
307
  | "retry"
128
308
  | "discard";
129
309
 
130
- export interface TelegramUpdateJournalOperatorDisposition {
310
+ export interface TelegramUpdateJournalLegacyCustodyEvidence {
311
+ updateId: number;
312
+ state: "retry-wait" | "failed";
313
+ attemptCount: number;
314
+ failedAtMs: number;
315
+ failureClass: string;
316
+ summary: string;
317
+ nextRetryAtMs?: number;
318
+ terminalAtMs?: number;
319
+ terminalReason?: string;
320
+ terminalFailureId?: string;
321
+ evidenceSha256: string;
322
+ }
323
+
324
+ export interface TelegramUpdateJournalLegacyCustodyCandidate {
325
+ updateId: number;
326
+ state: "retry-wait" | "failed";
327
+ attemptCount: number;
328
+ failureClass: string;
329
+ evidenceSha256: string;
330
+ }
331
+
332
+ export function listTelegramUpdateJournalLegacyCustodyCandidates(
333
+ snapshot: Pick<TelegramUpdateJournalSnapshot, "entries">,
334
+ ): TelegramUpdateJournalLegacyCustodyCandidate[] {
335
+ return snapshot.entries.flatMap(entry => {
336
+ const evidence = createTelegramUpdateJournalLegacyCustodyEvidence(entry);
337
+ return evidence ? [{ updateId: evidence.updateId, state: evidence.state,
338
+ attemptCount: evidence.attemptCount, failureClass: evidence.failureClass,
339
+ evidenceSha256: evidence.evidenceSha256 }] : [];
340
+ }).sort((left, right) => left.updateId - right.updateId);
341
+ }
342
+
343
+ export interface TelegramUpdateJournalLegacyCustodyDispositionAuthority {
344
+ version: 1;
345
+ dispositionId: string;
346
+ updateId: number;
347
+ evidenceSha256: string;
348
+ action: "requeue-v3" | "discard";
349
+ operatorAuthorityId: string;
350
+ authorizedAtMs: number;
351
+ }
352
+
353
+ export function createTelegramUpdateJournalLegacyCustodyEvidence(
354
+ entry: TelegramUpdateJournalEntry,
355
+ ): TelegramUpdateJournalLegacyCustodyEvidence | undefined {
356
+ if ((entry.state !== "retry-wait" && entry.state !== "failed") || !entry.failure ||
357
+ entry.inputClaim || entry.inputProvenance) return undefined;
358
+ const base = { updateId: entry.updateId, state: entry.state,
359
+ attemptCount: entry.failure.attemptCount, failedAtMs: entry.failure.failedAtMs,
360
+ failureClass: entry.failure.failureClass, summary: entry.failure.summary,
361
+ ...(entry.nextRetryAtMs === undefined ? {} : { nextRetryAtMs: entry.nextRetryAtMs }),
362
+ ...(entry.terminalAtMs === undefined ? {} : { terminalAtMs: entry.terminalAtMs }),
363
+ ...(entry.terminalReason === undefined ? {} : { terminalReason: entry.terminalReason }),
364
+ ...(entry.terminalFailureId === undefined ? {} : { terminalFailureId: entry.terminalFailureId }) };
365
+ return { ...base, evidenceSha256: createHash("sha256").update(JSON.stringify(base)).digest("hex") };
366
+ }
367
+
368
+ export function normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
369
+ value: unknown,
370
+ expected: TelegramUpdateJournalLegacyCustodyEvidence,
371
+ ): TelegramUpdateJournalLegacyCustodyDispositionAuthority | undefined {
372
+ if (!isRecord(value)) return undefined;
373
+ const keys = ["version", "dispositionId", "updateId", "evidenceSha256", "action",
374
+ "operatorAuthorityId", "authorizedAtMs"];
375
+ if (Object.keys(value).some(key => !keys.includes(key)) || value.version !== 1 ||
376
+ !isBoundedString(value.dispositionId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
377
+ value.updateId !== expected.updateId || value.evidenceSha256 !== expected.evidenceSha256 ||
378
+ (value.action !== "requeue-v3" && value.action !== "discard") ||
379
+ !isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
380
+ !isSafeNonNegativeInteger(value.authorizedAtMs)) return undefined;
381
+ return { version: 1, dispositionId: value.dispositionId, updateId: expected.updateId,
382
+ evidenceSha256: expected.evidenceSha256, action: value.action,
383
+ operatorAuthorityId: value.operatorAuthorityId, authorizedAtMs: value.authorizedAtMs };
384
+ }
385
+
386
+ export interface TelegramUpdateJournalTerminalOperatorDisposition {
131
387
  failureId: string;
132
388
  updateId: number;
133
389
  action: TelegramUpdateJournalOperatorDispositionAction;
@@ -138,15 +394,34 @@ export interface TelegramUpdateJournalOperatorDisposition {
138
394
  terminalReason: string;
139
395
  }
140
396
 
397
+ export interface TelegramUpdateJournalLegacyCustodyDisposition {
398
+ dispositionKind: "legacy-custody";
399
+ failureId: string;
400
+ updateId: number;
401
+ action: "requeue-v3" | "discard";
402
+ committedAtMs: number;
403
+ evidenceSha256: string;
404
+ operatorAuthorityId: string;
405
+ authorizedAtMs: number;
406
+ }
407
+
408
+ export type TelegramUpdateJournalOperatorDisposition =
409
+ | TelegramUpdateJournalTerminalOperatorDisposition
410
+ | TelegramUpdateJournalLegacyCustodyDisposition;
411
+
141
412
  export interface TelegramUpdateJournalEntry {
142
413
  updateId: number;
143
414
  update: TelegramJournaledUpdate;
415
+ /** Mandatory in v2/v3; immutable veto, never sender authorization. Absent only in legacy v1. */
416
+ preApprovalExcluded?: boolean;
144
417
  admittedAtMs: number;
145
418
  state: TelegramUpdateJournalEntryState;
146
419
  queueKind?: TelegramUpdateJournalQueueKind;
147
420
  queueReceiptId?: string;
148
421
  queueOwner?: TelegramUpdateJournalQueueOwner;
149
422
  queueHandoff?: TelegramUpdateJournalQueueHandoff;
423
+ inputClaim?: TelegramUpdateJournalInputClaim;
424
+ inputProvenance?: TelegramUpdateJournalInputProvenance;
150
425
  failure?: TelegramUpdateJournalFailure;
151
426
  nextRetryAtMs?: number;
152
427
  terminalAtMs?: number;
@@ -155,7 +430,7 @@ export interface TelegramUpdateJournalEntry {
155
430
  }
156
431
 
157
432
  export interface TelegramUpdateJournalFile {
158
- version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
433
+ version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION | typeof TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
159
434
  revision?: number;
160
435
  acceptedThroughUpdateId?: number;
161
436
  profile: string;
@@ -171,6 +446,8 @@ export interface TelegramUpdateJournalSnapshot
171
446
  }
172
447
 
173
448
  export interface TelegramUpdateJournalAppendResult {
449
+ /** Retained batch sources without the immutable veto; not sender authorization. */
450
+ nonExcludedUpdateIds: number[];
174
451
  addedUpdateIds: number[];
175
452
  duplicateUpdateIds: number[];
176
453
  entryCount: number;
@@ -302,8 +579,15 @@ export interface TelegramUpdateJournalOperatorDispositionInput {
302
579
  action: TelegramUpdateJournalOperatorDispositionAction;
303
580
  }
304
581
 
582
+ export interface TelegramUpdateJournalLegacyCustodyDispositionResult {
583
+ disposition: TelegramUpdateJournalLegacyCustodyDisposition;
584
+ duplicate: boolean;
585
+ entryCount: number;
586
+ serializedBytes: number;
587
+ }
588
+
305
589
  export interface TelegramUpdateJournalOperatorDispositionResult {
306
- disposition: TelegramUpdateJournalOperatorDisposition;
590
+ disposition: TelegramUpdateJournalTerminalOperatorDisposition;
307
591
  duplicate: boolean;
308
592
  entryCount: number;
309
593
  serializedBytes: number;
@@ -324,6 +608,9 @@ export interface TelegramUpdateJournalStore {
324
608
  applyOperatorDisposition(
325
609
  input: TelegramUpdateJournalOperatorDispositionInput,
326
610
  ): TelegramUpdateJournalOperatorDispositionResult;
611
+ applyLegacyCustodyDisposition(
612
+ authority: TelegramUpdateJournalLegacyCustodyDispositionAuthority,
613
+ ): TelegramUpdateJournalLegacyCustodyDispositionResult;
327
614
  offerQueuedHandoff(
328
615
  input: TelegramUpdateJournalQueueHandoffInput,
329
616
  ): TelegramUpdateJournalQueueHandoffOfferResult;
@@ -371,20 +658,183 @@ export interface TelegramUpdateJournalStoreOptions {
371
658
  getQueueProcessLiveness?: (
372
659
  owner: TelegramUpdateJournalQueueProcessIdentity,
373
660
  ) => TelegramProcessLiveness;
661
+ /** Optional outer writer fence. Must authorize before source serialization/journal locking and must not perform journal I/O. */
662
+ withWriterAdmission?: <T>(operation: () => T) => T;
663
+ /** Explicit operator authority for quarantined legacy retry/failure disposition. Production omission disables mutation. */
664
+ authorizeLegacyCustodyDisposition?: (
665
+ authority: TelegramUpdateJournalLegacyCustodyDispositionAuthority,
666
+ ) => boolean;
667
+ /** Lock-only synchronous serialization, not source authorization or schema selection. Use the same config resource as admission hooks. */
668
+ withSourceSerialization?: <T>(operation: () => T) => T;
669
+ /** Opt-in strict consumption. Caller binds all gates to the same config resource and excludes other writers. */
670
+ sourceAccess?: {
671
+ directory: string;
672
+ limits: { maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
673
+ };
674
+ /** Opt-in v2 for cursor-ordered polling admission only. Must hold config authority through synchronous publish. */
675
+ withPairingAdmission?: <T>(publish: (preApprovalExcluded: boolean) => T) => T;
676
+ /** Paired-only v1 gate over canonical inputs. Runs inside Workspace admission, before journal locking. */
677
+ withPairedAdmission?: <T>(
678
+ updates: readonly TelegramJournaledUpdate[],
679
+ publish: () => T,
680
+ ) => { admitted: false } | { admitted: true; value: T };
681
+ workspaceAdmission?: Pick<
682
+ TelegramWorkspaceAdmissionLedger,
683
+ "acquireAdmission" | "releaseAdmission"
684
+ >;
374
685
  onPublicationBoundary?: (
375
686
  boundary: TelegramUpdateJournalPublicationBoundary,
376
687
  publicationPath: string,
377
688
  ) => void;
378
689
  }
379
690
 
691
+ export interface TelegramInputJournalSourceReference {
692
+ journalBindingKey: string;
693
+ tokenSha256: string;
694
+ updateId: number;
695
+ }
696
+
697
+ export interface TelegramInputJournalReceipt extends TelegramInputJournalSourceReference {
698
+ owner: TelegramUpdateJournalQueueOwner;
699
+ }
700
+
701
+ export interface TelegramInputJournalReleaseResult {
702
+ released: boolean;
703
+ entryCount: number;
704
+ serializedBytes: number;
705
+ }
706
+
707
+ export interface TelegramInputJournalRecoveryInput {
708
+ receipt: TelegramInputJournalReceipt;
709
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
710
+ }
711
+
712
+ export interface TelegramInputJournalRecoveryResult {
713
+ status: "owner-alive" | "owner-unverifiable" | "unclaimed" | "recovered";
714
+ entryCount: number;
715
+ serializedBytes: number;
716
+ }
717
+
718
+ export interface TelegramInputJournalHandoffOfferInput {
719
+ receipt: TelegramInputJournalReceipt;
720
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
721
+ handoffToken: string;
722
+ }
723
+
724
+ export interface TelegramInputJournalHandoffAcceptInput {
725
+ source: TelegramInputJournalSourceReference;
726
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
727
+ handoffId: string;
728
+ }
729
+
730
+ export interface TelegramInputJournalHandoffCancelInput {
731
+ receipt: TelegramInputJournalReceipt;
732
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
733
+ handoffId: string;
734
+ }
735
+
736
+ export interface TelegramInputJournalQueueInput {
737
+ queueKind: TelegramUpdateJournalQueueKind;
738
+ receiptId: string;
739
+ receipts: readonly TelegramInputJournalReceipt[];
740
+ }
741
+
742
+ export interface TelegramInputJournalQueueResult {
743
+ queued: boolean;
744
+ queueReceipt: TelegramUpdateJournalQueuedCompletion;
745
+ entryCount: number;
746
+ serializedBytes: number;
747
+ }
748
+
749
+ export interface TelegramInputJournalHandoffOfferResult {
750
+ source: TelegramInputJournalSourceReference;
751
+ handoff: TelegramUpdateJournalInputHandoff;
752
+ previousOwner: TelegramUpdateJournalQueueOwner;
753
+ duplicate: boolean;
754
+ entryCount: number;
755
+ serializedBytes: number;
756
+ }
757
+
758
+ export interface TelegramInputJournalHandoffAcceptResult {
759
+ handoffId: string;
760
+ previousOwner?: TelegramUpdateJournalQueueOwner;
761
+ receipt: TelegramInputJournalReceipt;
762
+ duplicate: boolean;
763
+ entryCount: number;
764
+ serializedBytes: number;
765
+ }
766
+
767
+ export interface TelegramInputJournalHandoffCancelResult {
768
+ handoffId: string;
769
+ previousOwner: TelegramUpdateJournalQueueOwner;
770
+ cancelled: boolean;
771
+ entryCount: number;
772
+ serializedBytes: number;
773
+ }
774
+
775
+ export interface TelegramInputJournalStore {
776
+ read: TelegramUpdateJournalStore["read"];
777
+ appendBatch: TelegramUpdateJournalStore["appendBatch"];
778
+ listLegacyCustodyCandidates(): TelegramUpdateJournalLegacyCustodyCandidate[];
779
+ applyLegacyCustodyDisposition: TelegramUpdateJournalStore["applyLegacyCustodyDisposition"];
780
+ /** Removes vetoed input only; an absent ID inside the retained cursor is a no-op, not completion evidence. */
781
+ removeExcluded(updateIds: readonly number[]): TelegramUpdateJournalRemoveResult;
782
+ acquireInput(input: {
783
+ updateId: number;
784
+ recipientBindingKey: string;
785
+ executionUpdate?: TelegramJournaledUpdate;
786
+ }): { acquired: boolean; receipt: TelegramInputJournalReceipt };
787
+ /** Returns this process's exact ready authority to the same unclaimed input. */
788
+ releaseInput(receipt: TelegramInputJournalReceipt): TelegramInputJournalReleaseResult;
789
+ /** Releases exact ready authority only after process-birth liveness proves its owner dead. */
790
+ recoverReadyInput(input: TelegramInputJournalRecoveryInput): TelegramInputJournalRecoveryResult;
791
+ /** Freezes exact ready donor authority around one persisted recipient offer. */
792
+ offerInputHandoff(input: TelegramInputJournalHandoffOfferInput): TelegramInputJournalHandoffOfferResult;
793
+ /** Replaces the offered donor with one exact ready recipient acquisition. */
794
+ acceptInputHandoff(input: TelegramInputJournalHandoffAcceptInput): TelegramInputJournalHandoffAcceptResult;
795
+ /** Unfreezes only the exact unaccepted donor offer. */
796
+ cancelInputHandoff(input: TelegramInputJournalHandoffCancelInput): TelegramInputJournalHandoffCancelResult;
797
+ /** Atomically replaces exact running raw acquisitions with one grouped Pi queue receipt. */
798
+ queueInputs(input: TelegramInputJournalQueueInput): TelegramInputJournalQueueResult;
799
+ completeQueued: TelegramUpdateJournalStore["completeQueued"];
800
+ discardQueued: TelegramUpdateJournalStore["discardQueued"];
801
+ recoverDeadQueueOwner: TelegramUpdateJournalStore["recoverDeadQueueOwner"];
802
+ offerQueuedHandoff: TelegramUpdateJournalStore["offerQueuedHandoff"];
803
+ acceptQueuedHandoff: TelegramUpdateJournalStore["acceptQueuedHandoff"];
804
+ cancelQueuedHandoff: TelegramUpdateJournalStore["cancelQueuedHandoff"];
805
+ /** One durable start transition, not proof that an external effect ran. Publication errors may be commit-unknown. */
806
+ startInput(receipt: TelegramInputJournalReceipt):
807
+ { started: false } | { started: true; update: TelegramJournaledUpdate };
808
+ completeInput(receipt: TelegramInputJournalReceipt): TelegramUpdateJournalRemoveResult;
809
+ }
810
+
811
+ export interface TelegramInputJournalContext {
812
+ owner: TelegramUpdateJournalQueueOwnerIdentity;
813
+ recipientBindingKey: string;
814
+ }
815
+
816
+ export type TelegramInputJournalStoreOptions = Omit<TelegramUpdateJournalStoreOptions, "withPairedAdmission"> &
817
+ Required<Pick<TelegramUpdateJournalStoreOptions,
818
+ "sourceAccess" | "withSourceSerialization" | "withPairingAdmission" | "queueRuntimeIdentity">> & {
819
+ /** Bound originating profile/token/session/recipient context, not transport role; undefined revokes acquisition/start/transfer. */
820
+ getInputContext: () => TelegramInputJournalContext | undefined;
821
+ };
822
+
380
823
  interface ReadTelegramUpdateJournalResult {
381
824
  file: TelegramUpdateJournalFile;
382
825
  exists: boolean;
383
826
  serializedBytes: number;
827
+ source?: JournalSourceAcquisition;
828
+ }
829
+
830
+ interface JournalSourceAcquisition {
831
+ evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily>;
832
+ snapshotRevision: number;
833
+ segments: { name: string; bytes: number; work: number; botId?: number }[];
384
834
  }
385
835
 
386
836
  export interface TelegramUpdateJournalSegment {
387
- version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
837
+ version: TelegramUpdateJournalFile["version"];
388
838
  revision: number;
389
839
  previousRevision: number;
390
840
  acceptedThroughUpdateId?: number;
@@ -653,9 +1103,34 @@ function createTelegramUpdateQueueHandoffId(input: {
653
1103
  .slice(0, 32)}`;
654
1104
  }
655
1105
 
656
- function validateJournalQueueHandoff(
1106
+ function isTelegramInputHandoffId(value: unknown): value is string {
1107
+ return isBoundedString(value, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH) &&
1108
+ /^input-handoff-[a-f0-9]{32}$/u.test(value);
1109
+ }
1110
+
1111
+ function createTelegramInputHandoffId(input: {
1112
+ handoffToken: string;
1113
+ journalBindingKey: string;
1114
+ updateId: number;
1115
+ donorOwner: TelegramUpdateJournalQueueOwner;
1116
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
1117
+ recipientBindingKey: string;
1118
+ }): string {
1119
+ return `input-handoff-${createHash("sha256").update(JSON.stringify({
1120
+ version: 1,
1121
+ token: input.handoffToken,
1122
+ source: input.journalBindingKey,
1123
+ updateId: input.updateId,
1124
+ donorOwner: input.donorOwner,
1125
+ recipientOwner: input.recipientOwner,
1126
+ recipientBindingKey: input.recipientBindingKey,
1127
+ })).digest("hex").slice(0, 32)}`;
1128
+ }
1129
+
1130
+ function validateJournalHandoff(
657
1131
  value: unknown,
658
1132
  path: string,
1133
+ kind: "queue" | "input",
659
1134
  ): TelegramUpdateJournalQueueHandoff {
660
1135
  if (
661
1136
  !isRecord(value) ||
@@ -669,7 +1144,7 @@ function validateJournalQueueHandoff(
669
1144
  throw createJournalError(
670
1145
  "invalid",
671
1146
  path,
672
- "contains invalid queue handoff metadata",
1147
+ `contains invalid ${kind} handoff metadata`,
673
1148
  );
674
1149
  }
675
1150
  return {
@@ -746,59 +1221,107 @@ function validateJournalOperatorDisposition(
746
1221
  value: unknown,
747
1222
  path: string,
748
1223
  ): TelegramUpdateJournalOperatorDisposition {
749
- if (
750
- !isRecord(value) ||
751
- !hasOnlyKeys(value, [
752
- "failureId",
753
- "updateId",
754
- "action",
755
- "committedAtMs",
756
- "attemptCount",
757
- "failureClass",
758
- "terminalAtMs",
759
- "terminalReason",
760
- ]) ||
761
- !isBoundedString(
762
- value.failureId,
763
- TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH,
764
- ) ||
1224
+ if (isRecord(value) && value.dispositionKind === "legacy-custody") {
1225
+ if (!hasOnlyKeys(value, ["dispositionKind", "failureId", "updateId", "action",
1226
+ "committedAtMs", "evidenceSha256", "operatorAuthorityId", "authorizedAtMs"]) ||
1227
+ !isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
1228
+ !isSafeNonNegativeInteger(value.updateId) ||
1229
+ (value.action !== "requeue-v3" && value.action !== "discard") ||
1230
+ !isSafeNonNegativeInteger(value.committedAtMs) ||
1231
+ typeof value.evidenceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.evidenceSha256) ||
1232
+ !isBoundedString(value.operatorAuthorityId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
1233
+ !isSafeNonNegativeInteger(value.authorizedAtMs) || value.committedAtMs < value.authorizedAtMs)
1234
+ throw createJournalError("invalid", path,
1235
+ "contains invalid legacy custody disposition metadata");
1236
+ return { dispositionKind: "legacy-custody", failureId: value.failureId,
1237
+ updateId: value.updateId, action: value.action, committedAtMs: value.committedAtMs,
1238
+ evidenceSha256: value.evidenceSha256, operatorAuthorityId: value.operatorAuthorityId,
1239
+ authorizedAtMs: value.authorizedAtMs };
1240
+ }
1241
+ if (!isRecord(value) || !hasOnlyKeys(value, ["failureId", "updateId", "action",
1242
+ "committedAtMs", "attemptCount", "failureClass", "terminalAtMs", "terminalReason"]) ||
1243
+ !isBoundedString(value.failureId, TELEGRAM_UPDATE_JOURNAL_FAILURE_ID_MAX_LENGTH) ||
765
1244
  !isSafeNonNegativeInteger(value.updateId) ||
766
1245
  (value.action !== "retry" && value.action !== "discard") ||
767
- !isSafeNonNegativeInteger(value.committedAtMs) ||
768
- !isSafePositiveInteger(value.attemptCount) ||
769
- !isBoundedString(
770
- value.failureClass,
771
- TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH,
772
- ) ||
773
- !isSafeNonNegativeInteger(value.terminalAtMs) ||
774
- value.committedAtMs < value.terminalAtMs ||
775
- !isBoundedString(
776
- value.terminalReason,
777
- TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH,
778
- )
779
- ) {
780
- throw createJournalError(
781
- "invalid",
782
- path,
783
- "contains invalid operator disposition metadata",
784
- );
1246
+ !isSafeNonNegativeInteger(value.committedAtMs) || !isSafePositiveInteger(value.attemptCount) ||
1247
+ !isBoundedString(value.failureClass, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH) ||
1248
+ !isSafeNonNegativeInteger(value.terminalAtMs) || value.committedAtMs < value.terminalAtMs ||
1249
+ !isBoundedString(value.terminalReason, TELEGRAM_UPDATE_JOURNAL_TERMINAL_REASON_MAX_LENGTH))
1250
+ throw createJournalError("invalid", path, "contains invalid operator disposition metadata");
1251
+ return { failureId: value.failureId, updateId: value.updateId, action: value.action,
1252
+ committedAtMs: value.committedAtMs, attemptCount: value.attemptCount,
1253
+ failureClass: value.failureClass, terminalAtMs: value.terminalAtMs,
1254
+ terminalReason: value.terminalReason };
1255
+ }
1256
+
1257
+ function journalRequiresExclusion(version: TelegramUpdateJournalFile["version"]): boolean {
1258
+ return version === TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION ||
1259
+ version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION;
1260
+ }
1261
+
1262
+ function validateJournalInputClaim(
1263
+ value: unknown, path: string, updateId: number,
1264
+ ): TelegramUpdateJournalInputClaim {
1265
+ if (!isRecord(value) ||
1266
+ !hasOnlyKeys(value, ["phase", "owner", "recipientBindingKey", "handoff", "executionUpdate"]) ||
1267
+ (value.phase !== "ready" && value.phase !== "running") ||
1268
+ !isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
1269
+ !value.recipientBindingKey.trim()) {
1270
+ throw createJournalError("invalid", path, "contains invalid input claim metadata");
1271
+ }
1272
+ const owner = validateJournalQueueOwner(value.owner, path);
1273
+ const handoff = value.handoff === undefined ? undefined : validateJournalHandoff(value.handoff, path, "input");
1274
+ if ((owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) ||
1275
+ (handoff && (!isTelegramInputHandoffId(handoff.handoffId) || value.phase !== "ready" ||
1276
+ isTelegramUpdateJournalQueueOwnerProcess(owner, handoff.recipientOwner)))) {
1277
+ throw createJournalError("invalid", path, "contains conflicting input handoff metadata");
1278
+ }
1279
+ const executionUpdate = value.executionUpdate === undefined
1280
+ ? undefined : validateJournaledUpdate(value.executionUpdate, path);
1281
+ if (executionUpdate && executionUpdate.update_id !== updateId) {
1282
+ throw createJournalError("invalid", path, "contains an input claim/update id mismatch");
785
1283
  }
786
1284
  return {
787
- failureId: value.failureId,
788
- updateId: value.updateId,
789
- action: value.action,
790
- committedAtMs: value.committedAtMs,
791
- attemptCount: value.attemptCount,
792
- failureClass: value.failureClass,
793
- terminalAtMs: value.terminalAtMs,
794
- terminalReason: value.terminalReason,
1285
+ phase: value.phase,
1286
+ owner,
1287
+ recipientBindingKey: value.recipientBindingKey,
1288
+ ...(handoff ? { handoff } : {}),
1289
+ ...(executionUpdate ? { executionUpdate } : {}),
795
1290
  };
796
1291
  }
797
1292
 
1293
+ function validateJournalInputProvenance(
1294
+ value: unknown, path: string, updateId: number,
1295
+ ): TelegramUpdateJournalInputProvenance {
1296
+ if (!isRecord(value) ||
1297
+ !hasOnlyKeys(value, ["owner", "recipientBindingKey", "executionUpdate"]) ||
1298
+ !isBoundedString(value.recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
1299
+ !value.recipientBindingKey.trim()) {
1300
+ throw createJournalError("invalid", path, "contains invalid input queue provenance");
1301
+ }
1302
+ const owner = validateJournalQueueOwner(value.owner, path);
1303
+ if (owner.handoffId !== undefined && !isTelegramInputHandoffId(owner.handoffId)) {
1304
+ throw createJournalError("invalid", path, "contains invalid input queue provenance");
1305
+ }
1306
+ const executionUpdate = value.executionUpdate === undefined
1307
+ ? undefined : validateJournaledUpdate(value.executionUpdate, path);
1308
+ if (executionUpdate && executionUpdate.update_id !== updateId) {
1309
+ throw createJournalError("invalid", path, "contains an input provenance/update id mismatch");
1310
+ }
1311
+ return { owner, recipientBindingKey: value.recipientBindingKey,
1312
+ ...(executionUpdate ? { executionUpdate } : {}) };
1313
+ }
1314
+
798
1315
  function validateJournalEntry(
799
1316
  value: unknown,
800
1317
  path: string,
1318
+ version: TelegramUpdateJournalFile["version"],
801
1319
  ): TelegramUpdateJournalEntry {
1320
+ if (journalRequiresExclusion(version) &&
1321
+ (!isRecord(value) || typeof value.preApprovalExcluded !== "boolean" ||
1322
+ (value.preApprovalExcluded && value.state === "queued"))) {
1323
+ throw createJournalError("pairing-evidence", path, "contains missing, malformed, or queued exclusion evidence");
1324
+ }
802
1325
  if (
803
1326
  !isRecord(value) ||
804
1327
  !hasOnlyKeys(value, [
@@ -815,6 +1338,9 @@ function validateJournalEntry(
815
1338
  "terminalAtMs",
816
1339
  "terminalReason",
817
1340
  "terminalFailureId",
1341
+ ...(journalRequiresExclusion(version) ? ["preApprovalExcluded"] : []),
1342
+ ...(version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
1343
+ ? ["inputClaim", "inputProvenance"] : []),
818
1344
  ]) ||
819
1345
  !isSafeNonNegativeInteger(value.updateId) ||
820
1346
  !isSafeNonNegativeInteger(value.admittedAtMs) ||
@@ -872,7 +1398,7 @@ function validateJournalEntry(
872
1398
  : undefined;
873
1399
  const queueHandoff =
874
1400
  value.state === "queued" && value.queueHandoff !== undefined
875
- ? validateJournalQueueHandoff(value.queueHandoff, path)
1401
+ ? validateJournalHandoff(value.queueHandoff, path, "queue")
876
1402
  : undefined;
877
1403
  if (queueHandoff && !queueOwner) {
878
1404
  throw createJournalError(
@@ -881,6 +1407,19 @@ function validateJournalEntry(
881
1407
  "contains a queue handoff without donor authority",
882
1408
  );
883
1409
  }
1410
+ if (value.preApprovalExcluded === true &&
1411
+ (value.inputClaim !== undefined || value.inputProvenance !== undefined)) {
1412
+ throw createJournalError("pairing-evidence", path, "contains claimed exclusion evidence");
1413
+ }
1414
+ const inputClaim = value.inputClaim === undefined
1415
+ ? undefined : validateJournalInputClaim(value.inputClaim, path, value.updateId);
1416
+ const inputProvenance = value.inputProvenance === undefined
1417
+ ? undefined : validateJournalInputProvenance(value.inputProvenance, path, value.updateId);
1418
+ if ((inputClaim && (value.state === "queued" ||
1419
+ (inputClaim.phase === "running" && value.state !== "pending"))) ||
1420
+ (inputProvenance && (value.state !== "queued" || inputClaim))) {
1421
+ throw createJournalError("invalid", path, "contains conflicting input claim state");
1422
+ }
884
1423
  let failure: TelegramUpdateJournalFailure | undefined;
885
1424
  if (value.state === "retry-wait" || value.state === "failed") {
886
1425
  if (hasQueueMetadata) {
@@ -930,6 +1469,10 @@ function validateJournalEntry(
930
1469
  return {
931
1470
  updateId: value.updateId,
932
1471
  update,
1472
+ ...(journalRequiresExclusion(version)
1473
+ ? { preApprovalExcluded: value.preApprovalExcluded as boolean } : {}),
1474
+ ...(inputClaim ? { inputClaim } : {}),
1475
+ ...(inputProvenance ? { inputProvenance } : {}),
933
1476
  admittedAtMs: value.admittedAtMs,
934
1477
  state: value.state,
935
1478
  ...(queueKind === "prompt" || queueKind === "control"
@@ -969,10 +1512,26 @@ function validateJournalEntry(
969
1512
  };
970
1513
  }
971
1514
 
1515
+ function assertSupportedJournalVersion(
1516
+ value: unknown, path: string,
1517
+ version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
1518
+ ): void {
1519
+ if (isRecord(value) && Number.isSafeInteger(value.version) &&
1520
+ value.version !== version) {
1521
+ throw createJournalError("unsupported-version", path, `uses unsupported version ${String(value.version)}`);
1522
+ }
1523
+ }
1524
+
972
1525
  function parseJournalFile(
973
1526
  value: unknown,
974
1527
  path: string,
1528
+ version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
975
1529
  ): TelegramUpdateJournalFile {
1530
+ assertSupportedJournalVersion(value, path, version);
1531
+ if (journalRequiresExclusion(version) &&
1532
+ (!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
1533
+ throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
1534
+ }
976
1535
  if (
977
1536
  !isRecord(value) ||
978
1537
  !hasOnlyKeys(value, [
@@ -996,15 +1555,8 @@ function parseJournalFile(
996
1555
  ) {
997
1556
  throw createJournalError("invalid", path, "has a malformed schema");
998
1557
  }
999
- if (value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION) {
1000
- throw createJournalError(
1001
- "unsupported-version",
1002
- path,
1003
- `uses unsupported version ${String(value.version)}`,
1004
- );
1005
- }
1006
1558
  const entries = value.entries.map((entry) =>
1007
- validateJournalEntry(entry, path),
1559
+ validateJournalEntry(entry, path, version),
1008
1560
  );
1009
1561
  for (let index = 1; index < entries.length; index += 1) {
1010
1562
  if (entries[index]!.updateId <= entries[index - 1]!.updateId) {
@@ -1109,7 +1661,7 @@ function parseJournalFile(
1109
1661
  dispositionFailureIds.add(disposition.failureId);
1110
1662
  }
1111
1663
  return {
1112
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1664
+ version,
1113
1665
  ...(value.revision !== undefined
1114
1666
  ? { revision: value.revision as number }
1115
1667
  : {}),
@@ -1126,7 +1678,13 @@ function parseJournalFile(
1126
1678
  function parseJournalSegment(
1127
1679
  value: unknown,
1128
1680
  path: string,
1681
+ version: TelegramUpdateJournalFile["version"] = TELEGRAM_UPDATE_JOURNAL_VERSION,
1129
1682
  ): TelegramUpdateJournalSegment {
1683
+ assertSupportedJournalVersion(value, path, version);
1684
+ if (journalRequiresExclusion(version) &&
1685
+ (!isRecord(value) || !isSafeNonNegativeInteger(value.acceptedThroughUpdateId))) {
1686
+ throw createJournalError("pairing-evidence", path, "is missing its exclusion-mode admission cursor");
1687
+ }
1130
1688
  if (
1131
1689
  !isRecord(value) ||
1132
1690
  !hasOnlyKeys(value, [
@@ -1140,7 +1698,7 @@ function parseJournalSegment(
1140
1698
  "removedUpdateIds",
1141
1699
  "operatorDispositions",
1142
1700
  ]) ||
1143
- value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION ||
1701
+ value.version !== version ||
1144
1702
  !isSafePositiveInteger(value.revision) ||
1145
1703
  !isSafeNonNegativeInteger(value.previousRevision) ||
1146
1704
  (value.acceptedThroughUpdateId !== undefined &&
@@ -1154,7 +1712,7 @@ function parseJournalSegment(
1154
1712
  throw createJournalError("invalid", path, "has a malformed segment schema");
1155
1713
  }
1156
1714
  const upsertedEntries = value.upsertedEntries.map((entry) =>
1157
- validateJournalEntry(entry, path),
1715
+ validateJournalEntry(entry, path, version),
1158
1716
  );
1159
1717
  const upsertedIds = new Set<number>();
1160
1718
  for (const entry of upsertedEntries) {
@@ -1167,7 +1725,7 @@ function parseJournalSegment(
1167
1725
  upsertedEntries.length > 0 &&
1168
1726
  value.acceptedThroughUpdateId !== undefined &&
1169
1727
  value.acceptedThroughUpdateId <
1170
- Math.max(...upsertedEntries.map((entry) => entry.updateId))
1728
+ upsertedEntries.reduce((maximum, entry) => Math.max(maximum, entry.updateId), 0)
1171
1729
  ) {
1172
1730
  throw createJournalError(
1173
1731
  "invalid",
@@ -1194,7 +1752,7 @@ function parseJournalSegment(
1194
1752
  validateJournalOperatorDisposition(disposition, path),
1195
1753
  );
1196
1754
  return {
1197
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1755
+ version,
1198
1756
  revision: value.revision,
1199
1757
  previousRevision: value.previousRevision,
1200
1758
  ...(value.acceptedThroughUpdateId !== undefined
@@ -1210,10 +1768,365 @@ function parseJournalSegment(
1210
1768
  };
1211
1769
  }
1212
1770
 
1771
+ /**
1772
+ * Isolated evidence only: caller must serialize/quiesce writers before inspection
1773
+ * and consumption. Metadata checks detect observable changes, not hostile same-user
1774
+ * swaps or whole-profile completeness. No locks, recovery, or publication occurs.
1775
+ * maxFiles counts snapshot + every enumerated segment entry (one overflow witness).
1776
+ * maxBytes aggregates all retained bytes. maxEntries bounds each raw collection and
1777
+ * reconstructed collection; maxWork charges each raw/revalidated collection element.
1778
+ * JSON allocation is bounded by maxBytes before decoding; collections before codecs.
1779
+ */
1780
+ export function inspectTelegramInputCustodySourceStatus(
1781
+ input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
1782
+ ): "absent" | "v3" | "legacy" | "unsupported" | "ambiguous" {
1783
+ try {
1784
+ const evidence = inspectTelegramUpdateJournalFamily(input);
1785
+ if (evidence.kind === "absent") return "absent";
1786
+ if (evidence.file.version === 3) return "v3";
1787
+ if (evidence.file.version === 1 || evidence.file.version === 2) return "legacy";
1788
+ return "unsupported";
1789
+ } catch {
1790
+ return "ambiguous";
1791
+ }
1792
+ }
1793
+
1794
+ export function inspectTelegramUpdateJournalFamily(input: {
1795
+ directory: string;
1796
+ path: string;
1797
+ profile: string;
1798
+ botIdentity: TelegramUpdateJournalBotIdentity;
1799
+ limits: { maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
1800
+ }): { kind: "absent" } | {
1801
+ kind: "present";
1802
+ file: TelegramUpdateJournalFile;
1803
+ /** Validation constraint includes the caller's input, not only observed IDs. */
1804
+ knownBotId?: number;
1805
+ accounting: { files: number; bytes: number; work: number };
1806
+ } {
1807
+ return acquireTelegramUpdateJournalFamily(input).evidence;
1808
+ }
1809
+
1810
+ /**
1811
+ * Read-only source evidence for cooperating writers serialized by the caller through
1812
+ * consumption; never readiness, recovery, or permission to publish. Ancestors retain
1813
+ * canonical directory type and endpoint dev/ino/mode/uid/gid, tolerating sibling churn.
1814
+ * This deliberately loses ancestor size/nlink/mtime/ctime witnesses: no transient
1815
+ * namespace/permission/ACL continuity, inode-ABA resistance, or hostile-same-user
1816
+ * protection. Manual relocation/restore/security manipulation is outside the protocol.
1817
+ * Files and the segment directory retain full inspection checks and bounded census.
1818
+ * Identity is never enriched; accounting is physical inspection work, not store capacity.
1819
+ */
1820
+ export function readTelegramUpdateJournalSource(
1821
+ input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0] & { version: TelegramUpdateJournalFile["version"] },
1822
+ ): ReturnType<typeof inspectTelegramUpdateJournalFamily> {
1823
+ const version = input.version;
1824
+ if (version !== 1 && version !== 2 && version !== 3) {
1825
+ throw createJournalError("unsupported-version", input.path, "requires a selected source version");
1826
+ }
1827
+ return acquireTelegramUpdateJournalFamily({
1828
+ ...input,
1829
+ botIdentity: { ...input.botIdentity },
1830
+ limits: { ...input.limits },
1831
+ }, version).evidence;
1832
+ }
1833
+
1834
+ function acquireTelegramUpdateJournalFamily(
1835
+ input: Parameters<typeof inspectTelegramUpdateJournalFamily>[0],
1836
+ selectedVersion?: TelegramUpdateJournalFile["version"],
1837
+ ): JournalSourceAcquisition {
1838
+ const { path, limits } = input;
1839
+ const acquiredSegments: JournalSourceAcquisition["segments"] = [];
1840
+ let snapshotRevision = 0;
1841
+ const fail = (message: string): never => { throw createJournalError("invalid", path, message); };
1842
+ const capacity = (): never => { throw createJournalError("capacity", path, "exceeds inspection resource limits"); };
1843
+ for (const key of ["maxFiles", "maxBytes", "maxEntries", "maxWork"] as const) {
1844
+ if (!isSafePositiveInteger(limits[key])) fail("requires positive safe-integer limits");
1845
+ }
1846
+ const expected = validateBotIdentity(input.botIdentity, path);
1847
+ if (!isNonEmptyString(input.profile)) fail("requires an exact profile");
1848
+ const anchor = input.directory;
1849
+ const contained = relative(anchor, path);
1850
+ if (!isAbsolute(anchor) || resolve(anchor) !== anchor || !isAbsolute(path) ||
1851
+ resolve(path) !== path || !contained || contained === ".." ||
1852
+ contained.startsWith(`..${sep}`) || isAbsolute(contained)) fail("escapes its canonical directory anchor");
1853
+ if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK) fail("platform lacks no-follow nonblocking open evidence");
1854
+ let files = 0;
1855
+ let bytes = 0;
1856
+ let work = 0;
1857
+ const observed = new Map<string, BigIntStats | undefined>();
1858
+ const ancestors = new Set<string>();
1859
+ const sameAncestor = (a: BigIntStats, b: BigIntStats) =>
1860
+ b.isDirectory() && !b.isSymbolicLink() && a.dev === b.dev && a.ino === b.ino &&
1861
+ a.mode === b.mode && a.uid === b.uid && a.gid === b.gid;
1862
+ const same = (a: BigIntStats, b: BigIntStats) =>
1863
+ a.dev === b.dev && a.ino === b.ino && a.mode === b.mode &&
1864
+ a.nlink === b.nlink && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
1865
+ const status = (target: string): BigIntStats | undefined => {
1866
+ try { return lstatSync(target, { bigint: true }); }
1867
+ catch (error) {
1868
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1869
+ throw error;
1870
+ }
1871
+ };
1872
+ const observe = (target: string, directory: boolean, optional = false) => {
1873
+ const value = status(target);
1874
+ if (!value && !optional) fail("has a missing path component");
1875
+ if (value && (value.isSymbolicLink() || !(directory ? value.isDirectory() : value.isFile()))) fail("has a linked or unexpected file type");
1876
+ observed.set(target, value);
1877
+ return value;
1878
+ };
1879
+ const charge = (value: unknown) => {
1880
+ if (!isRecord(value)) return;
1881
+ for (const key of ["entries", "upsertedEntries", "removedUpdateIds", "operatorDispositions"]) {
1882
+ const collection = value[key];
1883
+ if (!Array.isArray(collection)) continue;
1884
+ if (collection.length > limits.maxEntries || collection.length > limits.maxWork - work) capacity();
1885
+ work += collection.length;
1886
+ }
1887
+ };
1888
+ const read = (target: string, before: BigIntStats): unknown => {
1889
+ if (before.size > BigInt(limits.maxBytes - bytes)) capacity();
1890
+ const fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1891
+ try {
1892
+ const opened = fstatSync(fd, { bigint: true });
1893
+ if (!opened.isFile() || !same(before, opened)) fail("changed before handle acquisition");
1894
+ const length = Number(opened.size);
1895
+ const buffer = Buffer.alloc(length);
1896
+ let offset = 0;
1897
+ while (offset < length) {
1898
+ const count = readSync(fd, buffer, offset, length - offset, offset);
1899
+ if (!count) fail("disappeared or shrank during read");
1900
+ offset += count;
1901
+ }
1902
+ if (readSync(fd, Buffer.alloc(1), 0, 1, length) !== 0 ||
1903
+ !same(opened, fstatSync(fd, { bigint: true }))) fail("changed during handle read");
1904
+ const after = status(target);
1905
+ if (!after || !same(opened, after)) fail("changed path during read");
1906
+ bytes += length;
1907
+ const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer));
1908
+ charge(value);
1909
+ return value;
1910
+ } finally { closeSync(fd); }
1911
+ };
1912
+ const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
1913
+ const enumerate = (): string[] => {
1914
+ const names: string[] = [];
1915
+ const directory = opendirSync(segmentDirectory, { bufferSize: 1 });
1916
+ try {
1917
+ for (;;) {
1918
+ const entry = directory.readSync();
1919
+ 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");
1922
+ names.push(entry.name);
1923
+ }
1924
+ } finally { directory.closeSync(); }
1925
+ return names.sort();
1926
+ };
1927
+ try {
1928
+ observe(anchor, true);
1929
+ ancestors.add(anchor);
1930
+ if (realpathSync(anchor) !== anchor) fail("requires a canonical directory anchor");
1931
+ let parent = anchor;
1932
+ for (const component of relative(anchor, dirname(path)).split(sep).filter(Boolean)) {
1933
+ parent = join(parent, component);
1934
+ observe(parent, true);
1935
+ ancestors.add(parent);
1936
+ }
1937
+ const snapshot = observe(path, false, true);
1938
+ const segments = observe(segmentDirectory, true, true);
1939
+ if (!snapshot && segments) fail("retains segments without a snapshot");
1940
+ files = snapshot ? 1 : 0;
1941
+ const names = segments ? enumerate() : [];
1942
+ let file: TelegramUpdateJournalFile | undefined;
1943
+ let knownBotId = expected.botId;
1944
+ const identity = (value: { profile: string; botIdentity: TelegramUpdateJournalBotIdentity }) => {
1945
+ if (value.profile !== input.profile || value.botIdentity.tokenSha256 !== expected.tokenSha256 ||
1946
+ (knownBotId !== undefined && value.botIdentity.botId !== undefined && knownBotId !== value.botIdentity.botId)) {
1947
+ throw createJournalError("identity-mismatch", path, "belongs to another exact journal identity");
1948
+ }
1949
+ knownBotId ??= value.botIdentity.botId;
1950
+ };
1951
+ if (snapshot) {
1952
+ const raw = read(path, snapshot);
1953
+ if (!isRecord(raw) || (raw.version !== 1 && raw.version !== 2 && raw.version !== 3)) {
1954
+ throw createJournalError("unsupported-version", path, "has an unsupported snapshot version");
1955
+ }
1956
+ file = parseJournalFile(raw, path, raw.version);
1957
+ snapshotRevision = file.revision ?? 0;
1958
+ identity(file);
1959
+ for (const name of names) {
1960
+ const target = join(segmentDirectory, name);
1961
+ const metadata = observe(target, false)!;
1962
+ const previousWork = work;
1963
+ const segment = parseJournalSegment(read(target, metadata), target, file.version);
1964
+ acquiredSegments.push({ name, bytes: Number(metadata.size), work: work - previousWork,
1965
+ ...(segment.botIdentity.botId !== undefined ? { botId: segment.botIdentity.botId } : {}) });
1966
+ files += 1;
1967
+ identity(segment);
1968
+ if (segment.revision !== Number(name.slice(0, 16)) || segment.previousRevision !== segment.revision - 1) fail("has an invalid intrinsic segment revision");
1969
+ const dispositionIds = new Set<string>();
1970
+ for (const disposition of segment.operatorDispositions ?? []) {
1971
+ if (dispositionIds.has(disposition.failureId)) fail("has duplicate segment disposition failure ids");
1972
+ dispositionIds.add(disposition.failureId);
1973
+ }
1974
+ const revision = file.revision ?? 0;
1975
+ if (segment.revision <= revision) continue;
1976
+ if (segment.revision !== revision + 1 || segment.previousRevision !== revision) fail("has a newer revision gap");
1977
+ if (segment.acceptedThroughUpdateId !== undefined && file.acceptedThroughUpdateId !== undefined &&
1978
+ segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId) fail("regresses the admission cursor");
1979
+ const entries = new Map(file.entries.map(entry => [entry.updateId, entry]));
1980
+ for (const id of segment.removedUpdateIds) entries.delete(id);
1981
+ for (const entry of segment.upsertedEntries) {
1982
+ const previous = entries.get(entry.updateId);
1983
+ if (journalRequiresExclusion(file.version) && ((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
1984
+ (!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
1985
+ throw createJournalError("pairing-evidence", target, "changes exclusion evidence or resurrects a settled source");
1986
+ }
1987
+ if (!entries.has(entry.updateId) && entries.size >= limits.maxEntries) capacity();
1988
+ entries.set(entry.updateId, entry);
1989
+ }
1990
+ const next = {
1991
+ ...file,
1992
+ revision: segment.revision,
1993
+ ...(segment.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId } : {}),
1994
+ entries: [...entries.values()].sort((a, b) => a.updateId - b.updateId),
1995
+ operatorDispositions: segment.operatorDispositions ?? file.operatorDispositions,
1996
+ };
1997
+ charge(next);
1998
+ file = parseJournalFile(next, target, file.version);
1999
+ }
2000
+ }
2001
+ // Re-enumeration has the same bound, rather than allocating an unbounded census.
2002
+ files = snapshot ? 1 : 0;
2003
+ if (segments && !isDeepStrictEqual(names, enumerate())) fail("changed segment namespace");
2004
+ files += names.length;
2005
+ for (const [target, before] of observed) {
2006
+ const after = status(target);
2007
+ const compare = selectedVersion !== undefined && ancestors.has(target) ? sameAncestor : same;
2008
+ if (before ? !after || !compare(before, after) : after !== undefined) fail("changed observed namespace or file");
2009
+ }
2010
+ if (selectedVersion !== undefined) {
2011
+ if (realpathSync(anchor) !== anchor) fail("requires a canonical directory anchor");
2012
+ if (file) {
2013
+ if (file.version !== selectedVersion) {
2014
+ throw createJournalError("unsupported-version", path, "does not match the selected source version");
2015
+ }
2016
+ if (createTelegramUpdateJournalReceiptScope({ profileName: input.profile, botIdentity: expected }) !==
2017
+ createTelegramUpdateJournalReceiptScope({ profileName: file.profile, botIdentity: file.botIdentity })) {
2018
+ throw createJournalError("identity-mismatch", path, "does not retain the expected receipt scope");
2019
+ }
2020
+ }
2021
+ }
2022
+ return {
2023
+ evidence: file ? { kind: "present", file, ...(knownBotId !== undefined ? { knownBotId } : {}), accounting: { files, bytes, work } } : { kind: "absent" },
2024
+ snapshotRevision,
2025
+ segments: acquiredSegments,
2026
+ };
2027
+ } catch (error) {
2028
+ if (error instanceof TelegramUpdateJournalError) throw error;
2029
+ throw createJournalError("io", path, "could not establish strict journal evidence", error);
2030
+ }
2031
+ }
2032
+
2033
+ /**
2034
+ * Canonical namespace evidence only, never source readiness or authorization.
2035
+ * Caller-proven serialization/quiescence is mandatory through consumption.
2036
+ * Recensus detects observable changes, not hostile same-user path swaps; arbitrary
2037
+ * consumer references and archive consumption require separate audits.
2038
+ */
2039
+ export function inspectTelegramProfileJournalNamespace(input: {
2040
+ directory: string;
2041
+ profile: string;
2042
+ botIdentity: TelegramUpdateJournalBotIdentity;
2043
+ limits: { maxDirectoryEntries: number; maxFiles: number; maxBytes: number; maxEntries: number; maxWork: number };
2044
+ }): {
2045
+ sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[];
2046
+ accounting: { directoryEntries: number; files: number; bytes: number; work: number };
2047
+ knownBotId?: number;
2048
+ } {
2049
+ const { directory, profile, limits } = input;
2050
+ const fail = (message: string): never => { throw createJournalError("invalid", directory, message); };
2051
+ const capacity = (): never => { throw createJournalError("capacity", directory, "exceeds namespace inspection resource limits"); };
2052
+ for (const key of ["maxDirectoryEntries", "maxFiles", "maxBytes", "maxEntries", "maxWork"] as const) {
2053
+ if (!isSafePositiveInteger(limits[key])) fail("requires positive safe-integer limits");
2054
+ }
2055
+ if (!/^[a-z0-9]{1,32}$/u.test(profile)) fail("requires a canonical profile namespace");
2056
+ const expected = validateBotIdentity(input.botIdentity, directory);
2057
+ if (!isAbsolute(directory) || resolve(directory) !== directory) fail("requires a canonical directory anchor");
2058
+ if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK) fail("platform lacks no-follow nonblocking open evidence");
2059
+ const metadata = (target: string) => {
2060
+ const value = lstatSync(target, { bigint: true });
2061
+ return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: value.nlink,
2062
+ size: value.size, mtimeNs: value.mtimeNs, ctimeNs: value.ctimeNs };
2063
+ };
2064
+ const suffix = profile === "default" ? "" : `.${profile}`;
2065
+ const polling = `inbox${suffix}.json`;
2066
+ try {
2067
+ const root = lstatSync(directory);
2068
+ if (!root.isDirectory() || root.isSymbolicLink() || realpathSync(directory) !== directory) fail("requires a canonical directory anchor");
2069
+ const beforeRoot = metadata(directory);
2070
+ const census = () => {
2071
+ 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, ""));
2089
+ }
2090
+ } finally { handle.closeSync(); }
2091
+ return { entries: [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), followers: [...followers].sort() };
2092
+ };
2093
+ const before = census();
2094
+ const sources: { role: "polling" | "follower"; path: string; evidence: ReturnType<typeof inspectTelegramUpdateJournalFamily> }[] = [];
2095
+ const accounting = { directoryEntries: before.entries.length, files: 0, bytes: 0, work: 0 };
2096
+ let knownBotId = expected.botId;
2097
+ for (const name of [polling, ...before.followers]) {
2098
+ const remaining = { maxFiles: limits.maxFiles - accounting.files, maxBytes: limits.maxBytes - accounting.bytes,
2099
+ maxWork: limits.maxWork - accounting.work, maxEntries: limits.maxEntries };
2100
+ 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`))) {
2105
+ fail("lost a discovered journal family");
2106
+ }
2107
+ if (evidence.kind === "present") {
2108
+ knownBotId = evidence.knownBotId;
2109
+ accounting.files += evidence.accounting.files;
2110
+ accounting.bytes += evidence.accounting.bytes;
2111
+ }
2112
+ accounting.work += evidence.kind === "present" ? Math.max(1, evidence.accounting.work) : 1;
2113
+ sources.push({ role: name === polling ? "polling" : "follower", path, evidence });
2114
+ }
2115
+ if (!isDeepStrictEqual(before, census()) || !isDeepStrictEqual(beforeRoot, metadata(directory))) fail("changed observed root namespace or identity");
2116
+ return { sources, accounting, ...(knownBotId !== undefined ? { knownBotId } : {}) };
2117
+ } catch (error) {
2118
+ if (error instanceof TelegramUpdateJournalError) throw error;
2119
+ throw createJournalError("io", directory, "could not establish canonical namespace evidence", error);
2120
+ }
2121
+ }
2122
+
1213
2123
  function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry {
1214
2124
  return {
1215
2125
  ...entry,
1216
2126
  update: structuredClone(entry.update),
2127
+ ...(entry.inputClaim ? { inputClaim: structuredClone(entry.inputClaim) } : {}),
2128
+ ...(entry.inputProvenance
2129
+ ? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
1217
2130
  ...(entry.queueOwner
1218
2131
  ? { queueOwner: cloneJournalQueueOwner(entry.queueOwner) }
1219
2132
  : {}),
@@ -1226,7 +2139,7 @@ function cloneEntry(entry: TelegramUpdateJournalEntry): TelegramUpdateJournalEnt
1226
2139
 
1227
2140
  function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
1228
2141
  return {
1229
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
2142
+ version: file.version,
1230
2143
  ...(file.revision !== undefined ? { revision: file.revision } : {}),
1231
2144
  ...(file.acceptedThroughUpdateId !== undefined
1232
2145
  ? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
@@ -1283,7 +2196,7 @@ function publishTelegramUpdateJournalSegmentUnlocked(
1283
2196
  let snapshotRevision = 0;
1284
2197
  try {
1285
2198
  const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
1286
- snapshotRevision = parseJournalFile(parsed, path).revision ?? 0;
2199
+ snapshotRevision = parseJournalFile(parsed, path, segment.version).revision ?? 0;
1287
2200
  } catch (error) {
1288
2201
  if ((error as { code?: unknown })?.code !== "ENOENT") throw error;
1289
2202
  }
@@ -1379,8 +2292,9 @@ function writeJournalFile(
1379
2292
  path: string,
1380
2293
  serialized: string,
1381
2294
  onPublicationBoundary?: TelegramUpdateJournalStoreOptions["onPublicationBoundary"],
2295
+ stagingPath = path,
1382
2296
  ): void {
1383
- const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
2297
+ const tempPath = `${stagingPath}.${process.pid}.${randomUUID()}.tmp`;
1384
2298
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1385
2299
  try {
1386
2300
  onPublicationBoundary?.("before-write", path);
@@ -1440,7 +2354,7 @@ export function createTelegramUpdateJournalReceiptScope(input: {
1440
2354
  );
1441
2355
  }
1442
2356
  return JSON.stringify({
1443
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
2357
+ version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
1444
2358
  profile,
1445
2359
  bot:
1446
2360
  input.botIdentity.botId === undefined
@@ -1458,7 +2372,7 @@ export function createTelegramUpdateJournalBindingKey(input: {
1458
2372
  throw new Error("Telegram update journal binding requires a path.");
1459
2373
  }
1460
2374
  return JSON.stringify({
1461
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
2375
+ version: TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION,
1462
2376
  path: input.path,
1463
2377
  receiptScope: createTelegramUpdateJournalReceiptScope(input),
1464
2378
  });
@@ -1469,7 +2383,7 @@ export function getTelegramUpdateJournalBindingPath(
1469
2383
  ): string | undefined {
1470
2384
  try {
1471
2385
  const value = JSON.parse(journalBindingKey) as Record<string, unknown>;
1472
- return value.version === TELEGRAM_UPDATE_JOURNAL_VERSION &&
2386
+ return value.version === TELEGRAM_UPDATE_JOURNAL_IDENTITY_VERSION &&
1473
2387
  typeof value.path === "string" &&
1474
2388
  value.path.length > 0 &&
1475
2389
  typeof value.receiptScope === "string"
@@ -1522,6 +2436,11 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
1522
2436
  getBotId: () => number | undefined;
1523
2437
  getJournalPath: (profileName?: string) => string;
1524
2438
  getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
2439
+ withWriterAdmission?: <T>(operation: () => T) => T;
2440
+ getWorkspaceAdmission?: () => Pick<
2441
+ TelegramWorkspaceAdmissionLedger,
2442
+ "acquireAdmission" | "releaseAdmission"
2443
+ > | undefined;
1525
2444
  onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
1526
2445
  }
1527
2446
 
@@ -1539,6 +2458,7 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
1539
2458
  botId: deps.getBotId(),
1540
2459
  });
1541
2460
  const path = deps.getJournalPath(configuredProfileName);
2461
+ const workspaceAdmission = deps.getWorkspaceAdmission?.();
1542
2462
  return {
1543
2463
  runtimeKey: JSON.stringify({
1544
2464
  path,
@@ -1557,12 +2477,75 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
1557
2477
  ...(deps.getQueueRuntimeIdentity
1558
2478
  ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
1559
2479
  : {}),
2480
+ ...(workspaceAdmission ? { workspaceAdmission } : {}),
2481
+ ...(deps.withWriterAdmission ? { withWriterAdmission: deps.withWriterAdmission } : {}),
1560
2482
  ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
1561
2483
  }),
1562
2484
  };
1563
2485
  };
1564
2486
  }
1565
2487
 
2488
+ export type TelegramUpdateJournalReferenceClass =
2489
+ | "leader-lifecycle"
2490
+ | "follower-lifecycle"
2491
+ | "polling-cursor"
2492
+ | "polling-bootstrap"
2493
+ | "workspace-retirement"
2494
+ | "operator-disposition";
2495
+
2496
+ export function createTelegramUpdateJournalReferenceRegistry(input: {
2497
+ maxActive?: number;
2498
+ } = {}) {
2499
+ const maxActive = input.maxActive ?? 64;
2500
+ let sequence = 0;
2501
+ const active = new Map<number, { referenceClass: TelegramUpdateJournalReferenceClass;
2502
+ recoveryKey: string }>();
2503
+ return {
2504
+ acquire(reference: { referenceClass: TelegramUpdateJournalReferenceClass;
2505
+ recoveryKey: string }): () => void {
2506
+ if (!reference.recoveryKey || active.size >= maxActive) throw new Error(
2507
+ "Telegram update journal reference registry is unavailable or full.",
2508
+ );
2509
+ const id = ++sequence;
2510
+ active.set(id, { ...reference });
2511
+ let released = false;
2512
+ return () => {
2513
+ if (released || !active.delete(id)) throw new Error(
2514
+ "Telegram update journal reference lease is stale.",
2515
+ );
2516
+ released = true;
2517
+ };
2518
+ },
2519
+ list: () => [...active.values()].map(reference => ({ ...reference })),
2520
+ withReference<T>(reference: { referenceClass: TelegramUpdateJournalReferenceClass;
2521
+ recoveryKey: string }, operation: () => T): T {
2522
+ const release = this.acquire(reference);
2523
+ try {
2524
+ const result = operation();
2525
+ if (result && typeof (result as { finally?: unknown }).finally === "function")
2526
+ return ((result as unknown) as Promise<unknown>).finally(release) as T;
2527
+ release();
2528
+ return result;
2529
+ } catch (error) {
2530
+ release();
2531
+ throw error;
2532
+ }
2533
+ },
2534
+ };
2535
+ }
2536
+
2537
+ export function withTelegramResolvedUpdateJournalReference<T>(input: {
2538
+ registry: ReturnType<typeof createTelegramUpdateJournalReferenceRegistry>;
2539
+ resolveBinding(): TelegramUpdateJournalRuntimeBinding | undefined;
2540
+ referenceClass: TelegramUpdateJournalReferenceClass;
2541
+ operation(binding: TelegramUpdateJournalRuntimeBinding): T;
2542
+ }): T | undefined {
2543
+ const binding = input.resolveBinding();
2544
+ if (!binding) return undefined;
2545
+ return input.registry.withReference({ referenceClass: input.referenceClass,
2546
+ recoveryKey: binding.recoveryKey }, () => input.operation(binding));
2547
+ }
2548
+
1566
2549
  export interface TelegramUpdateJournalBindingRuntime {
1567
2550
  resolveLeader: () => TelegramUpdateJournalRuntimeBinding | undefined;
1568
2551
  resolveFollower: () => TelegramUpdateJournalRuntimeBinding | undefined;
@@ -1571,6 +2554,9 @@ export interface TelegramUpdateJournalBindingRuntime {
1571
2554
  createRecipientResolver: (
1572
2555
  recipientBindingKey: string,
1573
2556
  ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
2557
+ createPathResolver: (
2558
+ path: string,
2559
+ ) => () => TelegramUpdateJournalRuntimeBinding | undefined;
1574
2560
  }
1575
2561
 
1576
2562
  export function createTelegramUpdateJournalBindingRuntime(deps: {
@@ -1598,6 +2584,11 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
1598
2584
  ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
1599
2585
  ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
1600
2586
  : {}),
2587
+ ...(deps.base.withWriterAdmission
2588
+ ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
2589
+ ...(deps.base.getWorkspaceAdmission
2590
+ ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2591
+ : {}),
1601
2592
  ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
1602
2593
  getJournalPath(profileName) {
1603
2594
  return deps.getFollowerJournalPath(bindingKey, profileName);
@@ -1614,12 +2605,48 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
1614
2605
  getActiveRecoveryKey: () => resolveActive()?.recoveryKey,
1615
2606
  createRecipientResolver: (bindingKey) =>
1616
2607
  createFollowerResolver(bindingKey, false),
2608
+ createPathResolver: (path) =>
2609
+ createTelegramUpdateJournalRuntimeBindingResolver({
2610
+ getProfileName: deps.base.getProfileName,
2611
+ getBotToken: deps.base.getBotToken,
2612
+ getBotId: deps.base.getBotId,
2613
+ ...(deps.base.withWriterAdmission
2614
+ ? { withWriterAdmission: deps.base.withWriterAdmission } : {}),
2615
+ ...(deps.base.getWorkspaceAdmission
2616
+ ? { getWorkspaceAdmission: deps.base.getWorkspaceAdmission }
2617
+ : {}),
2618
+ ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
2619
+ getJournalPath: () => path,
2620
+ }),
1617
2621
  };
1618
2622
  }
1619
2623
 
1620
2624
  export function createTelegramUpdateJournalStore(
1621
2625
  options: TelegramUpdateJournalStoreOptions,
1622
2626
  ): TelegramUpdateJournalStore {
2627
+ return createJournalStoreCore(options).journal;
2628
+ }
2629
+
2630
+ /** Opt-in v3 only; does not migrate old files or expose legacy unowned mutation ports. */
2631
+ export function createTelegramInputJournalStore(
2632
+ options: TelegramInputJournalStoreOptions,
2633
+ ): TelegramInputJournalStore {
2634
+ const captured = { ...options };
2635
+ if (!captured.sourceAccess || !captured.queueRuntimeIdentity ||
2636
+ typeof captured.withSourceSerialization !== "function" ||
2637
+ typeof captured.withPairingAdmission !== "function" ||
2638
+ typeof captured.getInputContext !== "function" ||
2639
+ ("withPairedAdmission" in captured && captured.withPairedAdmission !== undefined)) {
2640
+ throw new Error("Telegram input custody requires strict serialized polling admission and runtime identity.");
2641
+ }
2642
+ return createJournalStoreCore({ ...captured, queueRuntimeIdentity: { ...captured.queueRuntimeIdentity } },
2643
+ captured.getInputContext).input!;
2644
+ }
2645
+
2646
+ function createJournalStoreCore(
2647
+ options: TelegramUpdateJournalStoreOptions,
2648
+ getInputContext?: TelegramInputJournalStoreOptions["getInputContext"],
2649
+ ): { journal: TelegramUpdateJournalStore; input?: TelegramInputJournalStore } {
1623
2650
  const path = options.path;
1624
2651
  const profile = options.profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
1625
2652
  if (!path) throw new Error("Telegram update journal path is required.");
@@ -1654,6 +2681,22 @@ export function createTelegramUpdateJournalStore(
1654
2681
  );
1655
2682
  const getNowMs = options.getNowMs ?? Date.now;
1656
2683
  const onPublicationBoundary = options.onPublicationBoundary;
2684
+ const workspaceAdmission = options.workspaceAdmission;
2685
+ const withSourceSerialization = options.withSourceSerialization;
2686
+ const sourceAccess = options.sourceAccess && {
2687
+ directory: options.sourceAccess.directory,
2688
+ limits: { ...options.sourceAccess.limits },
2689
+ };
2690
+ if (sourceAccess && !withSourceSerialization) {
2691
+ throw new Error("Telegram journal source access requires source serialization.");
2692
+ }
2693
+ const withPairingAdmission = options.withPairingAdmission;
2694
+ const withPairedAdmission = options.withPairedAdmission;
2695
+ if (withPairingAdmission && withPairedAdmission) {
2696
+ throw new Error("Telegram journal admission modes are mutually exclusive.");
2697
+ }
2698
+ const version = getInputContext ? TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION
2699
+ : withPairingAdmission ? TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION : TELEGRAM_UPDATE_JOURNAL_VERSION;
1657
2700
  const notifyRecovery = (event: TelegramUpdateJournalRecoveryEvent): void => {
1658
2701
  try {
1659
2702
  options.onRecovery?.(event);
@@ -1796,7 +2839,7 @@ export function createTelegramUpdateJournalStore(
1796
2839
  };
1797
2840
 
1798
2841
  const emptyFile = (): TelegramUpdateJournalFile => ({
1799
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
2842
+ version,
1800
2843
  profile,
1801
2844
  botIdentity: { ...expectedIdentity },
1802
2845
  entries: [],
@@ -1836,6 +2879,7 @@ export function createTelegramUpdateJournalStore(
1836
2879
  );
1837
2880
  }
1838
2881
  if (orphanedSegmentNames.length > 0) {
2882
+ if (withPairingAdmission) throw createJournalError("pairing-evidence", path, "requires explicit missing-snapshot reconciliation");
1839
2883
  recoveringMissingSnapshot = true;
1840
2884
  source = serializeJournalFile(emptyFile());
1841
2885
  } else {
@@ -1851,7 +2895,7 @@ export function createTelegramUpdateJournalStore(
1851
2895
  } catch (error) {
1852
2896
  throw createJournalError("invalid", path, "contains invalid JSON", error);
1853
2897
  }
1854
- let file = parseJournalFile(parsed, path);
2898
+ let file = parseJournalFile(parsed, path, version);
1855
2899
  const storedProfile = file.profile;
1856
2900
  const storedIdentity = file.botIdentity;
1857
2901
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
@@ -1896,6 +2940,7 @@ export function createTelegramUpdateJournalStore(
1896
2940
  segment = parseJournalSegment(
1897
2941
  JSON.parse(readFileSync(segmentPath, "utf8")) as unknown,
1898
2942
  segmentPath,
2943
+ version,
1899
2944
  );
1900
2945
  } catch (error) {
1901
2946
  if (error instanceof TelegramUpdateJournalError) throw error;
@@ -1933,7 +2978,8 @@ export function createTelegramUpdateJournalStore(
1933
2978
  }
1934
2979
  if (
1935
2980
  segment.profile !== storedProfile ||
1936
- !identitiesMatch(segment.botIdentity, storedIdentity)
2981
+ !identitiesMatch(segment.botIdentity, storedIdentity) ||
2982
+ (withPairingAdmission && segment.botIdentity.tokenSha256 !== storedIdentity.tokenSha256)
1937
2983
  ) {
1938
2984
  throw createJournalError(
1939
2985
  "identity-mismatch",
@@ -1952,12 +2998,18 @@ export function createTelegramUpdateJournalStore(
1952
2998
  entriesById.delete(updateId);
1953
2999
  }
1954
3000
  for (const entry of segment.upsertedEntries) {
3001
+ const previous = entriesById.get(entry.updateId);
3002
+ if (withPairingAdmission &&
3003
+ ((previous && previous.preApprovalExcluded !== entry.preApprovalExcluded) ||
3004
+ (!previous && entry.updateId <= (file.acceptedThroughUpdateId ?? -1)))) {
3005
+ throw createJournalError("pairing-evidence", segmentPath, "changes exclusion evidence or resurrects a settled source");
3006
+ }
1955
3007
  entriesById.set(entry.updateId, entry);
1956
3008
  }
1957
3009
  if (segment.upsertedEntries.length > 0) orphanRecoverySawUpsert = true;
1958
3010
  file = parseJournalFile(
1959
3011
  {
1960
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
3012
+ version,
1961
3013
  revision: segment.revision,
1962
3014
  ...(segment.acceptedThroughUpdateId !== undefined
1963
3015
  ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
@@ -1976,14 +3028,16 @@ export function createTelegramUpdateJournalStore(
1976
3028
  : {}),
1977
3029
  },
1978
3030
  segmentPath,
3031
+ version,
1979
3032
  );
1980
3033
  revision = segment.revision;
1981
3034
  }
1982
3035
  const identityChanged =
1983
3036
  storedProfile !== profile ||
1984
- !identitiesMatch(file.botIdentity, expectedIdentity);
3037
+ !identitiesMatch(file.botIdentity, expectedIdentity) ||
3038
+ (withPairingAdmission && file.botIdentity.tokenSha256 !== expectedIdentity.tokenSha256);
1985
3039
  if (identityChanged) {
1986
- if (file.entries.length > 0) {
3040
+ if (file.entries.length > 0 || withPairingAdmission) {
1987
3041
  throw createJournalError(
1988
3042
  "identity-mismatch",
1989
3043
  path,
@@ -1993,7 +3047,7 @@ export function createTelegramUpdateJournalStore(
1993
3047
  );
1994
3048
  }
1995
3049
  file = {
1996
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
3050
+ version,
1997
3051
  ...(file.revision !== undefined ? { revision: file.revision } : {}),
1998
3052
  profile,
1999
3053
  botIdentity: { ...expectedIdentity },
@@ -2051,6 +3105,7 @@ export function createTelegramUpdateJournalStore(
2051
3105
  return readCurrentStrict();
2052
3106
  } catch (error) {
2053
3107
  if (
3108
+ withPairingAdmission ||
2054
3109
  !(error instanceof TelegramUpdateJournalError) ||
2055
3110
  error.code !== "invalid"
2056
3111
  ) {
@@ -2074,6 +3129,24 @@ export function createTelegramUpdateJournalStore(
2074
3129
  }
2075
3130
  if (segmentNames.length === 0) throw error;
2076
3131
 
3132
+ // Earlier corruption must not hide a newer schema behind repair or reset.
3133
+ let recoverySegmentBytes = 0;
3134
+ for (const candidatePath of [
3135
+ ...(snapshotExists ? [path] : []),
3136
+ ...segmentNames.map((name) => join(segmentDirectory, name)),
3137
+ ]) {
3138
+ const size = statSync(candidatePath).size;
3139
+ if (candidatePath !== path) recoverySegmentBytes += size;
3140
+ if (size > maxBytes || recoverySegmentBytes > maxBytes) {
3141
+ throw createJournalError("capacity", candidatePath, "exceeds the recovery inspection byte limit");
3142
+ }
3143
+ try {
3144
+ assertSupportedJournalVersion(JSON.parse(readFileSync(candidatePath, "utf8")), candidatePath);
3145
+ } catch (inspectionError) {
3146
+ if (!(inspectionError instanceof SyntaxError)) throw inspectionError;
3147
+ }
3148
+ }
3149
+
2077
3150
  if (snapshotExists) {
2078
3151
  try {
2079
3152
  const snapshot = parseJournalFile(
@@ -2108,8 +3181,13 @@ export function createTelegramUpdateJournalStore(
2108
3181
  });
2109
3182
  return repaired;
2110
3183
  }
2111
- } catch {
2112
- // Fall through to evidence-preserving quarantine and reset.
3184
+ } catch (recoveryError) {
3185
+ if (recoveryError instanceof TelegramUpdateJournalError) {
3186
+ if (recoveryError.code !== "invalid") throw recoveryError;
3187
+ } else if (!(recoveryError instanceof SyntaxError)) {
3188
+ throw recoveryError;
3189
+ }
3190
+ // Only known schema/JSON corruption may fall through to reset.
2113
3191
  }
2114
3192
  }
2115
3193
 
@@ -2179,23 +3257,363 @@ export function createTelegramUpdateJournalStore(
2179
3257
  }
2180
3258
  };
2181
3259
 
2182
- const runMutation = <T>(operation: () => T): T => {
3260
+ const runJournalTransaction = <T>(operation: (read: typeof readCurrent) => T): T => {
2183
3261
  try {
2184
- return withTelegramFileTransaction(`${path}.transaction`, operation);
3262
+ // Acquire before the transaction helper can create parents or staging names.
3263
+ // The config continuation excludes participating writers through consumption.
3264
+ const source = sourceAccess ? acquireTelegramUpdateJournalFamily({
3265
+ ...sourceAccess, path, profile, botIdentity: expectedIdentity,
3266
+ }, version) : undefined;
3267
+ const readSource = (): ReadTelegramUpdateJournalResult => {
3268
+ if (!source) return readCurrent();
3269
+ const file = source.evidence.kind === "present" ? source.evidence.file : emptyFile();
3270
+ return { file, exists: source.evidence.kind === "present",
3271
+ serializedBytes: assertCapacity(file), source };
3272
+ };
3273
+ return withTelegramFileTransaction(`${path}.transaction`, () => operation(readSource));
2185
3274
  } catch (error) {
2186
3275
  if (error instanceof TelegramUpdateJournalError) throw error;
2187
3276
  throw createJournalError("io", path, "mutation failed", error);
2188
3277
  }
2189
3278
  };
2190
3279
 
3280
+ const runMutationCore = <T>(operation: (read: typeof readCurrent) => T): T =>
3281
+ withSourceSerialization
3282
+ ? withSourceSerialization(() => runJournalTransaction(operation))
3283
+ : runJournalTransaction(operation);
3284
+ const runMutation = <T>(operation: (read: typeof readCurrent) => T): T =>
3285
+ options.withWriterAdmission
3286
+ ? options.withWriterAdmission(() => runMutationCore(operation))
3287
+ : runMutationCore(operation);
3288
+
3289
+ const assertSourceResources = (files: number, bytes: number, work: number,
3290
+ collections: readonly number[]): void => {
3291
+ if (!sourceAccess) return;
3292
+ const limits = sourceAccess.limits;
3293
+ if (files > limits.maxFiles || bytes > limits.maxBytes || work > limits.maxWork ||
3294
+ collections.some((length) => length > limits.maxEntries)) {
3295
+ throw createJournalError("capacity", path, "publication would exceed source inspection resource limits");
3296
+ }
3297
+ };
3298
+
3299
+ type InputHeadroomState = { file: TelegramUpdateJournalFile; projectionSlack: number };
3300
+ const inputHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH);
3301
+ const inputBindingHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH);
3302
+ const inputHandoffHeadroomText = "\0".repeat(TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_ID_MAX_LENGTH);
3303
+ const getFileWork = (file: TelegramUpdateJournalFile): number =>
3304
+ file.entries.length + (file.operatorDispositions?.length ?? 0);
3305
+ const advanceInputHeadroomState = (state: InputHeadroomState,
3306
+ entries: TelegramUpdateJournalEntry[], projectionSlack: number): InputHeadroomState => {
3307
+ const revision = (state.file.revision ?? 0) + 1;
3308
+ if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted input custody revisions");
3309
+ return { file: { ...state.file, revision, entries }, projectionSlack };
3310
+ };
3311
+ const replaceInputHeadroomEntry = (file: TelegramUpdateJournalFile, updateId: number,
3312
+ replacement?: TelegramUpdateJournalEntry): TelegramUpdateJournalEntry[] => file.entries.flatMap(entry =>
3313
+ entry.updateId === updateId ? (replacement ? [replacement] : []) : [entry]);
3314
+ const createInputHeadroomClaim = (entry: TelegramUpdateJournalEntry,
3315
+ phase: TelegramUpdateJournalInputClaim["phase"]): TelegramUpdateJournalInputClaim => ({
3316
+ phase,
3317
+ owner: { instanceId: inputHeadroomText, processId: Number.MAX_SAFE_INTEGER,
3318
+ processBirthId: inputHeadroomText, sessionGeneration: Number.MAX_SAFE_INTEGER,
3319
+ acquisitionId: inputHeadroomText, acquiredAtMs: Number.MAX_SAFE_INTEGER,
3320
+ handoffId: inputHandoffHeadroomText },
3321
+ recipientBindingKey: inputBindingHeadroomText,
3322
+ executionUpdate: structuredClone(entry.update),
3323
+ });
3324
+ const largestInputHeadroomEntry = (entries: TelegramUpdateJournalEntry[],
3325
+ project: (entry: TelegramUpdateJournalEntry) => TelegramUpdateJournalEntry) => entries.reduce<
3326
+ { entry: TelegramUpdateJournalEntry; bytes: number } | undefined
3327
+ >((selected, entry) => {
3328
+ const bytes = Buffer.byteLength(JSON.stringify(project(entry), null, 2));
3329
+ return !selected || bytes > selected.bytes ? { entry, bytes } : selected;
3330
+ }, undefined)?.entry;
3331
+ const smallestInputHeadroomEntry = (entries: TelegramUpdateJournalEntry[]) => entries.reduce<
3332
+ { entry: TelegramUpdateJournalEntry; bytes: number } | undefined
3333
+ >((selected, entry) => {
3334
+ const bytes = Buffer.byteLength(JSON.stringify(entry, null, 2));
3335
+ return !selected || bytes < selected.bytes ? { entry, bytes } : selected;
3336
+ }, undefined)?.entry;
3337
+ const createInputHeadroomTransitionPlan = (file: TelegramUpdateJournalFile,
3338
+ entry: TelegramUpdateJournalEntry): InputHeadroomState[] => {
3339
+ const initial: InputHeadroomState = { file, projectionSlack: 0 };
3340
+ if (entry.inputClaim?.phase === "running") return [advanceInputHeadroomState(initial,
3341
+ replaceInputHeadroomEntry(file, entry.updateId), TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
3342
+ if (entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff) {
3343
+ const acceptedClaim = { ...entry.inputClaim,
3344
+ owner: { ...entry.inputClaim.handoff.recipientOwner, acquisitionId: inputHeadroomText,
3345
+ acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText } };
3346
+ delete acceptedClaim.handoff;
3347
+ const acceptedEntry = { ...entry, inputClaim: acceptedClaim };
3348
+ const acceptedState = advanceInputHeadroomState(initial,
3349
+ replaceInputHeadroomEntry(file, entry.updateId, acceptedEntry),
3350
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
3351
+ return [acceptedState, ...createInputHeadroomTransitionPlan(acceptedState.file, acceptedEntry)];
3352
+ }
3353
+ if (entry.state === "pending" && entry.inputClaim?.phase === "ready") {
3354
+ const runningEntry = { ...entry, inputClaim: { ...entry.inputClaim, phase: "running" as const } };
3355
+ const runningState = advanceInputHeadroomState(initial,
3356
+ replaceInputHeadroomEntry(file, entry.updateId, runningEntry),
3357
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
3358
+ return [runningState, advanceInputHeadroomState(runningState,
3359
+ replaceInputHeadroomEntry(runningState.file, entry.updateId),
3360
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
3361
+ }
3362
+ if (entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim) {
3363
+ const readyEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
3364
+ const readyState = advanceInputHeadroomState(initial,
3365
+ replaceInputHeadroomEntry(file, entry.updateId, readyEntry),
3366
+ TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
3367
+ const runningEntry = { ...readyEntry, inputClaim: { ...readyEntry.inputClaim, phase: "running" as const } };
3368
+ const runningState = advanceInputHeadroomState(readyState,
3369
+ replaceInputHeadroomEntry(readyState.file, entry.updateId, runningEntry),
3370
+ TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES);
3371
+ return [readyState, runningState, advanceInputHeadroomState(runningState,
3372
+ replaceInputHeadroomEntry(runningState.file, entry.updateId),
3373
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
3374
+ }
3375
+ return entry.preApprovalExcluded === true ? [advanceInputHeadroomState(initial,
3376
+ replaceInputHeadroomEntry(file, entry.updateId),
3377
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)] : [];
3378
+ };
3379
+ const createInputHeadroomCancelPlan = (file: TelegramUpdateJournalFile,
3380
+ entry: TelegramUpdateJournalEntry): InputHeadroomState[] => {
3381
+ if (!entry.inputClaim?.handoff) return [];
3382
+ const claim = structuredClone(entry.inputClaim);
3383
+ delete claim.handoff;
3384
+ const cancelledEntry = { ...entry, inputClaim: claim };
3385
+ const initial: InputHeadroomState = { file, projectionSlack: 0 };
3386
+ const cancelledState = advanceInputHeadroomState(initial,
3387
+ replaceInputHeadroomEntry(file, entry.updateId, cancelledEntry),
3388
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
3389
+ return [cancelledState, ...createInputHeadroomTransitionPlan(cancelledState.file, cancelledEntry)];
3390
+ };
3391
+ const createInputHeadroomReleasePlan = (file: TelegramUpdateJournalFile,
3392
+ entry: TelegramUpdateJournalEntry, allowOffered = false): InputHeadroomState[] => {
3393
+ if (entry.inputClaim?.handoff && !allowOffered) return [];
3394
+ const normal = createInputHeadroomTransitionPlan(file, entry);
3395
+ const readyState = entry.inputClaim?.phase === "ready" ? { file, projectionSlack: 0 } : normal[0];
3396
+ if (!readyState) return [];
3397
+ const readyEntry = readyState.file.entries.find(candidate => candidate.updateId === entry.updateId);
3398
+ if (readyEntry?.inputClaim?.phase !== "ready") return [];
3399
+ const releasedEntry = cloneEntry(readyEntry);
3400
+ delete releasedEntry.inputClaim;
3401
+ const releasedState = advanceInputHeadroomState(readyState,
3402
+ replaceInputHeadroomEntry(readyState.file, entry.updateId, releasedEntry),
3403
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES);
3404
+ const reacquisition = createInputHeadroomTransitionPlan(releasedState.file, releasedEntry);
3405
+ return [...(readyState.file === file ? [] : [readyState]), releasedState, ...reacquisition];
3406
+ };
3407
+ const createInputHeadroomQueueCompletionPlan = (file: TelegramUpdateJournalFile,
3408
+ entries: TelegramUpdateJournalEntry[]): InputHeadroomState[] => {
3409
+ if (entries.length === 0 || entries.some(entry => entry.state !== "queued" || entry.queueHandoff)) return [];
3410
+ const requestedIds = new Set(entries.map(entry => entry.updateId));
3411
+ const initial: InputHeadroomState = { file, projectionSlack: 0 };
3412
+ return [advanceInputHeadroomState(initial,
3413
+ file.entries.filter(entry => !requestedIds.has(entry.updateId)),
3414
+ TELEGRAM_UPDATE_JOURNAL_INPUT_TRANSITION_HEADROOM_BYTES)];
3415
+ };
3416
+ const getInputHeadroomTransitionPlans = (file: TelegramUpdateJournalFile,
3417
+ updateId: number | readonly number[] | "all"): InputHeadroomState[][] => {
3418
+ if (updateId !== "all") {
3419
+ const requestedIds = new Set<number>(typeof updateId === "number" ? [updateId] : [...updateId]);
3420
+ const requestedEntries = file.entries.filter(entry => requestedIds.has(entry.updateId));
3421
+ const queuedReceiptId = requestedEntries[0]?.queueReceiptId;
3422
+ if (requestedEntries.length === requestedIds.size && queuedReceiptId &&
3423
+ requestedEntries.every(entry => entry.state === "queued" &&
3424
+ entry.queueReceiptId === queuedReceiptId) &&
3425
+ file.entries.filter(entry => entry.queueReceiptId === queuedReceiptId).length === requestedIds.size) {
3426
+ const completion = createInputHeadroomQueueCompletionPlan(file, requestedEntries);
3427
+ return completion.length > 0 ? [completion] : [];
3428
+ }
3429
+ if (typeof updateId !== "number") return [];
3430
+ const entry = requestedEntries[0];
3431
+ const plan = entry ? createInputHeadroomTransitionPlan(file, entry) : [];
3432
+ const cancel = entry ? createInputHeadroomCancelPlan(file, entry) : [];
3433
+ const recover = entry?.inputClaim?.handoff
3434
+ ? createInputHeadroomReleasePlan(file, entry, true) : [];
3435
+ return [plan, cancel, recover].filter(candidate => candidate.length > 0);
3436
+ }
3437
+ const running = smallestInputHeadroomEntry(file.entries.filter(entry =>
3438
+ entry.inputClaim?.phase === "running"));
3439
+ const offered = largestInputHeadroomEntry(file.entries.filter(entry =>
3440
+ entry.state === "pending" && entry.inputClaim?.phase === "ready" && entry.inputClaim.handoff), entry =>
3441
+ ({ ...entry, inputClaim: { ...entry.inputClaim!, owner: {
3442
+ ...entry.inputClaim!.handoff!.recipientOwner, acquisitionId: inputHeadroomText,
3443
+ acquiredAtMs: Number.MAX_SAFE_INTEGER, handoffId: inputHandoffHeadroomText }, handoff: undefined } }));
3444
+ const ready = largestInputHeadroomEntry(file.entries.filter(entry =>
3445
+ entry.state === "pending" && entry.inputClaim?.phase === "ready" && !entry.inputClaim.handoff), entry =>
3446
+ ({ ...entry, inputClaim: { ...entry.inputClaim!, phase: "running" as const } }));
3447
+ const unclaimed = largestInputHeadroomEntry(file.entries.filter(entry =>
3448
+ entry.state === "pending" && entry.preApprovalExcluded === false && !entry.inputClaim), entry =>
3449
+ ({ ...entry, inputClaim: createInputHeadroomClaim(entry, "running") }));
3450
+ const excluded = smallestInputHeadroomEntry(file.entries.filter(entry =>
3451
+ entry.preApprovalExcluded === true));
3452
+ const representatives = [running, offered, ready, unclaimed, excluded].filter(
3453
+ (entry): entry is TelegramUpdateJournalEntry => entry !== undefined);
3454
+ const plans = representatives.map(entry => createInputHeadroomTransitionPlan(file, entry));
3455
+ if (offered) {
3456
+ plans.push(createInputHeadroomCancelPlan(file, offered));
3457
+ plans.push(createInputHeadroomReleasePlan(file, offered, true));
3458
+ }
3459
+ for (const entry of [ready, unclaimed]) {
3460
+ if (entry) plans.push(createInputHeadroomReleasePlan(file, entry));
3461
+ }
3462
+ const queuedReceipts = new Map<string, TelegramUpdateJournalEntry[]>();
3463
+ for (const entry of file.entries) {
3464
+ if (entry.state !== "queued" || !entry.queueReceiptId || entry.queueHandoff) continue;
3465
+ const grouped = queuedReceipts.get(entry.queueReceiptId) ?? [];
3466
+ grouped.push(entry);
3467
+ queuedReceipts.set(entry.queueReceiptId, grouped);
3468
+ }
3469
+ for (const entries of queuedReceipts.values()) {
3470
+ const completion = createInputHeadroomQueueCompletionPlan(file, entries);
3471
+ if (completion.length > 0) plans.push(completion);
3472
+ }
3473
+ return plans;
3474
+ };
3475
+ const createInputHeadroomSegment = (
3476
+ previous: TelegramUpdateJournalFile, next: TelegramUpdateJournalFile,
3477
+ ): TelegramUpdateJournalSegment => {
3478
+ const previousEntries = new Map(previous.entries.map(entry => [entry.updateId, entry]));
3479
+ const nextEntries = new Map(next.entries.map(entry => [entry.updateId, entry]));
3480
+ return { version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
3481
+ revision: next.revision!, previousRevision: next.revision! - 1,
3482
+ profile: next.profile, botIdentity: next.botIdentity,
3483
+ upsertedEntries: next.entries.filter(entry =>
3484
+ !previousEntries.has(entry.updateId) || !isDeepStrictEqual(previousEntries.get(entry.updateId), entry)),
3485
+ removedUpdateIds: previous.entries.filter(entry => !nextEntries.has(entry.updateId)).map(entry => entry.updateId),
3486
+ ...(next.acceptedThroughUpdateId !== undefined ? { acceptedThroughUpdateId: next.acceptedThroughUpdateId } : {}) };
3487
+ };
3488
+ const assertInputHeadroomCapacity = (file: TelegramUpdateJournalFile, extraBytes: number): number => {
3489
+ const bytes = assertCapacity(file);
3490
+ if (bytes > maxBytes - extraBytes) {
3491
+ throw createJournalError("capacity", path, "does not retain logical input transition headroom");
3492
+ }
3493
+ return bytes + extraBytes;
3494
+ };
3495
+ const assertInputProgressHeadroom = (current: ReadTelegramUpdateJournalResult,
3496
+ publishedFile: TelegramUpdateJournalFile, updateId: number | readonly number[] | "all",
3497
+ publishedSegment?: TelegramUpdateJournalSegment): void => {
3498
+ const plans = getInputHeadroomTransitionPlans(publishedFile, updateId);
3499
+ if (plans.length === 0) return;
3500
+ const sourceSegments = current.source?.segments ?? [];
3501
+ let baseRetainedCount = sourceSegments.length;
3502
+ let baseRetainedBytes = sourceSegments.reduce((sum, segment) => sum + segment.bytes, 0);
3503
+ let baseRetainedWork = sourceSegments.reduce((sum, segment) => sum + segment.work, 0);
3504
+ const publishedBytes = assertInputHeadroomCapacity(publishedFile, 0);
3505
+ let baseAccounting = current.source?.evidence.kind === "present"
3506
+ ? { ...current.source.evidence.accounting }
3507
+ : { files: 1, bytes: publishedBytes, work: getFileWork(publishedFile) };
3508
+ if (publishedSegment) {
3509
+ const segmentWork = publishedSegment.upsertedEntries.length + publishedSegment.removedUpdateIds.length +
3510
+ (publishedSegment.operatorDispositions?.length ?? 0);
3511
+ const segmentBytes = Buffer.byteLength(`${JSON.stringify(publishedSegment, null, 2)}\n`);
3512
+ baseRetainedCount += 1;
3513
+ baseRetainedBytes += segmentBytes;
3514
+ baseRetainedWork += segmentWork;
3515
+ const segmentFirst = { files: baseAccounting.files + 1, bytes: baseAccounting.bytes + segmentBytes,
3516
+ work: baseAccounting.work + segmentWork + getFileWork(publishedFile) };
3517
+ const compactResidue = { files: 1 + baseRetainedCount,
3518
+ bytes: publishedBytes + baseRetainedBytes, work: getFileWork(publishedFile) + baseRetainedWork };
3519
+ baseAccounting = { files: Math.max(segmentFirst.files, compactResidue.files),
3520
+ bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
3521
+ work: Math.max(segmentFirst.work, compactResidue.work) };
3522
+ }
3523
+ for (const transitions of plans) {
3524
+ let retainedCount = baseRetainedCount;
3525
+ let retainedBytes = baseRetainedBytes;
3526
+ let retainedWork = baseRetainedWork;
3527
+ let accounting = { ...baseAccounting };
3528
+ let state: InputHeadroomState = { file: publishedFile, projectionSlack: 0 };
3529
+ for (const next of transitions) {
3530
+ const segment = createInputHeadroomSegment(state.file, next.file);
3531
+ const segmentCollections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
3532
+ segment.operatorDispositions?.length ?? 0, next.file.entries.length,
3533
+ next.file.operatorDispositions?.length ?? 0];
3534
+ const segmentWork = segmentCollections[0]! + segmentCollections[1]! + segmentCollections[2]!;
3535
+ const segmentBytes = Buffer.byteLength(`${JSON.stringify(segment, null, 2)}\n`) + next.projectionSlack;
3536
+ const nextBytes = assertInputHeadroomCapacity(next.file, next.projectionSlack);
3537
+ const segmentFirst = { files: accounting.files + 1, bytes: accounting.bytes + segmentBytes,
3538
+ work: accounting.work + segmentWork + getFileWork(next.file) };
3539
+ assertSourceResources(segmentFirst.files, segmentFirst.bytes, segmentFirst.work, segmentCollections);
3540
+ retainedCount += 1;
3541
+ retainedBytes += segmentBytes;
3542
+ retainedWork += segmentWork;
3543
+ const compactResidue = { files: 1 + retainedCount, bytes: nextBytes + retainedBytes,
3544
+ work: getFileWork(next.file) + retainedWork };
3545
+ assertSourceResources(compactResidue.files, compactResidue.bytes, compactResidue.work, segmentCollections);
3546
+ accounting = { files: Math.max(segmentFirst.files, compactResidue.files),
3547
+ bytes: Math.max(segmentFirst.bytes, compactResidue.bytes),
3548
+ work: Math.max(segmentFirst.work, compactResidue.work) };
3549
+ state = next;
3550
+ }
3551
+ }
3552
+ };
3553
+
3554
+ const publishSourceSegment = (
3555
+ current: ReadTelegramUpdateJournalResult,
3556
+ file: TelegramUpdateJournalFile,
3557
+ segment: TelegramUpdateJournalSegment,
3558
+ publicationBoundary: TelegramUpdateJournalStoreOptions["onPublicationBoundary"],
3559
+ forceCompact: boolean,
3560
+ ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
3561
+ const source = current.source!;
3562
+ if (source.evidence.kind !== "present") throw createJournalError("invalid", path, "requires existing source evidence");
3563
+ const revisedFile = { ...file, revision: segment.revision };
3564
+ const serialized = serializeJournalFile(revisedFile);
3565
+ const serializedBytes = assertCapacity(revisedFile, serialized);
3566
+ const segmentText = `${JSON.stringify(segment, null, 2)}\n`;
3567
+ const segmentBytes = Buffer.byteLength(segmentText);
3568
+ const collections = [segment.upsertedEntries.length, segment.removedUpdateIds.length,
3569
+ segment.operatorDispositions?.length ?? 0, file.entries.length, file.operatorDispositions?.length ?? 0];
3570
+ const segmentWork = collections[0]! + collections[1]! + collections[2]!;
3571
+ const fileWork = collections[3]! + collections[4]!;
3572
+ const accounting = source.evidence.accounting;
3573
+ // Both the segment-first state and snapshot-first cleanup residue must fit.
3574
+ assertSourceResources(accounting.files + 1, accounting.bytes + segmentBytes,
3575
+ accounting.work + segmentWork + fileWork, collections);
3576
+ const unapplied = source.segments.filter((item) => Number(item.name.slice(0, 16)) > source.snapshotRevision);
3577
+ const compact = forceCompact ||
3578
+ unapplied.length + 1 >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_COUNT ||
3579
+ unapplied.reduce((sum, item) => sum + item.bytes, segmentBytes) >= TELEGRAM_UPDATE_JOURNAL_COMPACTION_SEGMENT_BYTES;
3580
+ if (compact) {
3581
+ assertSourceResources(accounting.files + 1,
3582
+ serializedBytes + source.segments.reduce((sum, item) => sum + item.bytes, segmentBytes),
3583
+ fileWork + source.segments.reduce((sum, item) => sum + item.work, segmentWork), collections);
3584
+ }
3585
+ const segmentPath = getTelegramUpdateJournalSegmentPath(path, segment.revision);
3586
+ // A crash may retain staging, so keep it outside the strictly enumerated segment directory.
3587
+ writeJournalFile(segmentPath, segmentText, publicationBoundary, path);
3588
+ if (compact) {
3589
+ writeJournalFile(path, serialized, publicationBoundary);
3590
+ // Snapshot scope must not change. Preserve a validated redundant ID witness instead.
3591
+ const witness = file.botIdentity.botId === undefined
3592
+ ? source.segments.find((item) => item.botId !== undefined)?.name : undefined;
3593
+ const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
3594
+ const names = [...source.segments.map((item) => item.name), basename(segmentPath)];
3595
+ for (const name of names) {
3596
+ if (name === witness) continue;
3597
+ try { unlinkSync(join(segmentDirectory, name)); } catch {
3598
+ // Every subset of redundant cleanup residue was admitted above.
3599
+ }
3600
+ }
3601
+ try { rmdirSync(segmentDirectory); } catch { /* Retained witness or cleanup residue. */ }
3602
+ }
3603
+ return { file: revisedFile, serializedBytes };
3604
+ };
3605
+
2191
3606
  const publishMutation = (
2192
3607
  current: ReadTelegramUpdateJournalResult,
2193
3608
  entries: TelegramUpdateJournalEntry[],
2194
3609
  contentChanged: boolean,
2195
3610
  operatorDispositions = current.file.operatorDispositions,
2196
3611
  acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
3612
+ publicationBoundary = onPublicationBoundary,
3613
+ inputHeadroom: number | readonly number[] | "all" | false =
3614
+ version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION ? "all" : false,
2197
3615
  ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
2198
- const botIdentity = mergeBotIdentity(
3616
+ const botIdentity = current.source ? current.file.botIdentity : mergeBotIdentity(
2199
3617
  current.file.botIdentity,
2200
3618
  expectedIdentity,
2201
3619
  );
@@ -2211,7 +3629,7 @@ export function createTelegramUpdateJournalStore(
2211
3629
  return { file: current.file, serializedBytes: current.serializedBytes };
2212
3630
  }
2213
3631
  const file: TelegramUpdateJournalFile = {
2214
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
3632
+ version,
2215
3633
  profile,
2216
3634
  botIdentity,
2217
3635
  entries,
@@ -2222,6 +3640,7 @@ export function createTelegramUpdateJournalStore(
2222
3640
  ? { operatorDispositions }
2223
3641
  : {}),
2224
3642
  };
3643
+ if (withPairingAdmission || current.source) parseJournalFile(file, path, version);
2225
3644
  const serialized = serializeJournalFile(file);
2226
3645
  const serializedBytes = assertCapacity(file, serialized);
2227
3646
  const changed =
@@ -2232,12 +3651,23 @@ export function createTelegramUpdateJournalStore(
2232
3651
  return { file, serializedBytes: current.serializedBytes };
2233
3652
  }
2234
3653
  if (!current.exists) {
2235
- writeJournalFile(path, serialized, onPublicationBoundary);
3654
+ if (current.source) {
3655
+ const collections = [file.entries.length, file.operatorDispositions?.length ?? 0];
3656
+ assertSourceResources(1, serializedBytes, collections[0]! + collections[1]!, collections);
3657
+ }
3658
+ if (inputHeadroom !== false) assertInputProgressHeadroom(current, file, inputHeadroom);
3659
+ writeJournalFile(path, serialized, publicationBoundary);
2236
3660
  return { file, serializedBytes };
2237
3661
  }
2238
3662
  const previousEntries = new Map(
2239
3663
  current.file.entries.map((entry) => [entry.updateId, entry]),
2240
3664
  );
3665
+ if (withPairingAdmission && entries.some((entry) => {
3666
+ const previous = previousEntries.get(entry.updateId);
3667
+ return previous && previous.preApprovalExcluded !== entry.preApprovalExcluded;
3668
+ })) {
3669
+ throw createJournalError("pairing-evidence", path, "changes immutable exclusion evidence");
3670
+ }
2241
3671
  const nextEntries = new Map(entries.map((entry) => [entry.updateId, entry]));
2242
3672
  const upsertedEntries = entries.filter(
2243
3673
  (entry) =>
@@ -2248,8 +3678,8 @@ export function createTelegramUpdateJournalStore(
2248
3678
  .filter((entry) => !nextEntries.has(entry.updateId))
2249
3679
  .map((entry) => entry.updateId);
2250
3680
  const revision = (current.file.revision ?? 0) + 1;
2251
- publishTelegramUpdateJournalSegmentUnlocked(path, {
2252
- version: TELEGRAM_UPDATE_JOURNAL_VERSION,
3681
+ const segment: TelegramUpdateJournalSegment = {
3682
+ version,
2253
3683
  revision,
2254
3684
  previousRevision: revision - 1,
2255
3685
  profile,
@@ -2265,7 +3695,15 @@ export function createTelegramUpdateJournalStore(
2265
3695
  )
2266
3696
  ? { operatorDispositions: operatorDispositions ?? [] }
2267
3697
  : {}),
2268
- }, onPublicationBoundary);
3698
+ };
3699
+ if (current.source) {
3700
+ if (!isSafePositiveInteger(revision)) throw createJournalError("capacity", path, "exhausted source revisions");
3701
+ const revisedFile = { ...file, revision };
3702
+ if (inputHeadroom !== false) assertInputProgressHeadroom(current, revisedFile, inputHeadroom, segment);
3703
+ return publishSourceSegment(current, file, segment, publicationBoundary,
3704
+ version === TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION);
3705
+ }
3706
+ publishTelegramUpdateJournalSegmentUnlocked(path, segment, publicationBoundary);
2269
3707
  const revisedFile: TelegramUpdateJournalFile = { ...file, revision };
2270
3708
  const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
2271
3709
  const segmentNames = readdirSync(segmentDirectory).filter((name) =>
@@ -2276,6 +3714,7 @@ export function createTelegramUpdateJournalStore(
2276
3714
  snapshotRevision = parseJournalFile(
2277
3715
  JSON.parse(readFileSync(path, "utf8")) as unknown,
2278
3716
  path,
3717
+ version,
2279
3718
  ).revision ?? 0;
2280
3719
  } catch (error) {
2281
3720
  if ((error as { code?: unknown })?.code !== "ENOENT") throw error;
@@ -2294,7 +3733,7 @@ export function createTelegramUpdateJournalStore(
2294
3733
  ) {
2295
3734
  const compacted = serializeJournalFile(revisedFile);
2296
3735
  const compactedBytes = assertCapacity(revisedFile, compacted);
2297
- writeJournalFile(path, compacted, onPublicationBoundary);
3736
+ writeJournalFile(path, compacted, publicationBoundary);
2298
3737
  for (const name of segmentNames) {
2299
3738
  if (Number(name.slice(0, 16)) <= revision) {
2300
3739
  try {
@@ -2315,9 +3754,9 @@ export function createTelegramUpdateJournalStore(
2315
3754
  return { file: revisedFile, serializedBytes };
2316
3755
  };
2317
3756
 
2318
- return {
3757
+ const journal: TelegramUpdateJournalStore = {
2319
3758
  read() {
2320
- return runMutation(() => {
3759
+ return runMutation((readCurrent) => {
2321
3760
  const current = readCurrent();
2322
3761
  return {
2323
3762
  ...cloneFile(current.file),
@@ -2327,7 +3766,25 @@ export function createTelegramUpdateJournalStore(
2327
3766
  });
2328
3767
  },
2329
3768
  appendBatch(updates, requestedAcceptedThroughUpdateId) {
2330
- return runMutation(() => {
3769
+ const canonicalUpdates = updates.map((update) => normalizeIncomingJournaledUpdate(update, path));
3770
+ const normalizedUpdates: TelegramJournaledUpdate[] = [];
3771
+ for (const normalized of canonicalUpdates) {
3772
+ const previous = normalizedUpdates.at(-1);
3773
+ if (previous && normalized.update_id < previous.update_id) {
3774
+ throw createJournalError("invalid", path, "received an unordered update batch");
3775
+ }
3776
+ if (previous?.update_id === normalized.update_id) {
3777
+ if (!isDeepStrictEqual(previous, normalized)) {
3778
+ throw createJournalError("conflict", path, `received conflicting update ${normalized.update_id}`);
3779
+ }
3780
+ continue;
3781
+ }
3782
+ normalizedUpdates.push(normalized);
3783
+ }
3784
+ const appendWithEvidence = (preApprovalExcluded?: boolean) => runJournalTransaction((readCurrent) => {
3785
+ if (withPairingAdmission && (typeof preApprovalExcluded !== "boolean" || requestedAcceptedThroughUpdateId === undefined)) {
3786
+ throw createJournalError("pairing-evidence", path, "requires exclusion evidence and an admission cursor");
3787
+ }
2331
3788
  if (
2332
3789
  requestedAcceptedThroughUpdateId !== undefined &&
2333
3790
  !isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)
@@ -2339,30 +3796,6 @@ export function createTelegramUpdateJournalStore(
2339
3796
  );
2340
3797
  }
2341
3798
  const current = readCurrent();
2342
- const normalizedUpdates: TelegramJournaledUpdate[] = [];
2343
- for (const update of updates) {
2344
- const normalized = normalizeIncomingJournaledUpdate(update, path);
2345
- const previous = normalizedUpdates.at(-1);
2346
- if (previous && normalized.update_id < previous.update_id) {
2347
- throw createJournalError(
2348
- "invalid",
2349
- path,
2350
- "received an unordered update batch",
2351
- );
2352
- }
2353
- if (previous?.update_id === normalized.update_id) {
2354
- if (!isDeepStrictEqual(previous, normalized)) {
2355
- throw createJournalError(
2356
- "conflict",
2357
- path,
2358
- `received conflicting update ${normalized.update_id}`,
2359
- );
2360
- }
2361
- continue;
2362
- }
2363
- normalizedUpdates.push(normalized);
2364
- }
2365
-
2366
3799
  const entriesById = new Map(
2367
3800
  current.file.entries.map((entry) => [entry.updateId, entry]),
2368
3801
  );
@@ -2393,6 +3826,10 @@ export function createTelegramUpdateJournalStore(
2393
3826
  duplicateUpdateIds.push(update.update_id);
2394
3827
  continue;
2395
3828
  }
3829
+ if (withPairingAdmission && previousAcceptedThroughUpdateId !== undefined && update.update_id <= previousAcceptedThroughUpdateId) {
3830
+ duplicateUpdateIds.push(update.update_id);
3831
+ continue;
3832
+ }
2396
3833
  if (admittedAtMs === undefined) {
2397
3834
  admittedAtMs = getNowMs();
2398
3835
  if (!isSafeNonNegativeInteger(admittedAtMs)) {
@@ -2406,6 +3843,7 @@ export function createTelegramUpdateJournalStore(
2406
3843
  const entry: TelegramUpdateJournalEntry = {
2407
3844
  updateId: update.update_id,
2408
3845
  update,
3846
+ ...(withPairingAdmission ? { preApprovalExcluded: preApprovalExcluded! } : {}),
2409
3847
  admittedAtMs,
2410
3848
  state: "pending",
2411
3849
  };
@@ -2451,15 +3889,47 @@ export function createTelegramUpdateJournalStore(
2451
3889
  acceptedThroughUpdateId,
2452
3890
  );
2453
3891
  return {
3892
+ nonExcludedUpdateIds: normalizedUpdates.flatMap((update) => {
3893
+ const entry = entriesById.get(update.update_id);
3894
+ return entry && entry.preApprovalExcluded !== true ? [entry.updateId] : [];
3895
+ }),
2454
3896
  addedUpdateIds,
2455
3897
  duplicateUpdateIds,
2456
3898
  entryCount: published.file.entries.length,
2457
3899
  serializedBytes: published.serializedBytes,
2458
3900
  };
2459
3901
  });
3902
+ const append = () => {
3903
+ if (withPairedAdmission) {
3904
+ const result = withPairedAdmission(normalizedUpdates, () => appendWithEvidence());
3905
+ if (!result.admitted) throw createJournalError("sender-denied", path, "refused paired-only sender admission");
3906
+ return result.value;
3907
+ }
3908
+ if (withPairingAdmission) return withPairingAdmission(appendWithEvidence);
3909
+ return withSourceSerialization
3910
+ ? withSourceSerialization(() => appendWithEvidence())
3911
+ : appendWithEvidence();
3912
+ };
3913
+ const admittedAppend = () => {
3914
+ if (!workspaceAdmission) return append();
3915
+ const operationHash = createHash("sha256").update(path).update("\0");
3916
+ for (const update of canonicalUpdates) {
3917
+ operationHash.update(String(update.update_id)).update("\0");
3918
+ }
3919
+ operationHash.update(String(requestedAcceptedThroughUpdateId ?? "none"));
3920
+ return runWithTelegramWorkspaceAdmissions({
3921
+ ledger: workspaceAdmission,
3922
+ operationId: `journal:${operationHash.digest("hex")}`,
3923
+ operationKind: "journal.append",
3924
+ scopes: getTelegramUpdateJournalAdmissionScopes(canonicalUpdates),
3925
+ operation: append,
3926
+ });
3927
+ };
3928
+ return options.withWriterAdmission
3929
+ ? options.withWriterAdmission(admittedAppend) : admittedAppend();
2460
3930
  },
2461
3931
  markQueued(receipt) {
2462
- return runMutation(() => {
3932
+ return runMutation((readCurrent) => {
2463
3933
  if (
2464
3934
  (receipt.queueKind !== "prompt" &&
2465
3935
  receipt.queueKind !== "control") ||
@@ -2556,6 +4026,9 @@ export function createTelegramUpdateJournalStore(
2556
4026
  `cannot queue missing update ${updateId}`,
2557
4027
  );
2558
4028
  }
4029
+ if (entry.preApprovalExcluded) {
4030
+ throw createJournalError("pairing-evidence", path, `cannot queue excluded update ${updateId}`);
4031
+ }
2559
4032
  if (entry.state === "queued") {
2560
4033
  if (
2561
4034
  entry.queueKind !== receipt.queueKind ||
@@ -2581,6 +4054,7 @@ export function createTelegramUpdateJournalStore(
2581
4054
  updateId: entry.updateId,
2582
4055
  update: entry.update,
2583
4056
  admittedAtMs: entry.admittedAtMs,
4057
+ ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
2584
4058
  state: "queued",
2585
4059
  queueKind: receipt.queueKind,
2586
4060
  queueReceiptId: receipt.receiptId,
@@ -2610,7 +4084,7 @@ export function createTelegramUpdateJournalStore(
2610
4084
  });
2611
4085
  },
2612
4086
  markExecutionFailure(input) {
2613
- return runMutation(() => {
4087
+ return runMutation((readCurrent) => {
2614
4088
  if (
2615
4089
  !isSafeNonNegativeInteger(input.updateId) ||
2616
4090
  !isSafeNonNegativeInteger(input.expectedAttemptCount) ||
@@ -2705,6 +4179,7 @@ export function createTelegramUpdateJournalStore(
2705
4179
  updateId: entry.updateId,
2706
4180
  update: entry.update,
2707
4181
  admittedAtMs: entry.admittedAtMs,
4182
+ ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
2708
4183
  state: input.disposition,
2709
4184
  failure,
2710
4185
  ...(input.disposition === "retry-wait"
@@ -2730,7 +4205,7 @@ export function createTelegramUpdateJournalStore(
2730
4205
  });
2731
4206
  },
2732
4207
  applyOperatorDisposition(input) {
2733
- return runMutation(() => {
4208
+ return runMutation((readCurrent) => {
2734
4209
  if (
2735
4210
  !isSafeNonNegativeInteger(input.updateId) ||
2736
4211
  !isBoundedString(
@@ -2750,6 +4225,9 @@ export function createTelegramUpdateJournalStore(
2750
4225
  (candidate) => candidate.failureId === input.failureId,
2751
4226
  );
2752
4227
  if (existingDisposition) {
4228
+ if ("dispositionKind" in existingDisposition)
4229
+ throw createJournalError("conflict", path,
4230
+ `terminal failure ${input.failureId} collides with legacy custody authority`);
2753
4231
  if (
2754
4232
  existingDisposition.updateId !== input.updateId ||
2755
4233
  existingDisposition.action !== input.action
@@ -2809,6 +4287,7 @@ export function createTelegramUpdateJournalStore(
2809
4287
  updateId: entry.updateId,
2810
4288
  update: entry.update,
2811
4289
  admittedAtMs: entry.admittedAtMs,
4290
+ ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
2812
4291
  state: "retry-wait" as const,
2813
4292
  failure: entry.failure,
2814
4293
  nextRetryAtMs: disposition.committedAtMs,
@@ -2832,8 +4311,74 @@ export function createTelegramUpdateJournalStore(
2832
4311
  };
2833
4312
  });
2834
4313
  },
4314
+ applyLegacyCustodyDisposition(authority) {
4315
+ return runMutation((readCurrent) => {
4316
+ const current = readCurrent();
4317
+ if (current.file.version !== TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION)
4318
+ throw createJournalError("conflict", path,
4319
+ "legacy custody disposition requires a v3 journal");
4320
+ const existing = current.file.operatorDispositions?.find(
4321
+ candidate => candidate.failureId === authority.dispositionId);
4322
+ if (existing) {
4323
+ if (!("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody")
4324
+ throw createJournalError("conflict", path,
4325
+ `legacy custody disposition ${authority.dispositionId} already has another authority`);
4326
+ const normalizedDuplicate = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
4327
+ authority, { updateId: existing.updateId, state: "retry-wait", attemptCount: 1,
4328
+ failedAtMs: 0, failureClass: "retained-audit", summary: "retained-audit",
4329
+ evidenceSha256: existing.evidenceSha256 });
4330
+ if (!normalizedDuplicate || existing.action !== normalizedDuplicate.action ||
4331
+ existing.operatorAuthorityId !== normalizedDuplicate.operatorAuthorityId ||
4332
+ existing.authorizedAtMs !== normalizedDuplicate.authorizedAtMs)
4333
+ throw createJournalError("conflict", path,
4334
+ `legacy custody disposition ${authority.dispositionId} already has another authority`);
4335
+ let authorized = false;
4336
+ try { authorized = options.authorizeLegacyCustodyDisposition?.(normalizedDuplicate) === true; }
4337
+ catch { authorized = false; }
4338
+ if (!authorized) throw createJournalError("conflict", path,
4339
+ "legacy custody disposition is unauthorized");
4340
+ return { disposition: { ...existing }, duplicate: true,
4341
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
4342
+ }
4343
+ const entry = current.file.entries.find(candidate => candidate.updateId === authority.updateId);
4344
+ const evidence = entry && createTelegramUpdateJournalLegacyCustodyEvidence(entry);
4345
+ if (!evidence) throw createJournalError("conflict", path,
4346
+ `cannot dispose non-quarantined update ${authority.updateId}`);
4347
+ const normalized = normalizeTelegramUpdateJournalLegacyCustodyDispositionAuthority(
4348
+ authority, evidence);
4349
+ if (!normalized) throw createJournalError("conflict", path,
4350
+ `legacy custody evidence changed for update ${authority.updateId}`);
4351
+ let authorized = false;
4352
+ try { authorized = options.authorizeLegacyCustodyDisposition?.(normalized) === true; }
4353
+ catch { authorized = false; }
4354
+ if (!authorized) throw createJournalError("conflict", path,
4355
+ "legacy custody disposition is unauthorized");
4356
+ const nowMs = getNowMs();
4357
+ if (!isSafeNonNegativeInteger(nowMs)) throw createJournalError("invalid", path,
4358
+ "received an invalid legacy custody disposition timestamp");
4359
+ const disposition: TelegramUpdateJournalLegacyCustodyDisposition = {
4360
+ dispositionKind: "legacy-custody", failureId: normalized.dispositionId,
4361
+ updateId: normalized.updateId, action: normalized.action,
4362
+ committedAtMs: Math.max(nowMs, normalized.authorizedAtMs),
4363
+ evidenceSha256: normalized.evidenceSha256,
4364
+ operatorAuthorityId: normalized.operatorAuthorityId,
4365
+ authorizedAtMs: normalized.authorizedAtMs };
4366
+ const nextEntries = normalized.action === "discard"
4367
+ ? current.file.entries.filter(candidate => candidate.updateId !== entry.updateId)
4368
+ : current.file.entries.map(candidate => candidate.updateId === entry.updateId
4369
+ ? { updateId: entry.updateId, update: entry.update,
4370
+ admittedAtMs: entry.admittedAtMs,
4371
+ ...(entry.preApprovalExcluded === undefined ? {} :
4372
+ { preApprovalExcluded: entry.preApprovalExcluded }), state: "pending" as const }
4373
+ : candidate);
4374
+ const published = publishMutation(current, nextEntries, true,
4375
+ [...(current.file.operatorDispositions ?? []), disposition]);
4376
+ return { disposition: { ...disposition }, duplicate: false,
4377
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
4378
+ });
4379
+ },
2835
4380
  offerQueuedHandoff(input) {
2836
- return runMutation(() => {
4381
+ return runMutation((readCurrent) => {
2837
4382
  const { expectedOwner, recipientOwner, requestedIds, handoffId } =
2838
4383
  validateQueueHandoffInput(input, "offer");
2839
4384
  if (isTelegramUpdateJournalQueueOwnerProcess(expectedOwner, recipientOwner)) {
@@ -2919,7 +4464,7 @@ export function createTelegramUpdateJournalStore(
2919
4464
  });
2920
4465
  },
2921
4466
  acceptQueuedHandoff(input) {
2922
- return runMutation(() => {
4467
+ return runMutation((readCurrent) => {
2923
4468
  const { expectedOwner, recipientOwner, requestedIds, handoffId } =
2924
4469
  validateQueueHandoffInput(input, "accept");
2925
4470
  const current = readCurrent();
@@ -3003,10 +4548,13 @@ export function createTelegramUpdateJournalStore(
3003
4548
  updateId: entry.updateId,
3004
4549
  update: entry.update,
3005
4550
  admittedAtMs: entry.admittedAtMs,
4551
+ ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
3006
4552
  state: "queued" as const,
3007
4553
  queueKind: input.queueKind,
3008
4554
  queueReceiptId: input.receiptId,
3009
4555
  queueOwner: cloneJournalQueueOwner(queueOwner),
4556
+ ...(entry.inputProvenance
4557
+ ? { inputProvenance: structuredClone(entry.inputProvenance) } : {}),
3010
4558
  }
3011
4559
  : entry,
3012
4560
  ),
@@ -3024,7 +4572,7 @@ export function createTelegramUpdateJournalStore(
3024
4572
  });
3025
4573
  },
3026
4574
  cancelQueuedHandoff(input) {
3027
- return runMutation(() => {
4575
+ return runMutation((readCurrent) => {
3028
4576
  const { expectedOwner, recipientOwner, requestedIds, handoffId } =
3029
4577
  validateQueueHandoffInput(input, "cancel");
3030
4578
  const current = readCurrent();
@@ -3086,7 +4634,7 @@ export function createTelegramUpdateJournalStore(
3086
4634
  });
3087
4635
  },
3088
4636
  completeQueued(receipts) {
3089
- return runMutation(() => {
4637
+ return runMutation((readCurrent) => {
3090
4638
  if (!Array.isArray(receipts) || receipts.length === 0) {
3091
4639
  throw createJournalError(
3092
4640
  "invalid",
@@ -3196,7 +4744,7 @@ export function createTelegramUpdateJournalStore(
3196
4744
  });
3197
4745
  },
3198
4746
  discardQueued(input) {
3199
- return runMutation(() => {
4747
+ return runMutation((readCurrent) => {
3200
4748
  if (
3201
4749
  (input.queueKind !== "prompt" &&
3202
4750
  input.queueKind !== "control") ||
@@ -3283,7 +4831,7 @@ export function createTelegramUpdateJournalStore(
3283
4831
  });
3284
4832
  },
3285
4833
  recoverDeadQueueOwner(input) {
3286
- return runMutation(() => {
4834
+ return runMutation((readCurrent) => {
3287
4835
  if (!getQueueProcessLiveness) {
3288
4836
  throw createJournalError(
3289
4837
  "conflict",
@@ -3396,7 +4944,7 @@ export function createTelegramUpdateJournalStore(
3396
4944
  });
3397
4945
  },
3398
4946
  removeCompleted(updateIds) {
3399
- return runMutation(() => {
4947
+ return runMutation((readCurrent) => {
3400
4948
  const current = readCurrent();
3401
4949
  const requestedIds = new Set<number>();
3402
4950
  for (const updateId of updateIds) {
@@ -3442,4 +4990,514 @@ export function createTelegramUpdateJournalStore(
3442
4990
  });
3443
4991
  },
3444
4992
  };
4993
+ if (!getInputContext) return { journal };
4994
+ // Strict source acquisition already requires this exact stored receipt scope.
4995
+ const bindingKey = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
4996
+ const createInputSourceReference = (updateId: number): TelegramInputJournalSourceReference => ({
4997
+ journalBindingKey: bindingKey,
4998
+ tokenSha256: expectedIdentity.tokenSha256,
4999
+ updateId,
5000
+ });
5001
+ const createInputReceipt = (updateId: number,
5002
+ owner: TelegramUpdateJournalQueueOwner): TelegramInputJournalReceipt => ({
5003
+ ...createInputSourceReference(updateId),
5004
+ owner: cloneJournalQueueOwner(owner),
5005
+ });
5006
+ const assertInputProcess = (identity: TelegramUpdateJournalQueueOwnerIdentity): void => {
5007
+ if (!queueRuntimeIdentity ||
5008
+ identity.instanceId !== queueRuntimeIdentity.instanceId ||
5009
+ identity.processId !== queueRuntimeIdentity.processId ||
5010
+ identity.processBirthId !== queueRuntimeIdentity.processBirthId) {
5011
+ throw createJournalError("conflict", path, "input custody belongs to another runtime");
5012
+ }
5013
+ };
5014
+ const inputOwnerMatchesIdentity = (owner: TelegramUpdateJournalQueueOwner,
5015
+ identity: TelegramUpdateJournalQueueOwnerIdentity): boolean =>
5016
+ isTelegramUpdateJournalQueueOwnerProcess(owner, identity) &&
5017
+ owner.sessionGeneration === identity.sessionGeneration;
5018
+ const currentInputContext = (): TelegramInputJournalContext => {
5019
+ const observed = getInputContext();
5020
+ if (observed === undefined) throw createJournalError("conflict", path, "input execution context is unavailable");
5021
+ const owner = validateJournalQueueOwnerIdentity(observed.owner, path);
5022
+ const recipientBindingKey = observed.recipientBindingKey;
5023
+ if (!isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
5024
+ !recipientBindingKey.trim()) {
5025
+ throw createJournalError("invalid", path, "input execution binding is invalid");
5026
+ }
5027
+ assertInputProcess(owner);
5028
+ return { owner, recipientBindingKey };
5029
+ };
5030
+ const runInputAdmission = <T>(kind: string, operation: () => T): T =>
5031
+ workspaceAdmission ? runWithTelegramWorkspaceAdmissions({
5032
+ ledger: workspaceAdmission, operationId: `input:${randomUUID()}`, operationKind: `journal.input.${kind}`,
5033
+ scopes: [{ kind: "profile" }], operation,
5034
+ }) : operation();
5035
+ const runInputMutation = <T>(kind: string, operation: (read: typeof readCurrent) => T): T =>
5036
+ runInputAdmission(kind, () => runMutation(operation));
5037
+ const publishInputMutation = (current: ReadTelegramUpdateJournalResult,
5038
+ entries: TelegramUpdateJournalEntry[], updateId: number | readonly number[],
5039
+ context: TelegramInputJournalContext) =>
5040
+ publishMutation(current, entries, true, current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
5041
+ (boundary, publicationPath) => {
5042
+ onPublicationBoundary?.(boundary, publicationPath);
5043
+ if (!isDeepStrictEqual(currentInputContext(), context)) {
5044
+ throw createJournalError("conflict", path, "input execution context changed before publication");
5045
+ }
5046
+ }, updateId);
5047
+ const publishInputSettlement = (current: ReadTelegramUpdateJournalResult,
5048
+ entries: TelegramUpdateJournalEntry[], contentChanged: boolean) => publishMutation(current, entries,
5049
+ contentChanged, current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
5050
+ onPublicationBoundary, false);
5051
+ const normalizeInputSourceReference = (value: TelegramInputJournalSourceReference,
5052
+ operation: string): TelegramInputJournalSourceReference => {
5053
+ if (!isRecord(value) || !hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId"])) {
5054
+ throw createJournalError("invalid", path, `received invalid input handoff ${operation} source`);
5055
+ }
5056
+ const journalBindingKey = value.journalBindingKey;
5057
+ const updateId = value.updateId;
5058
+ if (journalBindingKey !== bindingKey || value.tokenSha256 !== expectedIdentity.tokenSha256 ||
5059
+ !isSafeNonNegativeInteger(updateId)) {
5060
+ throw createJournalError("conflict", path, "input receipt does not match its source identity");
5061
+ }
5062
+ return { journalBindingKey, tokenSha256: expectedIdentity.tokenSha256, updateId };
5063
+ };
5064
+ const normalizeInputReceipt = (value: TelegramInputJournalReceipt,
5065
+ requireCurrentProcess = true): TelegramInputJournalReceipt => {
5066
+ if (!isRecord(value) ||
5067
+ !hasOnlyKeys(value, ["journalBindingKey", "tokenSha256", "updateId", "owner"])) {
5068
+ throw createJournalError("invalid", path, "received an invalid input receipt");
5069
+ }
5070
+ const source = normalizeInputSourceReference({ journalBindingKey: value.journalBindingKey,
5071
+ tokenSha256: value.tokenSha256, updateId: value.updateId }, "receipt");
5072
+ const owner = validateJournalQueueOwner(value.owner, path);
5073
+ if (requireCurrentProcess) assertInputProcess(owner);
5074
+ return { ...source, owner };
5075
+ };
5076
+ const normalizeInputHandoffOffer = (value: TelegramInputJournalHandoffOfferInput) => {
5077
+ if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffToken"]) ||
5078
+ !isBoundedString(value.handoffToken, TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MAX_LENGTH) ||
5079
+ value.handoffToken.length < TELEGRAM_UPDATE_JOURNAL_QUEUE_HANDOFF_TOKEN_MIN_LENGTH) {
5080
+ throw createJournalError("invalid", path, "received invalid input handoff offer");
5081
+ }
5082
+ const receipt = normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt);
5083
+ const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
5084
+ if (isTelegramUpdateJournalQueueOwnerProcess(receipt.owner, recipientOwner)) {
5085
+ throw createJournalError("conflict", path, "cannot transfer input custody to the same runtime process");
5086
+ }
5087
+ return { receipt, recipientOwner, handoffToken: value.handoffToken };
5088
+ };
5089
+ const normalizeInputHandoffId = (value: unknown, operation: "accept" | "cancel") => {
5090
+ if (!isTelegramInputHandoffId(value)) {
5091
+ throw createJournalError("invalid", path, `received invalid input handoff ${operation}`);
5092
+ }
5093
+ return value;
5094
+ };
5095
+ const normalizeInputHandoffAccept = (value: TelegramInputJournalHandoffAcceptInput) => {
5096
+ if (!isRecord(value) || !hasOnlyKeys(value, ["source", "recipientOwner", "handoffId"])) {
5097
+ throw createJournalError("invalid", path, "received invalid input handoff accept");
5098
+ }
5099
+ const source = normalizeInputSourceReference(
5100
+ value.source as TelegramInputJournalSourceReference, "accept");
5101
+ const recipientOwner = validateJournalQueueOwnerIdentity(value.recipientOwner, path);
5102
+ assertInputProcess(recipientOwner);
5103
+ return { source, recipientOwner, handoffId: normalizeInputHandoffId(value.handoffId, "accept") };
5104
+ };
5105
+ const normalizeInputHandoffCancel = (value: TelegramInputJournalHandoffCancelInput) => {
5106
+ if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recipientOwner", "handoffId"])) {
5107
+ throw createJournalError("invalid", path, "received invalid input handoff cancel");
5108
+ }
5109
+ return { receipt: normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt),
5110
+ recipientOwner: validateJournalQueueOwnerIdentity(value.recipientOwner, path),
5111
+ handoffId: normalizeInputHandoffId(value.handoffId, "cancel") };
5112
+ };
5113
+ const normalizeInputQueue = (value: TelegramInputJournalQueueInput) => {
5114
+ if (!isRecord(value) || !hasOnlyKeys(value, ["queueKind", "receiptId", "receipts"]) ||
5115
+ (value.queueKind !== "prompt" && value.queueKind !== "control") ||
5116
+ !isBoundedString(value.receiptId, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH) ||
5117
+ !value.receiptId.trim() || !Array.isArray(value.receipts) || value.receipts.length === 0) {
5118
+ throw createJournalError("invalid", path, "received invalid input queue transition");
5119
+ }
5120
+ const receipts = value.receipts.map(receipt =>
5121
+ normalizeInputReceipt(receipt as TelegramInputJournalReceipt)).sort((left, right) => left.updateId - right.updateId);
5122
+ if (receipts.some((receipt, index) => index > 0 && receipt.updateId === receipts[index - 1]!.updateId)) {
5123
+ throw createJournalError("invalid", path, "received duplicate input queue transition receipts");
5124
+ }
5125
+ return { queueKind: value.queueKind, receiptId: value.receiptId, receipts };
5126
+ };
5127
+ const ownedInput = (current: ReadTelegramUpdateJournalResult, receipt: TelegramInputJournalReceipt): TelegramUpdateJournalEntry => {
5128
+ const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
5129
+ if (!entry?.inputClaim || entry.state !== "pending" ||
5130
+ !areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner)) {
5131
+ throw createJournalError("conflict", path, "input receipt lost its exact acquisition or phase");
5132
+ }
5133
+ return entry;
5134
+ };
5135
+ const getReadyInputRelease = (current: ReadTelegramUpdateJournalResult,
5136
+ receipt: TelegramInputJournalReceipt, allowOffered = false): {
5137
+ entry: TelegramUpdateJournalEntry; unclaimed: boolean;
5138
+ } => {
5139
+ const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
5140
+ if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
5141
+ throw createJournalError("conflict", path, "input is unavailable for ready-claim release");
5142
+ }
5143
+ if (!entry.inputClaim) return { entry, unclaimed: true };
5144
+ if (!areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
5145
+ entry.inputClaim.phase !== "ready" || (!allowOffered && entry.inputClaim.handoff !== undefined)) {
5146
+ throw createJournalError("conflict", path, "cannot release stale, running, or offered input authority");
5147
+ }
5148
+ return { entry, unclaimed: false };
5149
+ };
5150
+ const publishReadyInputRelease = (current: ReadTelegramUpdateJournalResult,
5151
+ entry: TelegramUpdateJournalEntry) => {
5152
+ const released = cloneEntry(entry);
5153
+ delete released.inputClaim;
5154
+ return publishMutation(current, current.file.entries.map(candidate =>
5155
+ candidate.updateId === entry.updateId ? released : candidate), true,
5156
+ current.file.operatorDispositions, current.file.acceptedThroughUpdateId,
5157
+ onPublicationBoundary, entry.updateId);
5158
+ };
5159
+ const input: TelegramInputJournalStore = {
5160
+ read: journal.read,
5161
+ appendBatch: journal.appendBatch,
5162
+ listLegacyCustodyCandidates() {
5163
+ const sourceAccess = options.sourceAccess!;
5164
+ const evidence = readTelegramUpdateJournalSource({
5165
+ directory: sourceAccess.directory, path, profile,
5166
+ botIdentity: expectedIdentity, limits: sourceAccess.limits,
5167
+ version: TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION,
5168
+ });
5169
+ return evidence.kind === "present"
5170
+ ? listTelegramUpdateJournalLegacyCustodyCandidates(evidence.file) : [];
5171
+ },
5172
+ applyLegacyCustodyDisposition(value) {
5173
+ return runInputAdmission("legacy-custody-disposition", () =>
5174
+ journal.applyLegacyCustodyDisposition(value));
5175
+ },
5176
+ completeQueued(receipts) {
5177
+ return runInputAdmission("complete-queued", () => journal.completeQueued(receipts));
5178
+ },
5179
+ discardQueued(value) {
5180
+ return runInputAdmission("discard-queued", () => journal.discardQueued(value));
5181
+ },
5182
+ recoverDeadQueueOwner(value) {
5183
+ return runInputAdmission("recover-queued", () => journal.recoverDeadQueueOwner(value));
5184
+ },
5185
+ offerQueuedHandoff(value) {
5186
+ return runInputAdmission("offer-queued-handoff", () => journal.offerQueuedHandoff(value));
5187
+ },
5188
+ acceptQueuedHandoff(value) {
5189
+ return runInputAdmission("accept-queued-handoff", () => journal.acceptQueuedHandoff(value));
5190
+ },
5191
+ cancelQueuedHandoff(value) {
5192
+ return runInputAdmission("cancel-queued-handoff", () => journal.cancelQueuedHandoff(value));
5193
+ },
5194
+ removeExcluded(updateIds) {
5195
+ const requestedIds = new Set<number>();
5196
+ for (const updateId of updateIds) {
5197
+ if (!isSafeNonNegativeInteger(updateId)) {
5198
+ throw createJournalError("invalid", path, "received an invalid exclusion removal update id");
5199
+ }
5200
+ requestedIds.add(updateId);
5201
+ }
5202
+ return runInputMutation("remove-excluded", read => {
5203
+ const current = read();
5204
+ if (!current.exists || [...requestedIds].some(id => id > (current.file.acceptedThroughUpdateId ?? -1))) {
5205
+ throw createJournalError("conflict", path, "exclusion removal lacks retained source admission evidence");
5206
+ }
5207
+ const selected = current.file.entries.filter(entry => requestedIds.has(entry.updateId));
5208
+ if (selected.some(entry => entry.preApprovalExcluded !== true)) {
5209
+ throw createJournalError("conflict", path, "cannot remove input without immutable exclusion evidence");
5210
+ }
5211
+ // Strict v3 decoding already excludes raw claims and queued authority on vetoed entries.
5212
+ const removedUpdateIds = selected.map(entry => entry.updateId);
5213
+ const published = publishInputSettlement(current,
5214
+ current.file.entries.filter(entry => !requestedIds.has(entry.updateId)), removedUpdateIds.length > 0);
5215
+ return { removedUpdateIds, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5216
+ });
5217
+ },
5218
+ releaseInput(value) {
5219
+ const receipt = normalizeInputReceipt(value);
5220
+ return runInputMutation("release", read => {
5221
+ const current = read();
5222
+ const release = getReadyInputRelease(current, receipt);
5223
+ if (release.unclaimed) return { released: false,
5224
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5225
+ const published = publishReadyInputRelease(current, release.entry);
5226
+ return { released: true, entryCount: published.file.entries.length,
5227
+ serializedBytes: published.serializedBytes };
5228
+ });
5229
+ },
5230
+ recoverReadyInput(value) {
5231
+ if (!isRecord(value) || !hasOnlyKeys(value, ["receipt", "recoveryOwner"])) {
5232
+ throw createJournalError("invalid", path, "received invalid ready-input recovery authority");
5233
+ }
5234
+ const receipt = normalizeInputReceipt(value.receipt as TelegramInputJournalReceipt, false);
5235
+ const recoveryOwner = validateJournalQueueOwnerIdentity(value.recoveryOwner, path);
5236
+ assertInputProcess(recoveryOwner);
5237
+ return runInputMutation("recover-ready", read => {
5238
+ const current = read();
5239
+ const release = getReadyInputRelease(current, receipt, true);
5240
+ if (release.unclaimed) return { status: "unclaimed" as const,
5241
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5242
+ let liveness: TelegramProcessLiveness;
5243
+ try {
5244
+ liveness = getQueueProcessLiveness({ processId: receipt.owner.processId,
5245
+ processBirthId: receipt.owner.processBirthId });
5246
+ } catch (error) {
5247
+ throw createJournalError("io", path, "could not prove ready input owner liveness", error);
5248
+ }
5249
+ if (liveness !== "dead") return { status: liveness === "alive" ? "owner-alive" as const : "owner-unverifiable" as const,
5250
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5251
+ const published = publishReadyInputRelease(current, release.entry);
5252
+ return { status: "recovered" as const, entryCount: published.file.entries.length,
5253
+ serializedBytes: published.serializedBytes };
5254
+ });
5255
+ },
5256
+ offerInputHandoff(value) {
5257
+ const { receipt, recipientOwner, handoffToken } = normalizeInputHandoffOffer(value);
5258
+ return runInputMutation("offer", read => {
5259
+ const context = currentInputContext();
5260
+ const current = read();
5261
+ const entry = ownedInput(current, receipt);
5262
+ const claim = entry.inputClaim!;
5263
+ if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
5264
+ claim.recipientBindingKey !== context.recipientBindingKey) {
5265
+ throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
5266
+ }
5267
+ if (claim.handoff) {
5268
+ if (!isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
5269
+ throw createJournalError("conflict", path, "input already has another handoff offer");
5270
+ }
5271
+ return { source: createInputSourceReference(receipt.updateId),
5272
+ handoff: cloneJournalQueueHandoff(claim.handoff),
5273
+ previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: true,
5274
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5275
+ }
5276
+ const handoffId = createTelegramInputHandoffId({ handoffToken, journalBindingKey: bindingKey,
5277
+ updateId: receipt.updateId, donorOwner: receipt.owner, recipientOwner,
5278
+ recipientBindingKey: claim.recipientBindingKey });
5279
+ const offeredAtMs = getNowMs();
5280
+ if (!isSafeNonNegativeInteger(offeredAtMs)) {
5281
+ throw createJournalError("invalid", path, "received an invalid input handoff offer timestamp");
5282
+ }
5283
+ const handoff: TelegramUpdateJournalInputHandoff = { handoffId, offeredAtMs,
5284
+ recipientOwner: { ...recipientOwner } };
5285
+ const offeredClaim = { ...claim, handoff };
5286
+ const published = publishInputMutation(current, current.file.entries.map(candidate =>
5287
+ candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: offeredClaim } : candidate),
5288
+ receipt.updateId, context);
5289
+ return { source: createInputSourceReference(receipt.updateId),
5290
+ handoff: cloneJournalQueueHandoff(handoff),
5291
+ previousOwner: cloneJournalQueueOwner(receipt.owner), duplicate: false,
5292
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5293
+ });
5294
+ },
5295
+ acceptInputHandoff(value) {
5296
+ const { source, recipientOwner, handoffId } = normalizeInputHandoffAccept(value);
5297
+ return runInputMutation("accept", read => {
5298
+ const context = currentInputContext();
5299
+ const current = read();
5300
+ const entry = current.file.entries.find(candidate => candidate.updateId === source.updateId);
5301
+ const claim = entry?.inputClaim;
5302
+ if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false || !claim ||
5303
+ !isDeepStrictEqual(context.owner, recipientOwner) ||
5304
+ context.recipientBindingKey !== claim.recipientBindingKey) {
5305
+ throw createJournalError("conflict", path, "input handoff recipient authority is unavailable or changed");
5306
+ }
5307
+ if (claim.owner.handoffId === handoffId && inputOwnerMatchesIdentity(claim.owner, recipientOwner)) {
5308
+ return { handoffId, receipt: createInputReceipt(source.updateId, claim.owner), duplicate: true,
5309
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5310
+ }
5311
+ if (claim.phase !== "ready" || claim.handoff?.handoffId !== handoffId ||
5312
+ !isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
5313
+ throw createJournalError("conflict", path, "cannot accept stale or unauthenticated input handoff");
5314
+ }
5315
+ const acquiredAtMs = getNowMs();
5316
+ if (!isSafeNonNegativeInteger(acquiredAtMs)) {
5317
+ throw createJournalError("invalid", path, "received an invalid input handoff acquisition timestamp");
5318
+ }
5319
+ const previousOwner = cloneJournalQueueOwner(claim.owner);
5320
+ const owner: TelegramUpdateJournalQueueOwner = { ...recipientOwner,
5321
+ acquisitionId: randomUUID(), acquiredAtMs, handoffId };
5322
+ const acceptedClaim = structuredClone(claim);
5323
+ acceptedClaim.owner = owner;
5324
+ delete acceptedClaim.handoff;
5325
+ const publishedEntries = current.file.entries.map(candidate =>
5326
+ candidate.updateId === source.updateId ? { ...candidate, inputClaim: acceptedClaim } : candidate);
5327
+ const published = publishInputMutation(current, publishedEntries, source.updateId, context);
5328
+ return { handoffId, previousOwner, receipt: createInputReceipt(source.updateId, owner), duplicate: false,
5329
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5330
+ });
5331
+ },
5332
+ cancelInputHandoff(value) {
5333
+ const { receipt, recipientOwner, handoffId } = normalizeInputHandoffCancel(value);
5334
+ return runInputMutation("cancel", read => {
5335
+ const context = currentInputContext();
5336
+ const current = read();
5337
+ const entry = ownedInput(current, receipt);
5338
+ const claim = entry.inputClaim!;
5339
+ if (claim.phase !== "ready" || !inputOwnerMatchesIdentity(receipt.owner, context.owner) ||
5340
+ claim.recipientBindingKey !== context.recipientBindingKey) {
5341
+ throw createJournalError("conflict", path, "input handoff donor authority is stale or running");
5342
+ }
5343
+ if (!claim.handoff) return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner),
5344
+ cancelled: false, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5345
+ if (claim.handoff.handoffId !== handoffId ||
5346
+ !isDeepStrictEqual(claim.handoff.recipientOwner, recipientOwner)) {
5347
+ throw createJournalError("conflict", path, "cannot cancel another input handoff offer");
5348
+ }
5349
+ const cancelledClaim = structuredClone(claim);
5350
+ delete cancelledClaim.handoff;
5351
+ const publishedEntries = current.file.entries.map(candidate =>
5352
+ candidate.updateId === receipt.updateId ? { ...candidate, inputClaim: cancelledClaim } : candidate);
5353
+ const published = publishInputMutation(current, publishedEntries, receipt.updateId, context);
5354
+ return { handoffId, previousOwner: cloneJournalQueueOwner(receipt.owner), cancelled: true,
5355
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5356
+ });
5357
+ },
5358
+ queueInputs(value) {
5359
+ const { queueKind, receiptId, receipts } = normalizeInputQueue(value);
5360
+ return runInputMutation("queue", read => {
5361
+ const context = currentInputContext();
5362
+ if (receipts.some(receipt => !inputOwnerMatchesIdentity(receipt.owner, context.owner))) {
5363
+ throw createJournalError("conflict", path, "input queue transition belongs to another session");
5364
+ }
5365
+ const current = read();
5366
+ const receiptsById = new Map(receipts.map(receipt => [receipt.updateId, receipt]));
5367
+ const requestedIds = receipts.map(receipt => receipt.updateId);
5368
+ const existingReceiptEntries = current.file.entries.filter(entry => entry.queueReceiptId === receiptId);
5369
+ if (existingReceiptEntries.length > 0) {
5370
+ const queueOwner = existingReceiptEntries[0]?.queueOwner;
5371
+ if (existingReceiptEntries.length !== receipts.length || !queueOwner ||
5372
+ !inputOwnerMatchesIdentity(queueOwner, context.owner) ||
5373
+ existingReceiptEntries.some(entry => {
5374
+ const receipt = receiptsById.get(entry.updateId);
5375
+ return !receipt || entry.state !== "queued" || entry.queueKind !== queueKind ||
5376
+ entry.queueHandoff !== undefined || !entry.inputProvenance ||
5377
+ !areTelegramUpdateJournalQueueOwnersEqual(entry.inputProvenance.owner, receipt.owner) ||
5378
+ entry.inputProvenance.recipientBindingKey !== context.recipientBindingKey;
5379
+ })) {
5380
+ throw createJournalError("conflict", path, "input queue transition conflicts with queued authority");
5381
+ }
5382
+ return { queued: false, queueReceipt: { queueKind, receiptId,
5383
+ sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
5384
+ entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5385
+ }
5386
+ for (const receipt of receipts) {
5387
+ const entry = current.file.entries.find(candidate => candidate.updateId === receipt.updateId);
5388
+ if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false ||
5389
+ !entry.inputClaim || entry.inputClaim.phase !== "running" || entry.inputClaim.handoff ||
5390
+ !areTelegramUpdateJournalQueueOwnersEqual(entry.inputClaim.owner, receipt.owner) ||
5391
+ entry.inputClaim.recipientBindingKey !== context.recipientBindingKey) {
5392
+ throw createJournalError("conflict", path, "input queue transition lost exact running authority");
5393
+ }
5394
+ }
5395
+ const acquiredAtMs = getNowMs();
5396
+ if (!isSafeNonNegativeInteger(acquiredAtMs)) {
5397
+ throw createJournalError("invalid", path, "received an invalid input queue acquisition timestamp");
5398
+ }
5399
+ const queueOwner: TelegramUpdateJournalQueueOwner = { ...context.owner,
5400
+ acquisitionId: randomUUID(), acquiredAtMs };
5401
+ const queuedEntries = current.file.entries.map(entry => {
5402
+ const receipt = receiptsById.get(entry.updateId);
5403
+ if (!receipt) return entry;
5404
+ const claim = entry.inputClaim!;
5405
+ return { updateId: entry.updateId, update: entry.update,
5406
+ admittedAtMs: entry.admittedAtMs, preApprovalExcluded: false, state: "queued" as const,
5407
+ queueKind, queueReceiptId: receiptId, queueOwner: cloneJournalQueueOwner(queueOwner),
5408
+ inputProvenance: { owner: cloneJournalQueueOwner(claim.owner),
5409
+ recipientBindingKey: claim.recipientBindingKey,
5410
+ ...(claim.executionUpdate
5411
+ ? { executionUpdate: structuredClone(claim.executionUpdate) } : {}) } };
5412
+ });
5413
+ const published = publishInputMutation(current, queuedEntries, requestedIds, context);
5414
+ return { queued: true, queueReceipt: { queueKind, receiptId,
5415
+ sourceUpdateIds: requestedIds, queueOwner: cloneJournalQueueOwner(queueOwner) },
5416
+ entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5417
+ });
5418
+ },
5419
+ acquireInput(value) {
5420
+ const updateId = value.updateId;
5421
+ const recipientBindingKey = value.recipientBindingKey;
5422
+ const executionUpdate = value.executionUpdate === undefined ? undefined : normalizeIncomingJournaledUpdate(value.executionUpdate, path);
5423
+ if (!isSafeNonNegativeInteger(updateId) ||
5424
+ !isBoundedString(recipientBindingKey, TELEGRAM_UPDATE_JOURNAL_INPUT_BINDING_MAX_LENGTH) ||
5425
+ !recipientBindingKey.trim() || (executionUpdate && executionUpdate.update_id !== updateId)) {
5426
+ throw createJournalError("invalid", path, "received invalid input acquisition identity");
5427
+ }
5428
+ return runInputMutation("acquire", read => {
5429
+ const context = currentInputContext();
5430
+ const identity = context.owner;
5431
+ if (recipientBindingKey !== context.recipientBindingKey) {
5432
+ throw createJournalError("conflict", path, "input acquisition targets another execution binding");
5433
+ }
5434
+ const current = read();
5435
+ const entry = current.file.entries.find(candidate => candidate.updateId === updateId);
5436
+ if (!entry || entry.state !== "pending" || entry.preApprovalExcluded !== false) {
5437
+ throw createJournalError("conflict", path, "input is not eligible for acquisition");
5438
+ }
5439
+ const projected = executionUpdate ?? entry.update;
5440
+ const existing = entry.inputClaim;
5441
+ if (existing) {
5442
+ if (!isTelegramUpdateJournalQueueOwnerProcess(existing.owner, identity) ||
5443
+ existing.owner.sessionGeneration !== identity.sessionGeneration || existing.recipientBindingKey !== recipientBindingKey ||
5444
+ !isDeepStrictEqual(existing.executionUpdate ?? entry.update, projected)) {
5445
+ throw createJournalError("conflict", path, "input already has another owner or execution projection");
5446
+ }
5447
+ return { acquired: false, receipt: createInputReceipt(updateId, existing.owner) };
5448
+ }
5449
+ const claim: TelegramUpdateJournalInputClaim = { phase: "ready", recipientBindingKey,
5450
+ owner: { ...identity, acquisitionId: randomUUID(), acquiredAtMs: getNowMs() },
5451
+ ...(!isDeepStrictEqual(projected, entry.update) ? { executionUpdate: projected } : {}) };
5452
+ const claimedEntries = current.file.entries.map(candidate =>
5453
+ candidate.updateId === updateId ? { ...candidate, inputClaim: claim } : candidate);
5454
+ const reservedEntry = { ...entry, inputClaim: createInputHeadroomClaim(entry, "ready") };
5455
+ const reservedEntries = replaceInputHeadroomEntry(current.file, updateId, reservedEntry);
5456
+ const actualBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: claimedEntries }));
5457
+ const reservedBytes = Buffer.byteLength(serializeJournalFile({ ...current.file, entries: reservedEntries })) +
5458
+ TELEGRAM_UPDATE_JOURNAL_INPUT_PROJECTION_HEADROOM_BYTES;
5459
+ if (actualBytes > reservedBytes) {
5460
+ throw createJournalError("capacity", path, "execution projection exceeds reserved input headroom");
5461
+ }
5462
+ publishInputMutation(current, claimedEntries, updateId, context);
5463
+ return { acquired: true, receipt: createInputReceipt(updateId, claim.owner) };
5464
+ });
5465
+ },
5466
+ startInput(value) {
5467
+ const receipt = normalizeInputReceipt(value);
5468
+ return runInputMutation("start", read => {
5469
+ const context = currentInputContext();
5470
+ const current = read();
5471
+ const entry = ownedInput(current, receipt);
5472
+ const claim = entry.inputClaim!;
5473
+ if (context.owner.sessionGeneration !== receipt.owner.sessionGeneration ||
5474
+ context.recipientBindingKey !== claim.recipientBindingKey) {
5475
+ throw createJournalError("conflict", path, "input belongs to another execution session or binding");
5476
+ }
5477
+ if (claim.phase === "running") return { started: false as const };
5478
+ if (claim.handoff) throw createJournalError("conflict", path, "input handoff freezes donor execution");
5479
+ publishInputMutation(current, current.file.entries.map(candidate =>
5480
+ candidate.updateId === receipt.updateId
5481
+ ? { ...candidate, inputClaim: { ...claim, phase: "running" as const } } : candidate),
5482
+ receipt.updateId, context);
5483
+ return { started: true as const, update: structuredClone(claim.executionUpdate ?? entry.update) };
5484
+ });
5485
+ },
5486
+ completeInput(value) {
5487
+ const receipt = normalizeInputReceipt(value);
5488
+ return runInputMutation("complete", read => {
5489
+ const current = read();
5490
+ const exists = current.file.entries.some(entry => entry.updateId === receipt.updateId);
5491
+ if (!exists && current.exists && receipt.updateId <= (current.file.acceptedThroughUpdateId ?? -1)) {
5492
+ return { removedUpdateIds: [], entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5493
+ }
5494
+ const entry = ownedInput(current, receipt);
5495
+ if (entry.inputClaim!.phase !== "running") throw createJournalError("conflict", path, "input has not started");
5496
+ const published = publishInputSettlement(current,
5497
+ current.file.entries.filter(candidate => candidate.updateId !== receipt.updateId), true);
5498
+ return { removedUpdateIds: [receipt.updateId], entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5499
+ });
5500
+ },
5501
+ };
5502
+ return { journal, input };
3445
5503
  }