@llblab/pi-telegram 0.51.6 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/AGENTS.md +15 -12
  2. package/BACKLOG.md +16 -9
  3. package/CHANGELOG.md +19 -0
  4. package/LICENSE +21 -0
  5. package/README.md +17 -6
  6. package/dist/lib/activity-verbosity.d.ts +1 -5
  7. package/dist/lib/activity-verbosity.js +5 -7
  8. package/dist/lib/bindings.d.ts +5 -1
  9. package/dist/lib/bindings.js +7 -1
  10. package/dist/lib/bus-api.js +8 -5
  11. package/dist/lib/bus-follower.d.ts +54 -56
  12. package/dist/lib/bus-follower.js +206 -139
  13. package/dist/lib/bus-leader.d.ts +21 -2
  14. package/dist/lib/bus-leader.js +181 -25
  15. package/dist/lib/bus-transport.d.ts +4 -1
  16. package/dist/lib/bus-transport.js +28 -4
  17. package/dist/lib/bus.d.ts +52 -56
  18. package/dist/lib/bus.js +78 -246
  19. package/dist/lib/channel-posts.d.ts +1 -3
  20. package/dist/lib/channel-posts.js +23 -26
  21. package/dist/lib/command-templates.d.ts +0 -5
  22. package/dist/lib/command-templates.js +5 -5
  23. package/dist/lib/commands.d.ts +0 -23
  24. package/dist/lib/commands.js +15 -15
  25. package/dist/lib/config.d.ts +0 -17
  26. package/dist/lib/config.js +12 -14
  27. package/dist/lib/delivery.d.ts +0 -4
  28. package/dist/lib/delivery.js +2 -5
  29. package/dist/lib/extension.js +258 -61
  30. package/dist/lib/generative-apps.d.ts +0 -19
  31. package/dist/lib/inbound.d.ts +0 -2
  32. package/dist/lib/inbound.js +2 -2
  33. package/dist/lib/journal.d.ts +183 -16
  34. package/dist/lib/journal.js +1175 -248
  35. package/dist/lib/keyboard.d.ts +0 -2
  36. package/dist/lib/keyboard.js +2 -2
  37. package/dist/lib/lifecycle.js +1 -1
  38. package/dist/lib/locks.d.ts +100 -12
  39. package/dist/lib/locks.js +380 -14
  40. package/dist/lib/logging.d.ts +30 -12
  41. package/dist/lib/logging.js +129 -72
  42. package/dist/lib/media.d.ts +28 -5
  43. package/dist/lib/media.js +26 -8
  44. package/dist/lib/menu-model.d.ts +0 -11
  45. package/dist/lib/menu-model.js +8 -8
  46. package/dist/lib/menu-settings.d.ts +0 -18
  47. package/dist/lib/menu-settings.js +18 -18
  48. package/dist/lib/menu-status.d.ts +4 -4
  49. package/dist/lib/menu-status.js +12 -7
  50. package/dist/lib/menu-thinking.d.ts +1 -1
  51. package/dist/lib/menu-thinking.js +1 -1
  52. package/dist/lib/menu.d.ts +1 -0
  53. package/dist/lib/menu.js +4 -3
  54. package/dist/lib/outbound-attachments.d.ts +0 -1
  55. package/dist/lib/outbound-attachments.js +1 -1
  56. package/dist/lib/outbound-buttons.js +1 -8
  57. package/dist/lib/outbound-markup.d.ts +1 -6
  58. package/dist/lib/outbound-markup.js +4 -21
  59. package/dist/lib/outbound.d.ts +0 -8
  60. package/dist/lib/outbound.js +5 -5
  61. package/dist/lib/paths.d.ts +50 -7
  62. package/dist/lib/paths.js +155 -17
  63. package/dist/lib/pi.d.ts +1 -0
  64. package/dist/lib/pi.js +2 -1
  65. package/dist/lib/polling.d.ts +0 -5
  66. package/dist/lib/polling.js +5 -5
  67. package/dist/lib/preview.d.ts +0 -3
  68. package/dist/lib/preview.js +3 -3
  69. package/dist/lib/prompt-templates.d.ts +0 -1
  70. package/dist/lib/prompt-templates.js +1 -1
  71. package/dist/lib/queue.d.ts +5 -15
  72. package/dist/lib/queue.js +94 -11
  73. package/dist/lib/recovery.d.ts +29 -9
  74. package/dist/lib/recovery.js +104 -33
  75. package/dist/lib/replies.d.ts +0 -4
  76. package/dist/lib/replies.js +1 -1
  77. package/dist/lib/routing.d.ts +74 -8
  78. package/dist/lib/routing.js +2280 -307
  79. package/dist/lib/sections.d.ts +0 -2
  80. package/dist/lib/sections.js +1 -1
  81. package/dist/lib/status.d.ts +44 -9
  82. package/dist/lib/status.js +149 -27
  83. package/dist/lib/sync.d.ts +0 -5
  84. package/dist/lib/sync.js +5 -4
  85. package/dist/lib/telegram-api.d.ts +13 -3
  86. package/dist/lib/telegram-api.js +38 -23
  87. package/dist/lib/thread-cleanup-manager.d.ts +1 -0
  88. package/dist/lib/thread-cleanup-manager.js +18 -21
  89. package/dist/lib/thread-naming.d.ts +32 -5
  90. package/dist/lib/thread-naming.js +191 -7
  91. package/dist/lib/thread-reconciler.d.ts +2 -0
  92. package/dist/lib/thread-reconciler.js +1 -1
  93. package/dist/lib/threads.d.ts +293 -59
  94. package/dist/lib/threads.js +1689 -369
  95. package/dist/lib/turns.d.ts +0 -1
  96. package/dist/lib/turns.js +1 -1
  97. package/dist/lib/updates.d.ts +191 -28
  98. package/dist/lib/updates.js +1048 -114
  99. package/dist/lib/wire.d.ts +12 -0
  100. package/dist/lib/wire.js +21 -0
  101. package/dist/lib/workspace-admission.d.ts +25 -17
  102. package/dist/lib/workspace-admission.js +95 -20
  103. package/dist/lib/workspace-identity.d.ts +20 -0
  104. package/dist/lib/workspace-identity.js +103 -0
  105. package/dist/lib/workspace-retirement.d.ts +43 -8
  106. package/dist/lib/workspace-retirement.js +148 -52
  107. package/dist/package.json +6 -6
  108. package/dist/skills/generated-control-surface/SKILL.md +3 -3
  109. package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  110. package/docs/README.md +1 -1
  111. package/docs/activity.md +1 -1
  112. package/docs/architecture.md +156 -36
  113. package/docs/callback-namespaces.md +1 -1
  114. package/docs/delivery.md +1 -1
  115. package/docs/generative-apps.md +2 -2
  116. package/docs/inbound.md +1 -1
  117. package/docs/multi-instance-bus.md +125 -19
  118. package/docs/public-api.md +3 -1
  119. package/docs/ui-style.md +23 -9
  120. package/docs/updates.md +1 -1
  121. package/lib/activity-verbosity.ts +5 -9
  122. package/lib/bindings.ts +7 -0
  123. package/lib/bus-api.ts +9 -4
  124. package/lib/bus-follower.ts +243 -224
  125. package/lib/bus-leader.ts +178 -31
  126. package/lib/bus-transport.ts +30 -8
  127. package/lib/bus.ts +110 -334
  128. package/lib/channel-posts.ts +29 -29
  129. package/lib/command-templates.ts +5 -5
  130. package/lib/commands.ts +15 -15
  131. package/lib/config.ts +12 -17
  132. package/lib/delivery.ts +2 -8
  133. package/lib/extension.ts +261 -72
  134. package/lib/generative-apps.ts +0 -22
  135. package/lib/inbound.ts +2 -2
  136. package/lib/journal.ts +1250 -325
  137. package/lib/keyboard.ts +2 -2
  138. package/lib/lifecycle.ts +1 -1
  139. package/lib/locks.ts +402 -23
  140. package/lib/logging.ts +151 -101
  141. package/lib/media.ts +51 -9
  142. package/lib/menu-model.ts +8 -8
  143. package/lib/menu-settings.ts +18 -18
  144. package/lib/menu-status.ts +11 -0
  145. package/lib/menu-thinking.ts +2 -2
  146. package/lib/menu.ts +5 -1
  147. package/lib/outbound-attachments.ts +1 -1
  148. package/lib/outbound-buttons.ts +1 -10
  149. package/lib/outbound-markup.ts +5 -24
  150. package/lib/outbound.ts +5 -5
  151. package/lib/paths.ts +177 -17
  152. package/lib/pi.ts +5 -2
  153. package/lib/polling.ts +5 -5
  154. package/lib/preview.ts +3 -3
  155. package/lib/prompt-templates.ts +1 -1
  156. package/lib/queue.ts +93 -12
  157. package/lib/recovery.ts +104 -44
  158. package/lib/replies.ts +1 -1
  159. package/lib/routing.ts +2010 -375
  160. package/lib/sections.ts +1 -1
  161. package/lib/status.ts +156 -36
  162. package/lib/sync.ts +4 -4
  163. package/lib/telegram-api.ts +53 -33
  164. package/lib/thread-cleanup-manager.ts +24 -21
  165. package/lib/thread-naming.ts +267 -9
  166. package/lib/thread-reconciler.ts +3 -1
  167. package/lib/threads.ts +1736 -488
  168. package/lib/turns.ts +1 -1
  169. package/lib/updates.ts +1094 -151
  170. package/lib/wire.ts +28 -0
  171. package/lib/workspace-admission.ts +97 -47
  172. package/lib/workspace-identity.ts +147 -0
  173. package/lib/workspace-retirement.ts +188 -89
  174. package/package.json +6 -6
  175. package/scripts/audit-exports.mjs +100 -0
  176. package/scripts/check-downgrade.mjs +80 -43
  177. package/skills/generated-control-surface/SKILL.md +3 -3
  178. package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/lib/threads.ts CHANGED
@@ -1,36 +1,94 @@
1
1
  /**
2
2
  * Telegram thread binding helpers
3
- * Zones: multi-instance bus, Telegram UI threads, volatile extension state
4
- * Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
3
+ * Zones: multi-instance bus, Telegram UI threads, durable Workspace state
4
+ * Owns live mappings, Workspace bindings and guarded Restore transitions; routing and transport effects stay outside.
5
5
  */
6
6
 
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import {
9
9
  chmodSync,
10
+ closeSync,
11
+ constants,
10
12
  existsSync,
13
+ fstatSync,
14
+ lstatSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readFile as readFileCallback,
11
18
  readFileSync,
12
19
  renameSync,
20
+ unlinkSync,
13
21
  writeFileSync,
14
22
  } from "node:fs";
15
23
  import {
16
24
  chmod,
17
25
  mkdir,
18
- readFile,
19
- rename,
20
26
  unlink,
21
27
  writeFile,
22
28
  } from "node:fs/promises";
23
- import { dirname, posix, resolve } from "node:path";
24
- import { isDeepStrictEqual } from "node:util";
29
+ import { basename, dirname, join } from "node:path";
30
+ import { isDeepStrictEqual, promisify } from "node:util";
25
31
 
26
32
  import {
27
33
  isTelegramApiCommitUnknownError,
28
34
  TelegramApiCommitUnknownError,
29
35
  type TelegramApiCallOptions,
30
36
  } from "./telegram-api.ts";
31
- import type { TelegramTarget } from "./target.ts";
32
- import { withTelegramFileTransaction } from "./locks.ts";
37
+ import {
38
+ areTelegramTargetsEqual as targetMatches,
39
+ getTelegramTargetKey as getTargetRecoveryHintKey,
40
+ type TelegramTarget,
41
+ } from "./target.ts";
42
+ import { isWireRecord as restoreObject, isNonEmptyWireString as restoreText } from "./wire.ts";
43
+ import {
44
+ chooseTelegramThreadName,
45
+ getTelegramManualThreadDisplayNameValidationError,
46
+ getTelegramThreadNameLeadingSlot,
47
+ getTelegramTopicIdentityName,
48
+ getTelegramTopicName as buildTelegramTopicName,
49
+ getTelegramTopicTitleForThreadName,
50
+ isTelegramTopicThreadNameValidForSlot,
51
+ normalizeTelegramTopicTargetThreadName,
52
+ } from "./thread-naming.ts";
53
+ export {
54
+ chooseTelegramThreadName,
55
+ createTelegramThreadName,
56
+ getTelegramManualThreadDisplayNameValidationError,
57
+ getTelegramTopicIdentityName,
58
+ getTelegramTopicThreadNameValidationError,
59
+ getTelegramTopicTitleForThreadName,
60
+ isTelegramTopicThreadNameValidForSlot,
61
+ type TelegramThreadNameInput,
62
+ } from "./thread-naming.ts";
63
+
64
+ // Preserve the legacy provision-request signature without a runtime wrapper.
65
+ export const getTelegramTopicName: (
66
+ request: TelegramTopicTargetProvisionRequest,
67
+ template?: string,
68
+ slot?: string,
69
+ ) => string = buildTelegramTopicName;
70
+ import {
71
+ TELEGRAM_WORKSPACE_KEY_MAX_LENGTH,
72
+ createTelegramSessionKey,
73
+ createTelegramWorkspaceBindingIdentityWithKey,
74
+ createTelegramWorkspaceDirectoryKey,
75
+ normalizeTelegramSessionId,
76
+ normalizeTelegramWorkspacePath,
77
+ type TelegramWorkspaceBindingIdentity,
78
+ } from "./workspace-identity.ts";
79
+ export {
80
+ createTelegramWorkspaceBindingIdentity,
81
+ createTelegramWorkspaceDirectoryKey,
82
+ normalizeTelegramSessionId,
83
+ normalizeTelegramWorkspacePath,
84
+ type TelegramWorkspaceBindingIdentity,
85
+ } from "./workspace-identity.ts";
86
+ import { withTelegramFileTransaction, readTelegramRuntimeState,
87
+ type TelegramLockRuntime, type TelegramLockContext, type TelegramRuntimeStateMutation,
88
+ type TelegramOwnedStatePublicationOptions, type TelegramOwnedStatePublicationResult,
89
+ } from "./locks.ts";
33
90
  import * as ThreadReconciler from "./thread-reconciler.ts";
91
+ import { createTelegramRuntimeProjectionStore } from "./status.ts";
34
92
  import {
35
93
  planTelegramWorkspaceSlotAllocation,
36
94
  TELEGRAM_WORKSPACE_SLOTS,
@@ -42,153 +100,6 @@ import {
42
100
  resolveTelegramProfileTempFilePath,
43
101
  } from "./paths.ts";
44
102
 
45
- export interface TelegramThreadNameInput {
46
- seed: string;
47
- cwd?: string;
48
- role?: "leader" | "follower";
49
- peers?: readonly string[];
50
- slot?: string;
51
- }
52
-
53
- export interface TelegramWorkspaceBindingIdentity {
54
- cwd: string;
55
- workspaceKey: string;
56
- /** Exact durable Pi session identity; absent only on legacy cwd-only bindings. */
57
- sessionId?: string;
58
- /** Full SHA-256 index component for session-qualified bindings. */
59
- sessionKey?: string;
60
- /** Immutable legacy binding-key component, not the displayed global letter. */
61
- instanceSlot: string;
62
- bindingKey: string;
63
- /** Profile-wide letter reserved by the transient claim. */
64
- slot?: string;
65
- }
66
-
67
- const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
68
- const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
69
-
70
- export function normalizeTelegramSessionId(
71
- sessionId: string,
72
- ): string | undefined {
73
- if (typeof sessionId !== "string") return undefined;
74
- const normalized = sessionId.trim();
75
- return normalized && Buffer.byteLength(normalized, "utf8") <=
76
- TELEGRAM_SESSION_ID_MAX_LENGTH
77
- ? normalized
78
- : undefined;
79
- }
80
-
81
- export function createTelegramSessionKey(
82
- sessionId: string,
83
- ): string | undefined {
84
- const normalized = normalizeTelegramSessionId(sessionId);
85
- return normalized
86
- ? createHash("sha256").update(normalized).digest("hex")
87
- : undefined;
88
- }
89
-
90
- export function normalizeTelegramWorkspacePath(
91
- cwd: string,
92
- ): string | undefined {
93
- const trimmed = cwd.trim();
94
- if (!trimmed) return undefined;
95
- const normalized = (trimmed.startsWith("/")
96
- ? posix.normalize(trimmed)
97
- : resolve(trimmed).replaceAll("\\", "/"));
98
- const withoutTrailingSeparators =
99
- normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
100
- return process.platform === "win32"
101
- ? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive: string) =>
102
- `${drive.toLowerCase()}:`,
103
- )
104
- : withoutTrailingSeparators;
105
- }
106
-
107
- export function createTelegramWorkspaceDirectoryKey(
108
- cwd: string,
109
- ): string | undefined {
110
- const normalized = normalizeTelegramWorkspacePath(cwd);
111
- if (!normalized) return undefined;
112
- const readable =
113
- normalized
114
- .replace(/[^\p{L}\p{N}._-]+/gu, "-")
115
- .replace(/^-+|-+$/gu, "") || "root";
116
- const candidate = `--${readable}--`;
117
- if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH) return candidate;
118
- const digest = createHash("sha256")
119
- .update(normalized)
120
- .digest("hex")
121
- .slice(0, 12);
122
- const prefixLength =
123
- TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
124
- return `--${readable.slice(0, prefixLength)}-${digest}--`;
125
- }
126
-
127
- function createTelegramWorkspaceInstanceSlot(
128
- ordinal: number,
129
- ): string | undefined {
130
- if (!Number.isSafeInteger(ordinal) || ordinal < 0) return undefined;
131
- let value = ordinal + 1;
132
- let slot = "";
133
- while (value > 0) {
134
- value -= 1;
135
- slot = String.fromCharCode(97 + (value % 26)) + slot;
136
- value = Math.floor(value / 26);
137
- }
138
- return slot;
139
- }
140
-
141
- function createTelegramWorkspaceBindingIdentityWithKey(
142
- cwd: string,
143
- workspaceKey: string,
144
- ordinal: number,
145
- sessionId?: string,
146
- ): TelegramWorkspaceBindingIdentity | undefined {
147
- const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
148
- if (!instanceSlot) return undefined;
149
- const normalizedSessionId = sessionId === undefined
150
- ? undefined
151
- : normalizeTelegramSessionId(sessionId);
152
- const sessionKey = normalizedSessionId
153
- ? createTelegramSessionKey(normalizedSessionId)
154
- : undefined;
155
- if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
156
- return undefined;
157
- }
158
- const legacyBindingKey = instanceSlot === "a"
159
- ? workspaceKey
160
- : `${workspaceKey}${instanceSlot}`;
161
- return {
162
- cwd,
163
- workspaceKey,
164
- ...(normalizedSessionId && sessionKey
165
- ? { sessionId: normalizedSessionId, sessionKey }
166
- : {}),
167
- instanceSlot,
168
- bindingKey: sessionKey
169
- ? `${legacyBindingKey}-s-${sessionKey}`
170
- : legacyBindingKey,
171
- };
172
- }
173
-
174
- export function createTelegramWorkspaceBindingIdentity(
175
- cwd: string,
176
- ordinal = 0,
177
- sessionId?: string,
178
- ): TelegramWorkspaceBindingIdentity | undefined {
179
- const normalized = normalizeTelegramWorkspacePath(cwd);
180
- const workspaceKey = normalized
181
- ? createTelegramWorkspaceDirectoryKey(normalized)
182
- : undefined;
183
- if (!normalized || !workspaceKey) return undefined;
184
- return createTelegramWorkspaceBindingIdentityWithKey(
185
- normalized,
186
- workspaceKey,
187
- ordinal,
188
- sessionId,
189
- );
190
- }
191
-
192
103
  export type TelegramTopicTargetStatus =
193
104
  | "active"
194
105
  | "offline"
@@ -243,7 +154,7 @@ export interface TelegramThreadPendingProvision {
243
154
  export type TelegramThreadCleanupIntent =
244
155
  ThreadReconciler.TelegramThreadCleanupIntent;
245
156
 
246
- type TelegramProvisionRecoveryFile = Record<
157
+ export type TelegramProvisionRecoveryFile = Record<
247
158
  string,
248
159
  {
249
160
  instanceId: string;
@@ -292,6 +203,11 @@ export interface TelegramThreadIdentityRecord {
292
203
  updatedAtMs: number;
293
204
  }
294
205
 
206
+ export interface TelegramWorkspaceJournalSource {
207
+ sessionId: string;
208
+ recipientBindingKey: string;
209
+ }
210
+
295
211
  export interface TelegramWorkspaceThreadBinding {
296
212
  cwd: string;
297
213
  workspaceKey: string;
@@ -311,6 +227,8 @@ export interface TelegramWorkspaceThreadBinding {
311
227
  displayTitle?: string;
312
228
  /** Historical follower-journal routing keys that may retain accepted work. */
313
229
  journalBindingKeys?: string[];
230
+ /** Exact session-qualified journal addresses; retained across session re-key. */
231
+ journalSources?: TelegramWorkspaceJournalSource[];
314
232
  /** True only when the historical journal-key set is proven complete. */
315
233
  journalBindingsComplete?: true;
316
234
  /** Sticky once this directory has multiple retained bindings. */
@@ -360,6 +278,168 @@ export interface TelegramBotStateSnapshot {
360
278
  lastReconcileAction?: string;
361
279
  }
362
280
 
281
+ export interface TelegramWorkspaceRelocationRequest {
282
+ operationId: string;
283
+ binding: TelegramWorkspaceThreadBinding;
284
+ owner: TelegramTopicTargetRecord;
285
+ target: TelegramTarget & { threadId: number };
286
+ }
287
+
288
+ export interface TelegramWorkspaceRestoreRequest extends TelegramWorkspaceRelocationRequest {
289
+ source: { journalBindingKey: string; updateIds: number[] };
290
+ }
291
+ export interface TelegramWorkspaceRestoreExecutor { instanceId: string; leaderEpoch: string }
292
+ export interface TelegramWorkspaceRestoreAuthority {
293
+ executor: TelegramWorkspaceRestoreExecutor;
294
+ operatorUserId: number;
295
+ /** Exact profile, transport, session, source and Workspace-admission authority. */
296
+ isCurrent: () => boolean;
297
+ }
298
+ export type TelegramWorkspaceRestoreRecipient = {
299
+ kind: "leader" | "follower"; instanceId: string; sessionId: string; generation: string;
300
+ };
301
+ /** Retained queue admission is nonterminal; queue-completed requires positive journal-owner disposition. */
302
+ export type TelegramWorkspaceRestoreSourceSettlement = {
303
+ journalBindingKey: string; updateIds: number[];
304
+ } & ({ kind: "completed" } |
305
+ { kind: "queued" | "queue-completed"; receiptId: string; queueKind: "prompt" | "control" });
306
+ /** Positive execution/recipient acceptance, retained before source disposition; never a source-removal ACK. */
307
+ export type TelegramWorkspaceRestoreSourceAcceptance = {
308
+ journalBindingKey: string;
309
+ updateId: number;
310
+ /** SHA-256 of the exact journal entry captured by its worker owner, not a routed message projection. */
311
+ sourceSha256: string;
312
+ recipient: TelegramWorkspaceRestoreRecipient;
313
+ } & ({ kind: "completed" } |
314
+ { kind: "forwarded"; deliveryId: string; recipientBindingKey: string } |
315
+ { kind: "queued"; receiptId: string; queueKind: "prompt" | "control"; queueOwnerSha256: string });
316
+ export interface TelegramWorkspaceRestoreIntent {
317
+ request: TelegramWorkspaceRestoreRequest;
318
+ operatorUserId: number;
319
+ executor: TelegramWorkspaceRestoreExecutor;
320
+ revision: number;
321
+ createdAtMs: number;
322
+ updatedAtMs: number;
323
+ phase: "relocated" | "recipient-issued" | "ready";
324
+ committedAtMs: number;
325
+ recipient?: TelegramWorkspaceRestoreRecipient;
326
+ readyRecipient?: TelegramWorkspaceRestoreRecipient;
327
+ routing?: { acceptances?: TelegramWorkspaceRestoreSourceAcceptance[];
328
+ settlements: TelegramWorkspaceRestoreSourceSettlement[]; cleanup?: "issued" | "completed" | "not-issued" };
329
+ }
330
+ /** Immutable source membership, not readiness, cancellation, or proof of complete Thread coverage. */
331
+ export interface TelegramTemporaryThreadInput {
332
+ journalBindingKey: string;
333
+ updateIds: number[];
334
+ }
335
+ /** Journal-owned donor discard evidence: retained manual cancellation or body-free chooser expiry, never recipient cancellation. */
336
+ export interface TelegramTemporaryThreadCancellationEvidence {
337
+ journalBindingKey: string;
338
+ updateId: number;
339
+ operatorAuthorityId: string;
340
+ }
341
+ /** A bot-created routing tab with an exact creation source; it owns no Pi binding or slot. */
342
+ export interface TelegramTemporaryThreadEntry {
343
+ source: { journalBindingKey: string; updateId: number };
344
+ /** Append-only known source groups. Missing legacy metadata never proves that the creation source was alone. */
345
+ inputs?: TelegramTemporaryThreadInput[];
346
+ /** Whole known groups with positively observed donor cancellation or expiry; never recipient cancellation or deletion authority. */
347
+ cancelledInputs?: TelegramTemporaryThreadInput[];
348
+ /** Whole known groups whose Forward was positively completed by the journal owner; never deletion authority alone. */
349
+ completedInputs?: TelegramTemporaryThreadInput[];
350
+ /** Whole known groups whose Forward was durably issued before local handling/queueing or follower RPC; never delivery proof or a retry grant. */
351
+ forwardedInputs?: TelegramTemporaryThreadInput[];
352
+ /** Published at creation only: every subsequent Forward path must record issuance; missing legacy coverage stays unknown. */
353
+ forwardProtocol?: "one-shot-v1";
354
+ /** Published before the sole cleanup attempt; uncertainty survives executor adoption and restart. */
355
+ cleanupIssued?: true;
356
+ operatorUserId: number;
357
+ executor: TelegramWorkspaceRestoreExecutor;
358
+ /** Unique title token; the only identity an unacknowledged creation may later be matched by. */
359
+ token: string;
360
+ /** `creating` is published before the one creation request, so after a crash its outcome is unknown. */
361
+ phase: "creating" | "created";
362
+ target?: TelegramTarget & { threadId: number };
363
+ revision: number;
364
+ createdAtMs: number;
365
+ updatedAtMs: number;
366
+ }
367
+ /** Canonical target classification only; a bound target still needs live same-session readiness proof. */
368
+ export type TelegramTemporaryThreadTargetObservation =
369
+ | { kind: "bound"; binding: TelegramWorkspaceThreadBinding }
370
+ | { kind: "temporary" }
371
+ | { kind: "unknown" };
372
+ interface TelegramWorkspaceRestoreSnapshot {
373
+ version: 1; profileName: string; tokenSha256: string; revision: number;
374
+ operations: TelegramWorkspaceRestoreIntent[];
375
+ temporaryThreads?: TelegramTemporaryThreadEntry[];
376
+ }
377
+ /** Scope-bound Workspace operations; raw snapshot mutation is private to the store. */
378
+ export interface TelegramWorkspaceRestore {
379
+ list(): TelegramWorkspaceRestoreIntent[];
380
+ commit(request: TelegramWorkspaceRestoreRequest, authority: TelegramWorkspaceRestoreAuthority): Promise<TelegramWorkspaceRestoreIntent | undefined>;
381
+ adopt(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
382
+ issueRecipient(expected: TelegramWorkspaceRestoreIntent, recipient: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
383
+ confirmReady(expected: TelegramWorkspaceRestoreIntent, acknowledged: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
384
+ confirmInspectedReady(expected: TelegramWorkspaceRestoreIntent, observed: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
385
+ issueRouting(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
386
+ recordSourceAcceptance(expected: TelegramWorkspaceRestoreIntent, evidence: TelegramWorkspaceRestoreSourceAcceptance, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
387
+ recordSourceSettlement(expected: TelegramWorkspaceRestoreIntent, evidence: TelegramWorkspaceRestoreSourceSettlement, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
388
+ issueCleanup(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): { issued: true; intent: TelegramWorkspaceRestoreIntent } | undefined;
389
+ recordCleanup(expected: TelegramWorkspaceRestoreIntent, result: { target: TelegramWorkspaceRestoreRequest["target"]; kind: "completed" | "not-issued" }, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
390
+ retire(expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
391
+ /** Caller proves exact operator abandonment of every original; no dispatch grant may exist. */
392
+ retireAbandoned(expected: TelegramWorkspaceRestoreIntent, abandonedUpdateIds: readonly number[], authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceRestoreIntent | undefined;
393
+ listTemporaryThreads(): TelegramTemporaryThreadEntry[];
394
+ /** Caller holds admission for effectful use. Fresh exact read grants no adoption, routing, disposition or deletion. */
395
+ inspectTemporaryThreadTarget(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadTargetObservation | undefined;
396
+ /** Read-only veto for an already-granted in-flight attempt; never another grant, disposable classification or deletion ACK. */
397
+ isTemporaryThreadCleanupCurrent(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): boolean;
398
+ /** Publishes `creating` before the caller's single creation request; an existing source entry is returned, never recreated. */
399
+ reserveTemporaryThread(source: TelegramTemporaryThreadEntry["source"], token: string, authority: TelegramWorkspaceRestoreAuthority): { reserved: boolean; entry: TelegramTemporaryThreadEntry } | undefined;
400
+ /** Caller proves a fresh owner-authenticated implicit Telegram creation and exact live source under profile admission.
401
+ * Registers that observed unbound target without issuing or fabricating a Bot API creation. */
402
+ registerImplicitTemporaryThread(input: TelegramTemporaryThreadInput, target: TelegramTarget & { threadId: number }, token: string,
403
+ authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
404
+ /** Records the exact acknowledged creation target; it never reopens a created entry. */
405
+ acknowledgeTemporaryThread(expected: TelegramTemporaryThreadEntry, target: TelegramTarget & { threadId: number }, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
406
+ adoptTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
407
+ /** Caller supplies exact live source/group authority; duplicate membership is read-only, never a new dispatch grant. */
408
+ recordTemporaryThreadInput(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
409
+ /** Records a positively completed Forward group; the caller proves every source of the group completed. */
410
+ /** Publishes the one-time Forward issuance fact before any RPC; a duplicate, cancelled, completed or Restore-owned group is refused. */
411
+ recordTemporaryThreadForwardIssued(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
412
+ authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
413
+ recordTemporaryThreadInputCompletion(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
414
+ authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
415
+ recordTemporaryThreadInputCancellation(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
416
+ authority: TelegramWorkspaceRestoreAuthority,
417
+ inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
418
+ /** Body-free chooser expiry may terminate an uncertain donor Forward/Restore, never accepted recipient work or a bound target. */
419
+ recordTemporaryThreadInputExpiry(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
420
+ authority: TelegramWorkspaceRestoreAuthority,
421
+ inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
422
+ /** Caller holds profile admission and proves fresh source/protection clearance; publication grants one attempt, never retry. */
423
+ issueTemporaryThreadCleanup(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority):
424
+ { issued: true; entry: TelegramTemporaryThreadEntry } | undefined;
425
+ /** Caller proves source settlement or cancellation and target disposition; this releases protection only. */
426
+ /** `completed` names one newly completed group; every other known group must already be cancelled or completed. */
427
+ retireTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority,
428
+ completed?: TelegramTemporaryThreadInput): TelegramTemporaryThreadEntry | undefined;
429
+ /** New-world restart: atomically forgets this operator's Restore intents and temporary entries from previous runtime instances.
430
+ * Caller may preserve exact unbound temporary tokens for clock-bearing sources. Committed bindings stay; nothing is rolled back, replayed or deleted here. */
431
+ forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority, preserveTemporaryTokens?: readonly string[]):
432
+ { operations: TelegramWorkspaceRestoreIntent[]; temporaryThreads: TelegramTemporaryThreadEntry[] } | undefined;
433
+ }
434
+ export interface TelegramWorkspaceRestoreOptions {
435
+ profileName: string; tokenSha256: string;
436
+ isCurrentScope?: () => boolean;
437
+ maxBytes?: number;
438
+ getNowMs?: () => number;
439
+ legacyPath?: string;
440
+ onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
441
+ }
442
+
363
443
  export interface TelegramSessionReplacementIntent {
364
444
  continuity: "workspace-thread" | "classic-chat";
365
445
  cwd: string;
@@ -385,13 +465,11 @@ export interface TelegramTopicTargetFile {
385
465
  source: "snapshot";
386
466
  writtenAtMs: number;
387
467
  bot: TelegramBotStateSnapshot;
388
- runtime?: Record<string, unknown>;
389
- liveRoster?: Record<string, unknown>;
390
- diagnostics?: Record<string, unknown>;
391
468
  threads: TelegramTopicTargetRecord[];
392
469
  identities?: TelegramThreadIdentityRecord[];
393
470
  workspaceBindings?: TelegramWorkspaceThreadBinding[];
394
471
  workspaceRetirements?: TelegramWorkspaceRetirementIntent[];
472
+ workspaceRestore?: TelegramWorkspaceRestoreSnapshot;
395
473
  sessionReplacement?: TelegramSessionReplacementIntent;
396
474
  reservations?: TelegramThreadReservation[];
397
475
  pendingProvisions?: TelegramThreadPendingProvision[];
@@ -471,6 +549,17 @@ export interface TelegramTopicTargetStore {
471
549
  expected: TelegramTopicTargetRecord,
472
550
  isCurrent: () => boolean,
473
551
  ) => Promise<boolean>;
552
+ /** Caller holds Workspace admission; publication uses this store's exact transport-owner fence. */
553
+ workspaceRestore: (options: TelegramWorkspaceRestoreOptions) => TelegramWorkspaceRestore;
554
+ /** Read-only precondition under caller-owned admission; not registration or transport authority. */
555
+ assertWorkspaceRestoreRegistration: (candidate: { target: TelegramTarget; bindingKey?: string; slot?: string }) => void;
556
+ /** Runs synchronous live publication under the same evidence transaction; caller retains authentication/admission. */
557
+ commitWorkspaceRestoreRegistration: (candidate: Parameters<TelegramTopicTargetStore["assertWorkspaceRestoreRegistration"]>[0], publish: () => void) => void;
558
+ /** Read-only canonical observation; the callback must finish synchronously under the snapshot transaction. */
559
+ withWorkspaceRestoreSnapshot: (expected: TelegramWorkspaceRestoreIntent,
560
+ observe: (snapshot: Readonly<Pick<TelegramTopicTargetFile, "threads" | "workspaceBindings">>) => undefined) => void;
561
+ /** Fresh strict disk read of acknowledged temporary-tab targets; throws rather than guessing absence. */
562
+ listTemporaryThreadTargets: () => TelegramTarget[];
474
563
  list: () => TelegramTopicTargetRecord[];
475
564
  getFollowerRecoveryHintByTarget?: (
476
565
  target: TelegramTarget,
@@ -495,6 +584,8 @@ export interface TelegramTopicTargetStore {
495
584
  liveRoster?: Record<string, unknown>;
496
585
  diagnostics?: Record<string, unknown>;
497
586
  }) => void;
587
+ /** Writes the non-canonical status projection to its own file; it never touches canonical state or grants authority. */
588
+ persistStatus: () => Promise<void>;
498
589
  getByProfileKey: (
499
590
  profileKey: string,
500
591
  ) => TelegramTopicTargetRecord | undefined;
@@ -520,11 +611,18 @@ export interface TelegramTopicTargetStore {
520
611
  isCurrent: () => boolean,
521
612
  ) => Promise<boolean>;
522
613
  listWorkspaceRetirementIntents: () => TelegramWorkspaceRetirementIntent[];
614
+ /** Metadata subset CAS only: callers prove exact-source/writer evidence; independent snapshot roots are untouched. */
523
615
  commitWorkspaceJournalEvidence: (
524
616
  expected: TelegramWorkspaceThreadBinding,
525
617
  journalBindingKeys: readonly string[],
526
618
  complete: boolean,
619
+ journalSources?: readonly TelegramWorkspaceJournalSource[],
527
620
  ) => TelegramWorkspaceThreadBinding | undefined;
621
+ /** Acknowledge publication of the exact current metadata frame; never roll it back after an uncertain write. */
622
+ persistWorkspaceJournalEvidence: (
623
+ expected: TelegramWorkspaceThreadBinding,
624
+ isCurrent: () => boolean,
625
+ ) => Promise<boolean>;
528
626
  upsertWorkspaceRetirementIntent: (
529
627
  intent: TelegramWorkspaceRetirementIntent,
530
628
  ) => boolean;
@@ -646,7 +744,7 @@ export function reconcileTelegramFreshAllocationCursor(
646
744
  }
647
745
 
648
746
  export function createTelegramCleanupTargetProtection(
649
- store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups">>,
747
+ store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "listTemporaryThreadTargets">>,
650
748
  departingRecord?: TelegramTopicTargetRecord,
651
749
  ): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]> {
652
750
  const records = store.list();
@@ -658,6 +756,11 @@ export function createTelegramCleanupTargetProtection(
658
756
  const sameSnapshot = (left: unknown, right: unknown): boolean =>
659
757
  isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
660
758
  return (target, action) => {
759
+ // A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
760
+ if (store.listTemporaryThreadTargets) {
761
+ try { if (store.listTemporaryThreadTargets().some(candidate => targetMatches(candidate, target))) return true; }
762
+ catch { return true; }
763
+ }
661
764
  for (const record of store.list()) {
662
765
  if (!targetMatches(record.target, target)) continue;
663
766
  // A persisted shutdown intent may retire only its original pre-intent
@@ -698,6 +801,7 @@ export interface TelegramTopicTargetStoreOptions {
698
801
  getNowMs?: () => number;
699
802
  canPersist?: () => boolean;
700
803
  commitPersist?: (commit: () => void) => boolean;
804
+ consolidated?: Pick<TelegramConsolidatedWorkspaceStorageOptions, "captureAuthority" | "publishIfOwned">;
701
805
  getExternalReservedSlots?: () => readonly string[];
702
806
  }
703
807
 
@@ -832,51 +936,6 @@ interface TelegramTopicResult {
832
936
  message_thread_id?: number;
833
937
  }
834
938
 
835
- function hashString(value: string): number {
836
- let hash = 2166136261;
837
- for (let index = 0; index < value.length; index += 1) {
838
- hash ^= value.charCodeAt(index);
839
- hash = Math.imul(hash, 16777619);
840
- }
841
- return hash >>> 0;
842
- }
843
-
844
- function getWorkspaceHint(cwd: string | undefined): string | undefined {
845
- if (!cwd) return undefined;
846
- const parts = cwd.split("/").filter(Boolean);
847
- const last = parts.at(-1)?.trim();
848
- if (!last) return undefined;
849
- return (
850
- last
851
- .replace(/[^\p{L}\p{N}._-]+/gu, " ")
852
- .trim()
853
- .slice(0, 32) || undefined
854
- );
855
- }
856
-
857
- export function createTelegramThreadName(
858
- input: TelegramThreadNameInput,
859
- ): string {
860
- const workspace = getWorkspaceHint(input.cwd);
861
- const roleMark =
862
- input.role === "leader"
863
- ? "Leader"
864
- : input.role === "follower"
865
- ? "Follower"
866
- : undefined;
867
- const slot = input.slot ? `Thread ${input.slot}` : undefined;
868
- const peerSalt = input.peers?.slice().sort().join("|") ?? "";
869
- const fallback = `Instance ${hashString(
870
- `${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`,
871
- )
872
- .toString(36)
873
- .slice(0, 4)}`;
874
- return (
875
- [slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
876
- fallback
877
- );
878
- }
879
-
880
939
  export function getTelegramStatePath(
881
940
  agentDir = resolveAgentDir(),
882
941
  profileName?: string,
@@ -937,7 +996,7 @@ export function setTelegramLeaderSessionHandoff(
937
996
  else store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
938
997
  }
939
998
 
940
- export function isTelegramLeaderSessionHandoffFresh(
999
+ function isTelegramLeaderSessionHandoffFresh(
941
1000
  handoff: TelegramLeaderSessionHandoff | undefined,
942
1001
  options: { pid?: number; nowMs?: number; ttlMs?: number } = {},
943
1002
  ): handoff is TelegramLeaderSessionHandoff {
@@ -1121,6 +1180,7 @@ function normalizeRecord(
1121
1180
  typeof record.updatedAtMs !== "number"
1122
1181
  )
1123
1182
  return undefined;
1183
+ const threadName = getPersistedThreadName(record);
1124
1184
  const normalized: TelegramTopicTargetRecord = {
1125
1185
  profileKey: getTelegramThreadOwnerKey(owner),
1126
1186
  owner,
@@ -1128,7 +1188,7 @@ function normalizeRecord(
1128
1188
  status,
1129
1189
  createdAtMs: record.createdAtMs,
1130
1190
  updatedAtMs: record.updatedAtMs,
1131
- threadName: getPersistedThreadName(record),
1191
+ ...(threadName !== undefined ? { threadName } : {}),
1132
1192
  ...(typeof record.manualThreadName === "string" &&
1133
1193
  normalizeTelegramTopicTargetThreadName(record.manualThreadName)
1134
1194
  ? { manualThreadName:
@@ -1224,6 +1284,21 @@ function getWorkspaceBindingMapKey(
1224
1284
  return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
1225
1285
  }
1226
1286
 
1287
+ function normalizeWorkspaceJournalSources(value: unknown): TelegramWorkspaceJournalSource[] | undefined {
1288
+ if (!Array.isArray(value) || value.length > 256) return undefined;
1289
+ const sources = new Map<string, TelegramWorkspaceJournalSource>();
1290
+ for (const entry of value) {
1291
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
1292
+ const source = entry as Record<string, unknown>;
1293
+ if (Object.keys(source).some(key => key !== "sessionId" && key !== "recipientBindingKey") ||
1294
+ typeof source.sessionId !== "string" || normalizeTelegramSessionId(source.sessionId) !== source.sessionId ||
1295
+ typeof source.recipientBindingKey !== "string" || !source.recipientBindingKey || source.recipientBindingKey.length > 512) return undefined;
1296
+ const normalized = { sessionId: source.sessionId, recipientBindingKey: source.recipientBindingKey };
1297
+ sources.set(JSON.stringify(normalized), normalized);
1298
+ }
1299
+ return Array.from(sources.values());
1300
+ }
1301
+
1227
1302
  function normalizeWorkspaceBindingRecord(
1228
1303
  value: unknown,
1229
1304
  ): TelegramWorkspaceThreadBinding | undefined {
@@ -1298,6 +1373,8 @@ function normalizeWorkspaceBindingRecord(
1298
1373
  )
1299
1374
  ? Array.from(new Set(record.journalBindingKeys as string[]))
1300
1375
  : undefined;
1376
+ const journalSources = record.journalSources === undefined ? undefined : normalizeWorkspaceJournalSources(record.journalSources);
1377
+ if (record.journalSources !== undefined && !journalSources) return undefined;
1301
1378
  return {
1302
1379
  cwd,
1303
1380
  workspaceKey: record.workspaceKey,
@@ -1313,7 +1390,8 @@ function normalizeWorkspaceBindingRecord(
1313
1390
  ? { inactiveSinceMs: record.inactiveSinceMs }
1314
1391
  : {}),
1315
1392
  ...(journalBindingKeys ? { journalBindingKeys } : {}),
1316
- ...(record.journalBindingsComplete === true && journalBindingKeys
1393
+ ...(journalSources ? { journalSources } : {}),
1394
+ ...(record.journalBindingsComplete === true && (journalBindingKeys || journalSources)
1317
1395
  ? { journalBindingsComplete: true as const }
1318
1396
  : {}),
1319
1397
  target: { chatId: target.chatId, threadId: target.threadId },
@@ -1327,7 +1405,9 @@ function normalizeWorkspaceBindingRecord(
1327
1405
  function cloneWorkspaceBinding(
1328
1406
  binding: TelegramWorkspaceThreadBinding,
1329
1407
  ): TelegramWorkspaceThreadBinding {
1330
- return { ...binding, target: { ...binding.target } };
1408
+ return { ...binding, target: { ...binding.target },
1409
+ ...(binding.journalBindingKeys ? { journalBindingKeys: [...binding.journalBindingKeys] } : {}),
1410
+ ...(binding.journalSources ? { journalSources: binding.journalSources.map(source => ({ ...source })) } : {}) };
1331
1411
  }
1332
1412
 
1333
1413
  function cloneSessionReplacementIntent(
@@ -1644,6 +1724,325 @@ function normalizeReservation(
1644
1724
  };
1645
1725
  }
1646
1726
 
1727
+ function normalizeTelegramWorkspaceRelocationRequest(value: unknown): TelegramWorkspaceRelocationRequest | undefined {
1728
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1729
+ const request = value as TelegramWorkspaceRelocationRequest;
1730
+ const binding = normalizeWorkspaceBindingRecord(request.binding);
1731
+ const owner = normalizeRecord(request.owner);
1732
+ const target = request.target;
1733
+ if (typeof request.operationId !== "string" || !request.operationId ||
1734
+ !binding?.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) || binding.inactiveSinceMs !== undefined ||
1735
+ !owner || owner.status !== "active" || !owner.instanceId || owner.slot !== binding.slot ||
1736
+ !["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
1737
+ (owner.owner?.kind === "leader" &&
1738
+ (owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
1739
+ !targetMatches(binding.target, owner.target) ||
1740
+ !Number.isSafeInteger(binding.target.chatId) || binding.target.chatId === 0 ||
1741
+ !Number.isSafeInteger(binding.target.threadId) || binding.target.threadId <= 0 ||
1742
+ !target || target.chatId !== binding.target.chatId ||
1743
+ !Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
1744
+ targetMatches(target, binding.target)) return undefined;
1745
+ const normalized = { operationId: request.operationId, binding, owner,
1746
+ target: { chatId: target.chatId, threadId: target.threadId } };
1747
+ return isDeepStrictEqual(value, normalized) ? normalized : undefined;
1748
+ }
1749
+
1750
+ const restoreInteger = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1751
+ const restoreKeys = (value: Record<string, unknown>, allowed: string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
1752
+ const isTelegramWorkspaceRestoreExecutor = (value: unknown): value is TelegramWorkspaceRestoreExecutor => restoreObject(value) &&
1753
+ restoreKeys(value, ["instanceId", "leaderEpoch"]) && restoreText(value.instanceId) && restoreText(value.leaderEpoch);
1754
+ export const isTelegramWorkspaceRestoreRecipient = (value: unknown): value is TelegramWorkspaceRestoreRecipient => restoreObject(value) &&
1755
+ restoreKeys(value, ["kind", "instanceId", "sessionId", "generation"]) &&
1756
+ (value.kind === "leader" || value.kind === "follower") && restoreText(value.instanceId) && restoreText(value.sessionId) && restoreText(value.generation);
1757
+ export function isTelegramWorkspaceRestoreRequest(value: unknown): value is TelegramWorkspaceRestoreRequest {
1758
+ if (!restoreObject(value)) return false;
1759
+ const { source, ...relocation } = value;
1760
+ return !!normalizeTelegramWorkspaceRelocationRequest(relocation) && restoreObject(source) &&
1761
+ restoreKeys(source, ["journalBindingKey", "updateIds"]) && restoreText(source.journalBindingKey) &&
1762
+ Array.isArray(source.updateIds) && source.updateIds.length > 0 &&
1763
+ source.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
1764
+ }
1765
+ function isTelegramWorkspaceRestoreSettlement(value: unknown, source: TelegramWorkspaceRestoreRequest["source"]): value is TelegramWorkspaceRestoreSourceSettlement {
1766
+ if (!restoreObject(value) || value.journalBindingKey !== source.journalBindingKey || !Array.isArray(value.updateIds) ||
1767
+ value.updateIds.length === 0 || !value.updateIds.every((id, index, ids) => restoreInteger(id) && source.updateIds.includes(id) &&
1768
+ (index === 0 || id > ids[index - 1]))) return false;
1769
+ return value.kind === "completed" ? restoreKeys(value, ["journalBindingKey", "updateIds", "kind"]) :
1770
+ (value.kind === "queued" || value.kind === "queue-completed") && restoreKeys(value, ["journalBindingKey", "updateIds", "kind", "receiptId", "queueKind"]) &&
1771
+ restoreText(value.receiptId) && (value.queueKind === "prompt" || value.queueKind === "control");
1772
+ }
1773
+ function isWorkspaceRestoreSourceAcceptance(value: unknown, operation: TelegramWorkspaceRestoreIntent): value is TelegramWorkspaceRestoreSourceAcceptance {
1774
+ if (!restoreObject(value) || value.journalBindingKey !== operation.request.source.journalBindingKey || !restoreInteger(value.updateId) ||
1775
+ !operation.request.source.updateIds.includes(value.updateId) || typeof value.sourceSha256 !== "string" ||
1776
+ !/^[a-f0-9]{64}$/u.test(value.sourceSha256) || !isTelegramWorkspaceRestoreRecipient(value.recipient) ||
1777
+ value.recipient.sessionId !== operation.request.binding.sessionId || value.recipient.kind !== operation.recipient?.kind) return false;
1778
+ const keys = ["journalBindingKey", "updateId", "sourceSha256", "recipient", "kind"];
1779
+ if (value.kind === "completed") return value.recipient.kind === "leader" && restoreKeys(value, keys);
1780
+ if (value.kind === "forwarded") return value.recipient.kind === "follower" &&
1781
+ restoreKeys(value, [...keys, "deliveryId", "recipientBindingKey"]) && typeof value.deliveryId === "string" &&
1782
+ /^telegram-follower-v1-[a-f0-9]{64}$/u.test(value.deliveryId) && restoreText(value.recipientBindingKey);
1783
+ return value.kind === "queued" && value.recipient.kind === "leader" &&
1784
+ restoreKeys(value, [...keys, "receiptId", "queueKind", "queueOwnerSha256"]) && restoreText(value.receiptId) &&
1785
+ (value.queueKind === "prompt" || value.queueKind === "control") && typeof value.queueOwnerSha256 === "string" &&
1786
+ /^[a-f0-9]{64}$/u.test(value.queueOwnerSha256);
1787
+ }
1788
+ /** Stable acceptance scope, not a removal ACK; adoption and mutable progress cannot change it. */
1789
+ export function getTelegramWorkspaceRestoreSourceCompletionSha256(operation: TelegramWorkspaceRestoreIntent,
1790
+ acceptance: TelegramWorkspaceRestoreSourceAcceptance): string {
1791
+ if (!isTelegramWorkspaceRestoreRequest(operation.request) || operation.operatorUserId !== operation.request.target.chatId ||
1792
+ !isWorkspaceRestoreSourceAcceptance(acceptance, operation) ||
1793
+ !operation.routing?.acceptances?.some(value => isDeepStrictEqual(value, acceptance))) {
1794
+ throw new Error("Workspace Restore retained acceptance scope is unavailable.");
1795
+ }
1796
+ const frame = { kind: "workspace-restore-source-completion-v1", request: operation.request,
1797
+ operatorUserId: operation.operatorUserId, acceptance };
1798
+ return createHash("sha256").update(JSON.stringify(frame, function (_key, value: unknown) {
1799
+ return restoreObject(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value;
1800
+ })).digest("hex");
1801
+ }
1802
+
1803
+ function workspaceRestoreAcceptancesConsistent(acceptances: readonly TelegramWorkspaceRestoreSourceAcceptance[]): boolean {
1804
+ return acceptances.every((value, index) => (index === 0 || value.updateId > acceptances[index - 1]!.updateId) &&
1805
+ acceptances.slice(0, index).every(previous => {
1806
+ if (value.kind === "forwarded" && previous.kind === "forwarded") return value.deliveryId !== previous.deliveryId;
1807
+ if (value.kind !== "queued" || previous.kind !== "queued" || value.receiptId !== previous.receiptId) return true;
1808
+ return value.queueKind === previous.queueKind && value.queueOwnerSha256 === previous.queueOwnerSha256 &&
1809
+ isDeepStrictEqual(value.recipient, previous.recipient);
1810
+ }));
1811
+ }
1812
+ function workspaceRestoreSettlementMatchesAcceptance(evidence: TelegramWorkspaceRestoreSourceSettlement,
1813
+ acceptances: readonly TelegramWorkspaceRestoreSourceAcceptance[]): boolean {
1814
+ return evidence.updateIds.every(id => {
1815
+ const accepted = acceptances.find(value => value.updateId === id);
1816
+ if (!accepted) return evidence.kind !== "queue-completed"; // Legacy admission is retained, never terminal receipt proof.
1817
+ return evidence.kind === "completed" ? accepted.kind !== "queued" : accepted.kind === "queued" &&
1818
+ evidence.receiptId === accepted.receiptId && evidence.queueKind === accepted.queueKind;
1819
+ });
1820
+ }
1821
+ function workspaceRestoreSourcesSettled(operation: TelegramWorkspaceRestoreIntent): boolean {
1822
+ const settled = new Set(operation.routing?.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
1823
+ return operation.request.source.updateIds.every(id => settled.has(id));
1824
+ }
1825
+ function isWorkspaceRestoreIntent(value: unknown): value is TelegramWorkspaceRestoreIntent {
1826
+ if (!restoreObject(value) || !restoreKeys(value, ["request", "operatorUserId", "executor", "revision", "createdAtMs",
1827
+ "updatedAtMs", "phase", "committedAtMs", "recipient", "readyRecipient", "routing"]) || !isTelegramWorkspaceRestoreRequest(value.request) ||
1828
+ !restoreInteger(value.operatorUserId) || value.operatorUserId !== value.request.target.chatId ||
1829
+ !isTelegramWorkspaceRestoreExecutor(value.executor) || !restoreInteger(value.revision) || !restoreInteger(value.createdAtMs) ||
1830
+ !restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs ||
1831
+ !restoreInteger(value.committedAtMs)) return false;
1832
+ const source = value.request.source;
1833
+ if (value.routing !== undefined) {
1834
+ if (value.phase !== "ready" || !restoreObject(value.routing) || !restoreKeys(value.routing, ["acceptances", "settlements", "cleanup"]) ||
1835
+ !Array.isArray(value.routing.settlements) || !value.routing.settlements.every(item => isTelegramWorkspaceRestoreSettlement(item, source))) return false;
1836
+ if (value.routing.acceptances !== undefined) {
1837
+ const acceptances = value.routing.acceptances;
1838
+ if (!Array.isArray(acceptances) || !acceptances.length ||
1839
+ !acceptances.every(item => isWorkspaceRestoreSourceAcceptance(item, value as unknown as TelegramWorkspaceRestoreIntent)) ||
1840
+ !workspaceRestoreAcceptancesConsistent(acceptances)) return false;
1841
+ }
1842
+ const acceptances = (value.routing.acceptances ?? []) as TelegramWorkspaceRestoreSourceAcceptance[];
1843
+ if (!value.routing.settlements.every(item => workspaceRestoreSettlementMatchesAcceptance(item, acceptances))) return false;
1844
+ const ids = value.routing.settlements.flatMap(item => item.updateIds);
1845
+ if (new Set(ids).size !== ids.length || (value.routing.cleanup !== undefined &&
1846
+ (!["issued", "completed", "not-issued"].includes(value.routing.cleanup as string) ||
1847
+ !workspaceRestoreSourcesSettled(value as unknown as TelegramWorkspaceRestoreIntent)))) return false;
1848
+ }
1849
+ if (value.readyRecipient !== undefined && (value.phase !== "ready" || !isTelegramWorkspaceRestoreRecipient(value.readyRecipient) ||
1850
+ value.readyRecipient.sessionId !== value.request.binding.sessionId)) return false;
1851
+ if (value.phase === "relocated") return value.recipient === undefined;
1852
+ return (value.phase === "recipient-issued" || value.phase === "ready") && isTelegramWorkspaceRestoreRecipient(value.recipient) &&
1853
+ value.recipient.sessionId === value.request.binding.sessionId;
1854
+ }
1855
+ function isTemporaryThreadInput(value: unknown): value is TelegramTemporaryThreadInput {
1856
+ return restoreObject(value) && restoreKeys(value, ["journalBindingKey", "updateIds"]) && restoreText(value.journalBindingKey) &&
1857
+ Array.isArray(value.updateIds) && value.updateIds.length > 0 && value.updateIds.length <= TEMPORARY_THREAD_INPUT_CAPACITY &&
1858
+ value.updateIds.every((id, index, ids) => restoreInteger(id) && (index === 0 || id > ids[index - 1]));
1859
+ }
1860
+ export function getTelegramTemporaryThreadInputs(entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadInput[] {
1861
+ return structuredClone(entry.inputs ?? [{ journalBindingKey: entry.source.journalBindingKey, updateIds: [entry.source.updateId] }]);
1862
+ }
1863
+ /** Every known group is durably cancelled or Forward-completed; still not deletion authority by itself. */
1864
+ export function isTelegramTemporaryThreadFullyResolved(entry: TelegramTemporaryThreadEntry): boolean {
1865
+ const inputs = getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
1866
+ return resolved.length > 0 && inputs.every(input => resolved.some(other => isDeepStrictEqual(other, input)));
1867
+ }
1868
+ /** One unresolved group may release protection after completion when every other group has a durable terminal fact. */
1869
+ function isTemporaryThreadReleasedByCompletion(entry: TelegramTemporaryThreadEntry, completed: TelegramTemporaryThreadInput | undefined): boolean {
1870
+ if (!completed || !isTemporaryThreadInput(completed)) return false;
1871
+ const inputs = getTelegramTemporaryThreadInputs(entry), cancelled = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
1872
+ const same = (left: TelegramTemporaryThreadInput, right: TelegramTemporaryThreadInput): boolean => isDeepStrictEqual(left, right);
1873
+ return inputs.some(input => same(input, completed)) && !cancelled.some(input => same(input, completed)) &&
1874
+ inputs.every(input => same(input, completed) || cancelled.some(other => same(other, input)));
1875
+ }
1876
+ function isTemporaryThreadEntry(value: unknown): value is TelegramTemporaryThreadEntry {
1877
+ if (!restoreObject(value) || !restoreKeys(value, ["source", "inputs", "cancelledInputs", "completedInputs", "forwardedInputs", "forwardProtocol", "cleanupIssued", "operatorUserId", "executor", "token", "phase", "target", "revision",
1878
+ "createdAtMs", "updatedAtMs"]) || !restoreObject(value.source) || !restoreKeys(value.source, ["journalBindingKey", "updateId"]) ||
1879
+ !restoreText(value.source.journalBindingKey) || !restoreInteger(value.source.updateId) ||
1880
+ !restoreInteger(value.operatorUserId) || value.operatorUserId === 0 || !isTelegramWorkspaceRestoreExecutor(value.executor) ||
1881
+ typeof value.token !== "string" || !/^[a-f0-9]{32}$/u.test(value.token) || !restoreInteger(value.revision) ||
1882
+ !restoreInteger(value.createdAtMs) || !restoreInteger(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) return false;
1883
+ const source = value.source;
1884
+ if (value.forwardProtocol !== undefined && (value.forwardProtocol !== "one-shot-v1" || value.inputs === undefined)) return false;
1885
+ if (value.inputs !== undefined) {
1886
+ const inputs = value.inputs;
1887
+ if (!Array.isArray(inputs) || inputs.length === 0 || inputs.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
1888
+ !inputs.every(isTemporaryThreadInput) || !isDeepStrictEqual(inputs[0],
1889
+ { journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }) ||
1890
+ inputs.some((input, index) => input.journalBindingKey !== source.journalBindingKey ||
1891
+ inputs.slice(index + 1).some(other => other.updateIds.some(id => input.updateIds.includes(id))))) return false;
1892
+ }
1893
+ if (value.cancelledInputs !== undefined) {
1894
+ const cancelled = value.cancelledInputs;
1895
+ const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
1896
+ if (!Array.isArray(cancelled) || cancelled.length === 0 || cancelled.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
1897
+ !cancelled.every(isTemporaryThreadInput) || cancelled.some((input, index) =>
1898
+ !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
1899
+ }
1900
+ if (value.completedInputs !== undefined) {
1901
+ const completed = value.completedInputs;
1902
+ const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
1903
+ const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
1904
+ if (!Array.isArray(completed) || completed.length === 0 || completed.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
1905
+ !completed.every(isTemporaryThreadInput) || completed.some((input, index) =>
1906
+ !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
1907
+ completed.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
1908
+ }
1909
+ if (value.forwardedInputs !== undefined) {
1910
+ const forwarded = value.forwardedInputs;
1911
+ const inputs = Array.isArray(value.inputs) ? value.inputs : [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }];
1912
+ const cancelled = Array.isArray(value.cancelledInputs) ? value.cancelledInputs : [];
1913
+ if (!Array.isArray(forwarded) || forwarded.length === 0 || forwarded.length > TEMPORARY_THREAD_INPUT_CAPACITY ||
1914
+ !forwarded.every(isTemporaryThreadInput) || forwarded.some((input, index) =>
1915
+ !inputs.some(candidate => isDeepStrictEqual(candidate, input)) || cancelled.some(other => isDeepStrictEqual(other, input)) ||
1916
+ forwarded.slice(index + 1).some(other => isDeepStrictEqual(other, input)))) return false;
1917
+ }
1918
+ if (value.cleanupIssued !== undefined && (value.cleanupIssued !== true ||
1919
+ !isTelegramTemporaryThreadFullyResolved(value as unknown as TelegramTemporaryThreadEntry))) return false;
1920
+ if (value.phase === "creating") return value.target === undefined && value.cancelledInputs === undefined && value.completedInputs === undefined && value.forwardedInputs === undefined && value.cleanupIssued === undefined &&
1921
+ (!Array.isArray(value.inputs) || value.inputs.length === 1);
1922
+ const target = value.target;
1923
+ return value.phase === "created" && restoreObject(target) && restoreKeys(target, ["chatId", "threadId"]) &&
1924
+ target.chatId === value.operatorUserId && Number.isSafeInteger(target.threadId) && (target.threadId as number) > 0;
1925
+ }
1926
+ /** Only an exact recorded source group may Restore into its temporary tab. */
1927
+ function isTemporaryThreadRestore(entry: TelegramTemporaryThreadEntry, operations: readonly TelegramWorkspaceRestoreIntent[]): boolean {
1928
+ return operations.some(({ request }) => !!entry.target && targetMatches(request.target, entry.target) &&
1929
+ ![...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])].some(input => input.journalBindingKey === request.source.journalBindingKey &&
1930
+ input.updateIds.some(id => request.source.updateIds.includes(id))) &&
1931
+ (entry.inputs ? entry.inputs.some(input => isDeepStrictEqual(input, request.source)) :
1932
+ request.source.journalBindingKey === entry.source.journalBindingKey && request.source.updateIds.includes(entry.source.updateId)));
1933
+ }
1934
+ function conflictsWithTemporaryThread(snapshot: TelegramWorkspaceRestoreSnapshot | undefined, target: TelegramTarget | undefined): boolean {
1935
+ return !!target && !!snapshot?.temporaryThreads?.some(entry => !!entry.target && targetMatches(entry.target, target));
1936
+ }
1937
+ function conflictsWithWorkspaceRestoreProvision(request: TelegramWorkspaceRestoreRequest,
1938
+ provision: TelegramThreadPendingProvision | TelegramThreadReservation): boolean {
1939
+ if ("owner" in provision && !provision.workspaceBindingKey &&
1940
+ (provision.profileKey === request.owner.profileKey || provision.instanceId === request.owner.instanceId)) return true;
1941
+ return ("workspaceBindingKey" in provision && provision.workspaceBindingKey === request.binding.bindingKey) || provision.slot === request.binding.slot ||
1942
+ !!provision.target && (targetMatches(provision.target, request.binding.target) || targetMatches(provision.target, request.target));
1943
+ }
1944
+
1945
+ function assertWorkspaceRestoreBindingProtection(file: Pick<TelegramTopicTargetFile, "workspaceBindings" | "workspaceRestore" | "pendingProvisions" | "reservations"> & {
1946
+ threads?: readonly Pick<TelegramTopicTargetRecord, "target" | "slot">[];
1947
+ }): void {
1948
+ for (const { request } of file.workspaceRestore?.operations ?? []) {
1949
+ if (file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
1950
+ file.reservations?.some(value => conflictsWithWorkspaceRestoreProvision(request, value))) {
1951
+ throw new Error("Protected Workspace Restore provisioning conflict.");
1952
+ }
1953
+ const bindings = file.workspaceBindings ?? [];
1954
+ const retained = bindings.filter(value => value.bindingKey === request.binding.bindingKey);
1955
+ const current = retained[0];
1956
+ const identity = ["bindingKey", "cwd", "workspaceKey", "instanceSlot", "sessionId", "sessionKey", "slot"] as const;
1957
+ if (retained.length !== 1 || !current || identity.some(key => current[key] !== request.binding[key]) ||
1958
+ !targetMatches(current.target, request.target) || bindings.some(value => value !== current &&
1959
+ (value.slot === current.slot || targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target)))) {
1960
+ throw new Error("Protected Workspace Restore binding changed.");
1961
+ }
1962
+ const owners = (file.threads ?? []).filter(value => value.slot === current.slot ||
1963
+ targetMatches(value.target, request.binding.target) || targetMatches(value.target, request.target));
1964
+ if (owners.length > 1 || owners.some(value => value.slot !== current.slot || !targetMatches(value.target, request.target))) {
1965
+ throw new Error("Protected Workspace Restore owner target changed.");
1966
+ }
1967
+ }
1968
+ const operations = file.workspaceRestore?.operations ?? [];
1969
+ for (const entry of file.workspaceRestore?.temporaryThreads ?? []) {
1970
+ const target = entry.target;
1971
+ if (!target) continue;
1972
+ const claimed = [...(file.workspaceBindings ?? []), ...(file.threads ?? [])].some(value => targetMatches(value.target, target));
1973
+ if (file.pendingProvisions?.some(value => !!value.target && targetMatches(value.target, target)) ||
1974
+ file.reservations?.some(value => targetMatches(value.target, target)) ||
1975
+ (claimed && !isTemporaryThreadRestore(entry, operations))) {
1976
+ throw new Error("Protected temporary Thread target conflict.");
1977
+ }
1978
+ }
1979
+ }
1980
+
1981
+ function workspaceRestoresConflict(left: TelegramWorkspaceRestoreRequest, right: TelegramWorkspaceRestoreRequest): boolean {
1982
+ const targets = [left.binding.target, left.target];
1983
+ return left.operationId === right.operationId || left.binding.bindingKey === right.binding.bindingKey ||
1984
+ left.binding.slot === right.binding.slot || targets.some(target => targetMatches(target, right.binding.target) || targetMatches(target, right.target)) ||
1985
+ (left.source.journalBindingKey === right.source.journalBindingKey && right.source.updateIds.some(id => left.source.updateIds.includes(id)));
1986
+ }
1987
+ const WORKSPACE_RESTORE_MAX_BYTES = 1024 * 1024;
1988
+ const TEMPORARY_THREAD_CAPACITY = 26;
1989
+ const TEMPORARY_THREAD_INPUT_CAPACITY = 100;
1990
+ const WORKSPACE_SNAPSHOT_MAX_BYTES = 8 * WORKSPACE_RESTORE_MAX_BYTES;
1991
+ function parseWorkspaceRestore(value: unknown): TelegramWorkspaceRestoreSnapshot | undefined {
1992
+ if (value === undefined) return undefined;
1993
+ const temporary = restoreObject(value) ? value.temporaryThreads : undefined;
1994
+ if (!restoreObject(value) || !restoreKeys(value, ["version", "profileName", "tokenSha256", "revision", "operations", "temporaryThreads"]) ||
1995
+ value.version !== 1 || !restoreText(value.profileName) || typeof value.tokenSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.tokenSha256) ||
1996
+ !restoreInteger(value.revision) || !Array.isArray(value.operations) || value.operations.length > 26 ||
1997
+ (temporary !== undefined && (!Array.isArray(temporary) || temporary.length === 0 ||
1998
+ temporary.length > TEMPORARY_THREAD_CAPACITY || !temporary.every(isTemporaryThreadEntry))) ||
1999
+ ((value.operations.length > 0 || temporary !== undefined) && value.revision === 0) || !value.operations.every(isWorkspaceRestoreIntent) ||
2000
+ Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_RESTORE_MAX_BYTES) throw new Error("Invalid Workspace Restore evidence.");
2001
+ const snapshot = value as unknown as TelegramWorkspaceRestoreSnapshot;
2002
+ if (snapshot.operations.some((operation, index) => snapshot.operations.slice(index + 1).some(other => workspaceRestoresConflict(operation.request, other.request))))
2003
+ throw new Error("Conflicting Workspace Restore evidence.");
2004
+ const entries = snapshot.temporaryThreads ?? [];
2005
+ if (entries.some((entry, index) => entries.slice(index + 1).some(other => other.token === entry.token ||
2006
+ getTelegramTemporaryThreadInputs(entry).some(input => getTelegramTemporaryThreadInputs(other).some(candidate =>
2007
+ input.journalBindingKey === candidate.journalBindingKey && input.updateIds.some(id => candidate.updateIds.includes(id)))) ||
2008
+ (!!entry.target && !!other.target && targetMatches(entry.target, other.target)))) ||
2009
+ entries.some(entry => !!entry.target && snapshot.operations.some(({ request }) => targetMatches(request.binding.target, entry.target!) ||
2010
+ (targetMatches(request.target, entry.target!) && !isTemporaryThreadRestore(entry, [{ request } as TelegramWorkspaceRestoreIntent])))))
2011
+ throw new Error("Conflicting Workspace Restore evidence.");
2012
+ return snapshot;
2013
+ }
2014
+ /** All Restore reads and publishers share these physical-file checks; there is no repairing read. */
2015
+ const readWorkspaceDescriptor = promisify(readFileCallback);
2016
+ function readWorkspaceSnapshot(path: string, requirePrivate = true, asynchronous = false): unknown {
2017
+ let before;
2018
+ try { before = lstatSync(path, { bigint: true }); }
2019
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
2020
+ const uid = process.getuid?.();
2021
+ const privateFile = uid === undefined || (before.uid === BigInt(uid) && (before.mode & 0o077n) === 0n);
2022
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > BigInt(WORKSPACE_SNAPSHOT_MAX_BYTES) ||
2023
+ (requirePrivate && !privateFile)) throw new Error("Workspace snapshot must be a bounded private regular file.");
2024
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
2025
+ let closeImmediately = true;
2026
+ try {
2027
+ const opened = fstatSync(fd, { bigint: true });
2028
+ if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs)
2029
+ throw new Error("Workspace snapshot changed during inspection.");
2030
+ const decode = (content: string): unknown => {
2031
+ const value: unknown = JSON.parse(content);
2032
+ // Legacy metadata may predate private storage; a Restore-bearing snapshot never may.
2033
+ if (!privateFile && restoreObject(value) && value.workspaceRestore !== undefined)
2034
+ throw new Error("Workspace snapshot must be a bounded private regular file.");
2035
+ return value;
2036
+ };
2037
+ if (asynchronous) {
2038
+ const reading = readWorkspaceDescriptor(fd, "utf8").then(decode);
2039
+ closeImmediately = false;
2040
+ return reading.finally(() => closeSync(fd));
2041
+ }
2042
+ return decode(readFileSync(fd, "utf8"));
2043
+ } finally { if (closeImmediately) closeSync(fd); }
2044
+ }
2045
+
1647
2046
  function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1648
2047
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1649
2048
  return {
@@ -1655,6 +2054,12 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1655
2054
  };
1656
2055
  }
1657
2056
  const file = value as Record<string, unknown>;
2057
+ // The unpublished two-file draft is not migration or source-reconstruction authority.
2058
+ if ((file.workspaceRelocations !== undefined && (!Array.isArray(file.workspaceRelocations) || file.workspaceRelocations.length !== 0)) ||
2059
+ (file.workspaceRelocationRevision !== undefined && file.workspaceRelocationRevision !== 0))
2060
+ throw new Error("Unmigrated Workspace relocation evidence.");
2061
+ const workspaceRestore = parseWorkspaceRestore(file.workspaceRestore);
2062
+ if (file.version !== 1 && workspaceRestore) throw new Error("Unsupported Workspace Restore snapshot.");
1658
2063
  if (file.version !== 1) {
1659
2064
  return {
1660
2065
  version: 1,
@@ -1686,6 +2091,8 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1686
2091
  workspaceBindings: Array.isArray(file.workspaceBindings)
1687
2092
  ? file.workspaceBindings.flatMap((binding) => {
1688
2093
  const normalized = normalizeWorkspaceBindingRecord(binding);
2094
+ if (!normalized && binding && typeof binding === "object" && "journalSources" in binding)
2095
+ throw new Error("Invalid Workspace session journal evidence.");
1689
2096
  return normalized ? [normalized] : [];
1690
2097
  })
1691
2098
  : [],
@@ -1695,6 +2102,7 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1695
2102
  return normalized ? [normalized] : [];
1696
2103
  })
1697
2104
  : [],
2105
+ ...(workspaceRestore ? { workspaceRestore } : {}),
1698
2106
  sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
1699
2107
  reservations: Array.isArray(file.reservations)
1700
2108
  ? file.reservations.flatMap((reservation) => {
@@ -1723,6 +2131,92 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1723
2131
  };
1724
2132
  }
1725
2133
 
2134
+ /** The unified section accepts only lossless current-format Workspace evidence, never tolerant legacy repair. */
2135
+ export function parseTelegramWorkspaceStateSection(value: unknown, profile: string): TelegramTopicTargetFile | undefined {
2136
+ if (value === undefined) return undefined;
2137
+ if (!restoreObject(value) || value.version !== 1 || value.source !== "snapshot" ||
2138
+ !Number.isSafeInteger(value.writtenAtMs) || (value.writtenAtMs as number) < 0 ||
2139
+ !restoreObject(value.bot) || !Array.isArray(value.threads))
2140
+ throw new Error("Invalid consolidated Workspace section.");
2141
+ if (Buffer.byteLength(JSON.stringify(value)) > WORKSPACE_SNAPSHOT_MAX_BYTES)
2142
+ throw new Error("Workspace snapshot byte capacity reached.");
2143
+ const parsed = parseTopicTargetFile(value), rawThreads = value.threads;
2144
+ const wire = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
2145
+ wire.threads = (wire.threads as Record<string, unknown>[]).map((record, index) => {
2146
+ const copied = { ...record };
2147
+ const original = rawThreads[index];
2148
+ if (!restoreObject(original)) throw new Error("Invalid consolidated Workspace thread evidence.");
2149
+ if (!Object.hasOwn(original, "profileKey")) delete copied.profileKey;
2150
+ return copied;
2151
+ });
2152
+ for (const key of Object.keys(value)) {
2153
+ if (!Object.hasOwn(wire, key) || !isDeepStrictEqual(value[key], wire[key]))
2154
+ throw new Error("Consolidated Workspace evidence cannot drop or normalize unknown facts.");
2155
+ }
2156
+ if ((parsed.workspaceRestore && parsed.workspaceRestore.profileName !== profile) ||
2157
+ (parsed.sessionReplacement && parsed.sessionReplacement.profileName !== profile) ||
2158
+ parsed.threads.some(record => record.owner && "telegramProfile" in record.owner &&
2159
+ record.owner.telegramProfile !== undefined && record.owner.telegramProfile !== profile))
2160
+ throw new Error("Foreign consolidated Workspace profile evidence.");
2161
+ assertWorkspaceRestoreBindingProtection(parsed);
2162
+ return parsed;
2163
+ }
2164
+
2165
+ export function resolveTelegramWorkspaceProvisionRecoveryPath(statePath: string, profileName = "default", layout?: "consolidated"): string {
2166
+ if (layout !== undefined && layout !== "consolidated") throw new Error("Invalid Workspace provisioning recovery layout.");
2167
+ return layout === "consolidated"
2168
+ ? join(dirname(statePath), "runtime", `${basename(statePath)}.provision-recovery.${createHash("sha256").update(profileName).digest("hex").slice(0, 16)}.json`)
2169
+ : `${statePath}.provision-recovery.json`;
2170
+ }
2171
+
2172
+ export interface TelegramConsolidatedWorkspaceStorageOptions {
2173
+ getPath: () => string;
2174
+ getProfile: () => string | undefined;
2175
+ /** Exact owner/context/session grant captured before the caller's first await. */
2176
+ captureAuthority: () => (() => boolean) | undefined;
2177
+ publishIfOwned: NonNullable<TelegramLockRuntime<TelegramLockContext>["publishStateSectionIfOwned"]>;
2178
+ }
2179
+ export type TelegramWorkspaceStatePublication = <T>(
2180
+ mutate: (current: unknown) => TelegramRuntimeStateMutation<T>,
2181
+ publication?: Partial<Pick<TelegramOwnedStatePublicationOptions, "onPublicationBoundary" | "publishRename" | "isCurrent">>,
2182
+ ) => TelegramOwnedStatePublicationResult<T>;
2183
+
2184
+ /** Prepared Workspace IO adapter; transition/CAS policy remains with the existing Threads owner. */
2185
+ export function createTelegramConsolidatedWorkspaceStorage(options: TelegramConsolidatedWorkspaceStorageOptions) {
2186
+ const { getPath, getProfile, captureAuthority, publishIfOwned } = options;
2187
+ const profileName = (): string => getProfile() ?? "default";
2188
+ const readRaw = (): unknown => {
2189
+ const path = getPath(), profile = profileName(), root = readTelegramRuntimeState(path);
2190
+ const raw = Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.workspace : undefined;
2191
+ parseTelegramWorkspaceStateSection(raw, profile);
2192
+ if (path !== getPath() || profile !== profileName()) throw new Error("Workspace storage scope changed during observation.");
2193
+ return raw;
2194
+ };
2195
+ return {
2196
+ readRaw,
2197
+ read: () => parseTelegramWorkspaceStateSection(readRaw(), profileName()),
2198
+ capturePublication(): TelegramWorkspaceStatePublication | undefined {
2199
+ const path = getPath(), profile = profileName(), authority = captureAuthority();
2200
+ if (!authority) return undefined;
2201
+ const isCurrent = (): boolean => path === getPath() && profile === profileName() && authority() === true;
2202
+ if (!isCurrent()) return undefined;
2203
+ return <T>(mutate: (current: unknown) => TelegramRuntimeStateMutation<T>,
2204
+ publication?: Partial<Pick<TelegramOwnedStatePublicationOptions, "onPublicationBoundary" | "publishRename" | "isCurrent">>) => {
2205
+ const sourceCurrent = publication?.isCurrent;
2206
+ const currentGrant = () => isCurrent() && (sourceCurrent?.() ?? true);
2207
+ if (!currentGrant()) return { committed: false };
2208
+ return publishIfOwned<T>("workspace", current => {
2209
+ parseTelegramWorkspaceStateSection(current, profile);
2210
+ const next = mutate(current);
2211
+ if (next.value === undefined && current !== undefined) throw new Error("Consolidated Workspace section removal is not authorized.");
2212
+ parseTelegramWorkspaceStateSection(next.value, profile);
2213
+ return next;
2214
+ }, { ...publication, isCurrent: currentGrant, expectedScope: { path, profile } });
2215
+ };
2216
+ },
2217
+ };
2218
+ }
2219
+
1726
2220
  function getTelegramStateSemanticSnapshot(
1727
2221
  value: unknown,
1728
2222
  ): Record<string, unknown> | undefined {
@@ -1735,14 +2229,6 @@ function getTelegramStateSemanticSnapshot(
1735
2229
  return semantic;
1736
2230
  }
1737
2231
 
1738
- function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
1739
- return left.chatId === right.chatId && left.threadId === right.threadId;
1740
- }
1741
-
1742
- function getTargetRecoveryHintKey(target: TelegramTarget): string {
1743
- return `${target.chatId}:${target.threadId ?? "private"}`;
1744
- }
1745
-
1746
2232
  function parseFollowerRecoveryHints(
1747
2233
  value: unknown,
1748
2234
  ): Map<string, { slot?: string; threadName?: string }> {
@@ -1819,9 +2305,37 @@ function isPendingProvisionLiveOrTargeted(
1819
2305
  return !!provision.target;
1820
2306
  }
1821
2307
 
2308
+ /** Resolving selects a scope-bound view of the existing Workspace owner; it creates no files. */
2309
+ export function createTelegramWorkspaceRestoreResolver(deps: {
2310
+ getProfileName: () => string | undefined;
2311
+ getBotToken: () => string | undefined;
2312
+ threadStore: Pick<TelegramTopicTargetStore, "workspaceRestore">;
2313
+ agentDir?: string;
2314
+ }) {
2315
+ const { getProfileName, getBotToken, threadStore, agentDir } = deps;
2316
+ let active: { profileName: string; tokenSha256: string; store: TelegramWorkspaceRestore } | undefined;
2317
+ return () => {
2318
+ const profile = getProfileName();
2319
+ const profileName = profile ?? "default";
2320
+ const token = getBotToken();
2321
+ if (!token) return undefined;
2322
+ const tokenSha256 = createHash("sha256").update(token).digest("hex");
2323
+ if (active?.profileName === profileName && active.tokenSha256 === tokenSha256) return active.store;
2324
+ const store = threadStore.workspaceRestore({ profileName, tokenSha256,
2325
+ legacyPath: resolveTelegramProfileTempFilePath("workspace-restore", "json", agentDir, profile),
2326
+ isCurrentScope: () => (getProfileName() ?? "default") === profileName &&
2327
+ createHash("sha256").update(getBotToken() ?? "").digest("hex") === tokenSha256 });
2328
+ active = { profileName, tokenSha256, store };
2329
+ return store;
2330
+ };
2331
+ }
2332
+
1822
2333
  export function createTelegramTopicTargetStore(
1823
2334
  options: TelegramTopicTargetStoreOptions,
1824
2335
  ): TelegramTopicTargetStore {
2336
+ if (options.consolidated && options.commitPersist) throw new Error("Workspace must select one publication backend.");
2337
+ const consolidated = options.consolidated ? { ...options.consolidated } : undefined;
2338
+ const commitPersist = options.commitPersist?.bind(options);
1825
2339
  const getNowMs = options.getNowMs ?? Date.now;
1826
2340
  const captureExternalReservedSlots = (): string[] | undefined => {
1827
2341
  try {
@@ -1840,6 +2354,7 @@ export function createTelegramTopicTargetStore(
1840
2354
  let identities = new Map<string, TelegramThreadIdentityRecord>();
1841
2355
  let workspaceBindings = new Map<string, TelegramWorkspaceThreadBinding>();
1842
2356
  let workspaceRetirements: TelegramWorkspaceRetirementIntent[] = [];
2357
+ let workspaceRestore: TelegramWorkspaceRestoreSnapshot | undefined;
1843
2358
  let sessionReplacement: TelegramSessionReplacementIntent | undefined;
1844
2359
  let workspaceRetirementCommitInFlight = false;
1845
2360
  const hasWorkspaceRetirementConflict = (input: {
@@ -1870,10 +2385,14 @@ export function createTelegramTopicTargetStore(
1870
2385
  >();
1871
2386
  let loaded = false;
1872
2387
  let loadedPath: string | undefined;
2388
+ let loadedProfile: string | undefined;
2389
+ let observedWorkspaceSemantic: Record<string, unknown> | undefined;
1873
2390
  let dirty = false;
1874
2391
  let mutationRevision = 0;
1875
- let statusRevision = 0;
1876
2392
  let persistQueue: Promise<void> = Promise.resolve();
2393
+ let statusQueue: Promise<void> = Promise.resolve();
2394
+ let lastStatusSemantic: Record<string, unknown> | undefined;
2395
+ let lastStatusPath: string | undefined;
1877
2396
  let statusSnapshot: {
1878
2397
  runtime?: Record<string, unknown>;
1879
2398
  liveRoster?: Record<string, unknown>;
@@ -1967,6 +2486,11 @@ export function createTelegramTopicTargetStore(
1967
2486
 
1968
2487
  const getPath = () =>
1969
2488
  typeof options.path === "function" ? options.path() : options.path;
2489
+ /** The non-canonical projection sits beside canonical state (`state*.json` → `status*.json`). */
2490
+ const getStatusPath = (): string => {
2491
+ const statePath = getPath(), name = basename(statePath);
2492
+ return join(dirname(statePath), /^state/u.test(name) ? name.replace(/^state/u, "status") : `${name}.status`);
2493
+ };
1970
2494
  const getTelegramProfile = () =>
1971
2495
  typeof options.telegramProfile === "function"
1972
2496
  ? options.telegramProfile()
@@ -1985,11 +2509,61 @@ export function createTelegramTopicTargetStore(
1985
2509
  }
1986
2510
  return { ...owner, telegramProfile };
1987
2511
  };
1988
- const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
2512
+ const activeProfile = () => getTelegramProfile() ?? "default";
2513
+ const scopeMatches = (path: string, profile: string) => getPath() === path && activeProfile() === profile;
2514
+ const loadedScopeMatches = (path: string) => loadedPath === path && (!consolidated || loadedProfile === activeProfile());
2515
+ const workspaceIO = consolidated ? createTelegramConsolidatedWorkspaceStorage({ getPath, getProfile: getTelegramProfile, ...consolidated }) : undefined;
2516
+ const runtimeProjection = consolidated ? createTelegramRuntimeProjectionStore({ getPath, getProfile: getTelegramProfile,
2517
+ captureAuthority: consolidated.captureAuthority, getNowMs,
2518
+ storage: {
2519
+ read({ path, profile }) { const root = readTelegramRuntimeState(path); return Object.hasOwn(root.profiles, profile) ? root.profiles[profile]?.runtime : undefined; },
2520
+ publish(expectedScope, mutate, isCurrent) {
2521
+ const outcome = consolidated.publishIfOwned("runtime", current => {
2522
+ const next = mutate(current);
2523
+ return { value: next.value, result: next.changed };
2524
+ }, { isCurrent, expectedScope });
2525
+ return outcome.committed && outcome.result;
2526
+ },
2527
+ },
2528
+ }) : undefined;
2529
+ let workspaceFrame: { path: string; profile: string; value: unknown } | undefined;
2530
+ const readStoreSnapshot = (path: string, requirePrivate = true, asynchronous = false): unknown => {
2531
+ if (!workspaceIO) return readWorkspaceSnapshot(path, requirePrivate, asynchronous);
2532
+ if (path !== getPath()) throw new Error("Workspace storage scope changed during observation.");
2533
+ if (workspaceFrame) {
2534
+ if (!scopeMatches(workspaceFrame.path, workspaceFrame.profile)) throw new Error("Workspace transaction scope changed.");
2535
+ return workspaceFrame.value;
2536
+ }
2537
+ const value = workspaceIO.readRaw();
2538
+ return asynchronous ? Promise.resolve(value) : value;
2539
+ };
2540
+ const withStoreTransaction = <T>(path: string, operation: () => T,
2541
+ publication?: Parameters<TelegramWorkspaceStatePublication>[1]): T => {
2542
+ if (!workspaceIO) return withTelegramFileTransaction(`${path}.transaction`, operation);
2543
+ if (workspaceFrame) throw new Error("Nested Workspace transaction is not allowed.");
2544
+ const profile = activeProfile(), publish = workspaceIO.capturePublication();
2545
+ if (!publish || path !== getPath()) throw new Error("Workspace publication authority changed.");
2546
+ const outcome = publish(value => {
2547
+ workspaceFrame = { path, profile, value };
2548
+ try { const result = operation(); return { value: workspaceFrame.value, result }; }
2549
+ finally { workspaceFrame = undefined; }
2550
+ }, publication);
2551
+ if (!outcome.committed) throw new Error("Workspace publication authority changed.");
2552
+ return outcome.result;
2553
+ };
2554
+ const getRecoveryPath = (path: string) => consolidated
2555
+ ? resolveTelegramWorkspaceProvisionRecoveryPath(path, activeProfile(), "consolidated")
2556
+ : resolveTelegramWorkspaceProvisionRecoveryPath(path);
1989
2557
  const readProvisionRecoveries = (
1990
- path: string,
2558
+ path: string, strict = false,
1991
2559
  ): TelegramProvisionRecoveryFile => {
1992
2560
  const recoveryPath = getRecoveryPath(path);
2561
+ if (strict) {
2562
+ const value = readWorkspaceSnapshot(recoveryPath);
2563
+ if (value === undefined) return {};
2564
+ if (!restoreObject(value)) throw new Error("Invalid Workspace provisioning recovery evidence.");
2565
+ return value as TelegramProvisionRecoveryFile;
2566
+ }
1993
2567
  if (!existsSync(recoveryPath)) return {};
1994
2568
  try {
1995
2569
  const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown;
@@ -2000,13 +2574,39 @@ export function createTelegramTopicTargetStore(
2000
2574
  return {};
2001
2575
  }
2002
2576
  };
2577
+ const assertRestoreRecoveryProtection = (path: string, operations: readonly TelegramWorkspaceRestoreIntent[],
2578
+ provisions: readonly TelegramThreadPendingProvision[], requireKnownTargets = false,
2579
+ recoveryEvidence?: TelegramProvisionRecoveryFile): void => {
2580
+ if (!operations.length) return;
2581
+ const recoveries = recoveryEvidence ?? readProvisionRecoveries(path, true);
2582
+ for (const provision of provisions) {
2583
+ let target = provision.target;
2584
+ if (Object.hasOwn(recoveries, provision.id)) {
2585
+ const recovery = recoveries[provision.id];
2586
+ if (!restoreObject(recovery)) throw new Error("Invalid Workspace provisioning recovery evidence.");
2587
+ if (recovery.instanceId === provision.instanceId && recovery.profileKey === provision.profileKey &&
2588
+ recovery.leaderEpoch === provision.leaderEpoch) {
2589
+ const observed = recovery.target;
2590
+ if (!restoreObject(observed) || typeof observed.chatId !== "number" || !Number.isSafeInteger(observed.chatId) ||
2591
+ !restoreInteger(observed.threadId) || observed.threadId === 0) throw new Error("Invalid Workspace provisioning recovery evidence.");
2592
+ const recovered = { chatId: observed.chatId, threadId: observed.threadId };
2593
+ if (target && !targetMatches(target, recovered)) throw new Error("Conflicting Workspace provisioning target evidence.");
2594
+ target = recovered;
2595
+ }
2596
+ }
2597
+ if (requireKnownTargets && !target) throw new Error("Workspace Restore target availability is unknown during unfinished creation.");
2598
+ if (operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, { ...provision, target })))
2599
+ throw new Error("Protected Workspace Restore provisioning recovery conflict.");
2600
+ }
2601
+ };
2003
2602
  const resetForPath = (path: string) => {
2004
- if (loadedPath === path) return;
2603
+ if (loadedScopeMatches(path)) return;
2005
2604
  botState = { threadMode: "unknown" };
2006
2605
  records = new Map();
2007
2606
  identities = new Map();
2008
2607
  workspaceBindings = new Map();
2009
2608
  workspaceRetirements = [];
2609
+ workspaceRestore = undefined;
2010
2610
  sessionReplacement = undefined;
2011
2611
  workspaceRetirementCommitInFlight = false;
2012
2612
  workspaceClaims = new Map();
@@ -2019,17 +2619,60 @@ export function createTelegramTopicTargetStore(
2019
2619
  loaded = false;
2020
2620
  dirty = false;
2021
2621
  loadedPath = path;
2622
+ loadedProfile = activeProfile();
2623
+ observedWorkspaceSemantic = undefined;
2022
2624
  };
2023
2625
 
2024
- const loadFromDisk = async () => {
2626
+ const observeWorkspaceRestore = (incoming: TelegramWorkspaceRestoreSnapshot | undefined): void => {
2627
+ if (workspaceRestore && (!incoming || incoming.profileName !== workspaceRestore.profileName ||
2628
+ incoming.tokenSha256 !== workspaceRestore.tokenSha256 || incoming.revision < workspaceRestore.revision ||
2629
+ (incoming.revision === workspaceRestore.revision && !isDeepStrictEqual(incoming, workspaceRestore))))
2630
+ throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
2631
+ };
2632
+ const withRestoreRegistration = (candidate: Parameters<TelegramTopicTargetStore["assertWorkspaceRestoreRegistration"]>[0], publish?: () => void): void => {
2025
2633
  const path = getPath();
2634
+ withStoreTransaction(path, () => {
2635
+ const file = parseTopicTargetFile(readStoreSnapshot(path, false));
2636
+ observeWorkspaceRestore(file.workspaceRestore);
2637
+ const operations = file.workspaceRestore?.operations ?? [];
2638
+ if (operations.length) {
2639
+ if ((options.canPersist && !options.canPersist()) || getPath() !== path)
2640
+ throw new Error("Workspace Restore registration authority changed.");
2641
+ assertWorkspaceRestoreBindingProtection(file);
2642
+ assertRestoreRecoveryProtection(path, operations, file.pendingProvisions ?? []);
2643
+ for (const { request } of operations) {
2644
+ if (targetMatches(candidate.target, request.binding.target) ||
2645
+ ((candidate.bindingKey === request.binding.bindingKey || candidate.slot === request.binding.slot ||
2646
+ targetMatches(candidate.target, request.target)) &&
2647
+ (candidate.bindingKey !== request.binding.bindingKey || candidate.slot !== request.binding.slot ||
2648
+ !targetMatches(candidate.target, request.target))))
2649
+ throw new Error("Protected Workspace Restore registration conflicts with its binding or target.");
2650
+ }
2651
+ }
2652
+ if (publish && candidate.bindingKey !== undefined) {
2653
+ const bindings = (file.workspaceBindings ?? []).filter(binding => binding.bindingKey === candidate.bindingKey);
2654
+ if (bindings.length !== 1 || bindings[0]?.slot !== candidate.slot || !targetMatches(bindings[0].target, candidate.target))
2655
+ throw new Error("Workspace registration binding changed before publication.");
2656
+ }
2657
+ publish?.();
2658
+ });
2659
+ };
2660
+ const loadFromDisk = async () => {
2661
+ const path = getPath(), profile = activeProfile();
2026
2662
  resetForPath(path);
2027
- if (!existsSync(path)) {
2663
+ const revision = mutationRevision;
2664
+ const rawFile = await readStoreSnapshot(path, !!workspaceRestore, true);
2665
+ // A read begun before a local mutation cannot replace the newly admitted projection.
2666
+ if (mutationRevision !== revision || getPath() !== path || (consolidated && !scopeMatches(path, profile))) return;
2667
+ if (consolidated) observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(rawFile);
2668
+ if (rawFile === undefined) {
2669
+ if (workspaceRestore?.revision) throw new Error("Workspace Restore evidence disappeared.");
2028
2670
  botState = { threadMode: "unknown" };
2029
2671
  records = new Map();
2030
2672
  identities = new Map();
2031
2673
  workspaceBindings = new Map();
2032
2674
  workspaceRetirements = [];
2675
+ workspaceRestore = undefined;
2033
2676
  sessionReplacement = undefined;
2034
2677
  workspaceRetirementCommitInFlight = false;
2035
2678
  reservations = [];
@@ -2040,14 +2683,14 @@ export function createTelegramTopicTargetStore(
2040
2683
  loaded = true;
2041
2684
  return;
2042
2685
  }
2043
- const revision = mutationRevision;
2044
- const content = await readFile(path, "utf8");
2045
- // A read begun before a local mutation must not replace the newly admitted
2046
- // binding/cleanup state with its older disk snapshot.
2047
- if (mutationRevision !== revision || getPath() !== path) return;
2048
- const rawFile: unknown = JSON.parse(content);
2049
2686
  const file = parseTopicTargetFile(rawFile);
2050
- followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
2687
+ observeWorkspaceRestore(file.workspaceRestore);
2688
+ const recoveries = readProvisionRecoveries(path, !!file.workspaceRestore?.operations.length);
2689
+ assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? [], false, recoveries);
2690
+ try { followerRecoveryHints = parseFollowerRecoveryHints(consolidated
2691
+ ? runtimeProjection?.read()
2692
+ : JSON.parse(readFileSync(getStatusPath(), "utf8"))); }
2693
+ catch { followerRecoveryHints = new Map(); }
2051
2694
  botState = file.bot;
2052
2695
  const scopedRecords = file.threads.map((record) =>
2053
2696
  cloneRecord({
@@ -2079,6 +2722,7 @@ export function createTelegramTopicTargetStore(
2079
2722
  workspaceRetirements = (file.workspaceRetirements ?? []).map(
2080
2723
  cloneWorkspaceRetirementIntent,
2081
2724
  );
2725
+ workspaceRestore = structuredClone(file.workspaceRestore);
2082
2726
  sessionReplacement = file.sessionReplacement
2083
2727
  ? cloneSessionReplacementIntent(file.sessionReplacement)
2084
2728
  : undefined;
@@ -2092,9 +2736,7 @@ export function createTelegramTopicTargetStore(
2092
2736
  reservation.expiresAtMs > nowMs,
2093
2737
  )
2094
2738
  .map((reservation) => ({ ...reservation }));
2095
- const recoveries = readProvisionRecoveries(path);
2096
2739
  pendingProvisions = (file.pendingProvisions ?? [])
2097
- .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
2098
2740
  .map((provision) => {
2099
2741
  const recovery = recoveries[provision.id];
2100
2742
  const recoveryMatches =
@@ -2110,7 +2752,8 @@ export function createTelegramTopicTargetStore(
2110
2752
  ? { target: { ...provision.target } }
2111
2753
  : {}),
2112
2754
  };
2113
- });
2755
+ })
2756
+ .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs));
2114
2757
  pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
2115
2758
  ...intent,
2116
2759
  target: { ...intent.target },
@@ -2124,6 +2767,10 @@ export function createTelegramTopicTargetStore(
2124
2767
  };
2125
2768
 
2126
2769
  const markDirty = (): void => {
2770
+ if (consolidated && !loadedScopeMatches(getPath())) {
2771
+ if (loadedPath !== undefined) throw new Error("Workspace projection must refresh after scope change.");
2772
+ loadedPath = getPath(); loadedProfile = activeProfile();
2773
+ }
2127
2774
  loaded = true;
2128
2775
  dirty = true;
2129
2776
  mutationRevision += 1;
@@ -2146,15 +2793,24 @@ export function createTelegramTopicTargetStore(
2146
2793
  } & (
2147
2794
  | { kind: "invalidate"; lastSyncError: string }
2148
2795
  | { kind: "detach"; owner: TelegramTopicTargetRecord }
2149
- )): Promise<boolean> => {
2796
+ | { kind: "relocate"; operationId: string; owner: TelegramTopicTargetRecord;
2797
+ binding: TelegramWorkspaceThreadBinding;
2798
+ nextTarget: TelegramTarget & { threadId: number };
2799
+ restore: TelegramWorkspaceRestoreSnapshot; maxBytes: number;
2800
+ onPublicationBoundary?: TelegramWorkspaceRestoreOptions["onPublicationBoundary"] }
2801
+ ), isExactJournalFrame?: () => boolean): Promise<boolean> => {
2802
+ const submittedPath = getPath(), submittedProfile = activeProfile();
2803
+ const publishWorkspace = workspaceIO?.capturePublication();
2150
2804
  const persist = persistQueue.then(async () => {
2151
2805
  const path = getPath();
2152
- if (loadedPath !== path && !dirty) resetForPath(path);
2806
+ if (workspaceIO && (!publishWorkspace || !scopeMatches(submittedPath, submittedProfile) || (dirty && !loadedScopeMatches(path)))) return false;
2807
+ if (!loadedScopeMatches(path) && !dirty) resetForPath(path);
2153
2808
  if (options.canPersist && !options.canPersist()) {
2154
2809
  if (!transition) await loadFromDisk();
2155
2810
  return false;
2156
2811
  }
2157
2812
  if (!dirty || !loaded) await loadFromDisk();
2813
+ if (workspaceIO && (!scopeMatches(submittedPath, submittedProfile) || !loadedScopeMatches(path))) return false;
2158
2814
  if (transition && !transition.isCurrent()) return false;
2159
2815
  const nowMs = getNowMs();
2160
2816
  reservations = reservations.filter(
@@ -2172,6 +2828,47 @@ export function createTelegramTopicTargetStore(
2172
2828
  const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
2173
2829
  if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner)) return false;
2174
2830
  }
2831
+ if (transition?.kind === "relocate") {
2832
+ const { binding, owner, nextTarget } = transition;
2833
+ const slots = captureExternalReservedSlots();
2834
+ const sourceBindings = Array.from(workspaceBindings.values())
2835
+ .filter((value) => targetMatches(value.target, binding.target));
2836
+ const sourceOwners = currentRecords.filter((value) => targetMatches(value.target, binding.target));
2837
+ if ((workspaceRestore?.operations ?? []).some((operation) =>
2838
+ workspaceRestoresConflict(operation.request, transition.restore.operations.at(-1)!.request)) ||
2839
+ !binding.sessionId || !binding.slot || !/^[A-Z]$/.test(binding.slot) ||
2840
+ binding.inactiveSinceMs !== undefined || owner.status !== "active" ||
2841
+ !owner.instanceId || owner.slot !== binding.slot ||
2842
+ !["leader", "manual-follower"].includes(owner.owner?.kind ?? "") ||
2843
+ (owner.owner?.kind === "leader" &&
2844
+ (owner.owner.cwd !== binding.cwd || owner.owner.instanceId !== owner.instanceId)) ||
2845
+ !targetMatches(owner.target, binding.target) ||
2846
+ sourceBindings.length !== 1 || !isDeepStrictEqual(sourceBindings[0], binding) ||
2847
+ sourceOwners.length !== 1 || !isDeepStrictEqual(sourceOwners[0], owner) ||
2848
+ !slots || slots.includes(binding.slot) || workspaceRetirementCommitInFlight ||
2849
+ hasWorkspaceRetirementConflict(binding) || hasWorkspaceRetirementConflict({ target: nextTarget }) ||
2850
+ Array.from(workspaceBindings.values()).some((value) =>
2851
+ value.bindingKey !== binding.bindingKey &&
2852
+ (value.slot === binding.slot || targetMatches(value.target, nextTarget))) ||
2853
+ currentRecords.some((value) => targetMatches(value.target, nextTarget) ||
2854
+ (!targetMatches(value.target, binding.target) && value.slot === binding.slot)) ||
2855
+ Array.from(workspaceClaims.values()).some((value) =>
2856
+ value.identity.bindingKey === binding.bindingKey || value.identity.slot === binding.slot) ||
2857
+ reservations.some((value) => value.slot === binding.slot ||
2858
+ targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)) ||
2859
+ pendingProvisions.some((value) => value.workspaceBindingKey === binding.bindingKey ||
2860
+ value.instanceId === owner.instanceId || value.profileKey === owner.profileKey ||
2861
+ value.slot === binding.slot || (value.target &&
2862
+ (targetMatches(value.target, binding.target) || targetMatches(value.target, nextTarget)))) ||
2863
+ pendingCleanups.some((value) => targetMatches(value.target, binding.target) ||
2864
+ targetMatches(value.target, nextTarget)) ||
2865
+ syncObservations.some((value) => targetMatches(value.target, nextTarget) &&
2866
+ (value.syncStatus === "deleted" || value.syncStatus === "closed")) ||
2867
+ (sessionReplacement && ((sessionReplacement.cwd === binding.cwd &&
2868
+ sessionReplacement.sourceSessionId === binding.sessionId) ||
2869
+ targetMatches(sessionReplacement.target, binding.target) ||
2870
+ targetMatches(sessionReplacement.target, nextTarget)))) return false;
2871
+ }
2175
2872
  records = new Map(
2176
2873
  currentRecords.map((record) => [
2177
2874
  getRecordOwnerKey(record),
@@ -2179,13 +2876,23 @@ export function createTelegramTopicTargetStore(
2179
2876
  ]),
2180
2877
  );
2181
2878
  const persistedRevision = mutationRevision;
2182
- const persistedStatusRevision = statusRevision;
2879
+ const expectedRestore = structuredClone(workspaceRestore);
2880
+ const assertRestoreEvidenceCurrent = (raw?: unknown, fromTransaction = false): void => {
2881
+ if (isExactJournalFrame?.() === false) throw new Error("Telegram Workspace journal evidence publication lost its exact frame or authority.");
2882
+ const disk = parseTopicTargetFile(fromTransaction ? raw : readStoreSnapshot(path, transition?.kind === "relocate" || !!expectedRestore));
2883
+ if (!isDeepStrictEqual(disk.workspaceRestore, expectedRestore)) throw new Error("Workspace Restore evidence changed before publication.");
2884
+ assertWorkspaceRestoreBindingProtection(disk);
2885
+ assertRestoreRecoveryProtection(path, [...(disk.workspaceRestore?.operations ?? []), ...(file.workspaceRestore?.operations ?? [])],
2886
+ [...(disk.pendingProvisions ?? []), ...(file.pendingProvisions ?? [])]);
2887
+ const additions = (file.workspaceRestore?.operations ?? []).filter(operation =>
2888
+ !disk.workspaceRestore?.operations.some(retained => retained.request.operationId === operation.request.operationId));
2889
+ assertRestoreRecoveryProtection(path, additions, disk.pendingProvisions ?? [], true);
2890
+ };
2183
2891
  const file = {
2184
2892
  version: 1,
2185
2893
  source: "snapshot",
2186
2894
  writtenAtMs: nowMs,
2187
2895
  bot: botState,
2188
- ...statusSnapshot,
2189
2896
  identities: Array.from(identities.values()).map(cloneIdentityRecord),
2190
2897
  workspaceBindings: Array.from(workspaceBindings.values()).map(
2191
2898
  cloneWorkspaceBinding,
@@ -2193,6 +2900,7 @@ export function createTelegramTopicTargetStore(
2193
2900
  workspaceRetirements: workspaceRetirements.map(
2194
2901
  cloneWorkspaceRetirementIntent,
2195
2902
  ),
2903
+ ...(workspaceRestore ? { workspaceRestore: structuredClone(workspaceRestore) } : {}),
2196
2904
  ...(sessionReplacement
2197
2905
  ? { sessionReplacement: cloneSessionReplacementIntent(sessionReplacement) }
2198
2906
  : {}),
@@ -2222,32 +2930,98 @@ export function createTelegramTopicTargetStore(
2222
2930
  if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
2223
2931
  bindings[0]!.slot !== record.slot) return false;
2224
2932
  }
2225
- file.threads = file.threads.filter((candidate) => candidate !== record);
2226
- for (const binding of file.workspaceBindings) {
2227
- if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
2228
- binding.inactiveSinceMs = nowMs;
2933
+ if (transition.kind === "relocate") {
2934
+ if (transition.restore.revision !== (workspaceRestore?.revision ?? 0) + 1 ||
2935
+ !isDeepStrictEqual(transition.restore.operations.slice(0, -1), workspaceRestore?.operations ?? [])) return false;
2936
+ file.workspaceRestore = structuredClone(transition.restore);
2937
+ parseWorkspaceRestore(file.workspaceRestore);
2938
+ if (Buffer.byteLength(JSON.stringify(file.workspaceRestore)) > transition.maxBytes)
2939
+ throw new Error("Workspace Restore byte capacity reached.");
2940
+ const binding = file.workspaceBindings.find((value) =>
2941
+ value.bindingKey === transition.binding.bindingKey)!;
2942
+ binding.target = { ...transition.nextTarget };
2943
+ binding.updatedAtMs = nowMs;
2944
+ delete binding.displayTitle;
2945
+ record.target = { ...transition.nextTarget };
2946
+ record.updatedAtMs = nowMs;
2947
+ record.rerouteConfirmedAtMs = nowMs;
2948
+ record.syncStatus = "unknown";
2949
+ record.lastReconcileAction = "workspace-restore";
2950
+ delete record.lastError;
2951
+ delete record.lastSyncError;
2952
+ delete record.lastSyncObservedAtMs;
2953
+ delete record.lastSyncProbeAtMs;
2954
+ } else {
2955
+ file.threads = file.threads.filter((candidate) => candidate !== record);
2956
+ for (const binding of file.workspaceBindings) {
2957
+ if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
2958
+ binding.inactiveSinceMs = nowMs;
2959
+ }
2960
+ }
2961
+ if (transition.kind === "invalidate") {
2962
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2963
+ file.syncObservations.push({
2964
+ target: { ...record.target },
2965
+ syncStatus: "deleted",
2966
+ observedAtMs: nowMs,
2967
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2968
+ ...(record.slot ? { slot: record.slot } : {}),
2969
+ lastSyncError: transition.lastSyncError,
2970
+ lastReconcileAction: "mark-stale",
2971
+ });
2229
2972
  }
2230
2973
  }
2231
- if (transition.kind === "invalidate") {
2232
- file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2233
- file.syncObservations.push({
2234
- target: { ...record.target },
2235
- syncStatus: "deleted",
2236
- observedAtMs: nowMs,
2237
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2238
- ...(record.slot ? { slot: record.slot } : {}),
2239
- lastSyncError: transition.lastSyncError,
2240
- lastReconcileAction: "mark-stale",
2241
- });
2974
+ }
2975
+ assertWorkspaceRestoreBindingProtection(file);
2976
+ assertRestoreRecoveryProtection(path, file.workspaceRestore?.operations ?? [], file.pendingProvisions ?? []);
2977
+ if (publishWorkspace) {
2978
+ const wire = JSON.parse(JSON.stringify(file));
2979
+ const outcome = publishWorkspace(current => {
2980
+ if (!scopeMatches(submittedPath, submittedProfile) || mutationRevision !== persistedRevision ||
2981
+ transition?.isCurrent() === false || isExactJournalFrame?.() === false) return { value: current, result: false };
2982
+ const semanticCurrent = getTelegramStateSemanticSnapshot(current);
2983
+ if (!isDeepStrictEqual(semanticCurrent, observedWorkspaceSemantic))
2984
+ throw new Error("Workspace canonical snapshot changed before publication.");
2985
+ // A late whole-Workspace replacement cannot erase a concurrent canonical mutation.
2986
+ if (workspaceRestore && !isDeepStrictEqual(parseTopicTargetFile(current).workspaceRestore, expectedRestore))
2987
+ throw new Error("Workspace Restore evidence changed before publication.");
2988
+ assertRestoreEvidenceCurrent(current, true);
2989
+ if (transition?.kind === "relocate") {
2990
+ const slots = captureExternalReservedSlots();
2991
+ if (!slots || slots.includes(transition.binding.slot!) || workspaceRetirementCommitInFlight ||
2992
+ Array.from(workspaceClaims.values()).some(value => value.identity.bindingKey === transition.binding.bindingKey || value.identity.slot === transition.binding.slot))
2993
+ return { value: current, result: false };
2994
+ }
2995
+ const unchanged = !transition && isDeepStrictEqual(semanticCurrent, getTelegramStateSemanticSnapshot(wire));
2996
+ return { value: unchanged ? current : wire, result: true };
2997
+ }, { isCurrent: () => scopeMatches(submittedPath, submittedProfile) && mutationRevision === persistedRevision &&
2998
+ (transition?.isCurrent() ?? true) && (isExactJournalFrame?.() ?? true),
2999
+ onPublicationBoundary: transition?.kind === "relocate" ? boundary => {
3000
+ if (boundary !== "before-write") transition.onPublicationBoundary?.(boundary);
3001
+ } : undefined });
3002
+ if (!outcome.committed || !outcome.result) return false;
3003
+ loadedPath = path; loadedProfile = submittedProfile; loaded = true;
3004
+ observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(wire);
3005
+ if (transition) {
3006
+ records = new Map(file.threads.map(record => {
3007
+ const normalized = normalizeRecord(record)!;
3008
+ return [getRecordOwnerKey(normalized), cloneRecord(normalized)];
3009
+ }));
3010
+ workspaceRestore = structuredClone(file.workspaceRestore);
3011
+ syncObservations = file.syncObservations;
3012
+ workspaceBindings = new Map(file.workspaceBindings.map(binding => [getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding)]));
3013
+ mutationRevision += 1;
2242
3014
  }
3015
+ dirty = false;
3016
+ return true;
2243
3017
  }
2244
3018
  let persistedSemanticSnapshot: Record<string, unknown> | undefined;
2245
3019
  try {
2246
3020
  persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(
2247
- JSON.parse(await readFile(path, "utf8")),
3021
+ await readStoreSnapshot(path, !!workspaceRestore, true),
2248
3022
  );
2249
3023
  } catch {
2250
- /* missing or invalid snapshots must be replaced */
3024
+ /* Final Restore validation fences missing or unverifiable snapshots before publication. */
2251
3025
  }
2252
3026
  // Normalize optional fields to wire JSON; object key order is not a state change.
2253
3027
  if (
@@ -2255,32 +3029,49 @@ export function createTelegramTopicTargetStore(
2255
3029
  persistedSemanticSnapshot,
2256
3030
  getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
2257
3031
  ) &&
2258
- mutationRevision === persistedRevision &&
2259
- statusRevision === persistedStatusRevision
3032
+ mutationRevision === persistedRevision
2260
3033
  ) {
2261
3034
  loadedPath = path;
2262
3035
  loaded = true;
2263
3036
  dirty = false;
2264
3037
  return true;
2265
3038
  }
3039
+ const serialized = `${JSON.stringify(file, null, 2)}\n`;
3040
+ if (file.workspaceRestore && Buffer.byteLength(serialized) > WORKSPACE_SNAPSHOT_MAX_BYTES)
3041
+ throw new Error("Workspace snapshot byte capacity reached.");
2266
3042
  await mkdir(dirname(path), { recursive: true });
2267
3043
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
2268
- await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
2269
- encoding: "utf8",
2270
- mode: 0o600,
2271
- });
3044
+ await writeFile(tempPath, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 });
2272
3045
  await chmod(tempPath, 0o600);
2273
3046
  try {
2274
3047
  if (transition) {
3048
+ if (transition.kind === "relocate") transition.onPublicationBoundary?.("after-write-before-rename");
2275
3049
  let applied = false;
2276
3050
  const commit = () => {
2277
- if (getPath() !== path || mutationRevision !== persistedRevision ||
2278
- statusRevision !== persistedStatusRevision || !transition.isCurrent()) return;
2279
- // Fence and rename share one synchronous commit boundary. No stale
2280
- // transition enters the live projection before durable commit.
3051
+ if (!transition.isCurrent()) return;
3052
+ if (transition.kind === "relocate") {
3053
+ // Transient claims and external fences do not advance snapshot revisions.
3054
+ const slots = captureExternalReservedSlots();
3055
+ if (!slots || slots.includes(transition.binding.slot!) || workspaceRetirementCommitInFlight ||
3056
+ Array.from(workspaceClaims.values()).some((value) =>
3057
+ value.identity.bindingKey === transition.binding.bindingKey ||
3058
+ value.identity.slot === transition.binding.slot)) return;
3059
+ }
3060
+ if (getPath() !== path || mutationRevision !== persistedRevision) return;
3061
+ // All publishers fence the same retained operation, including after its terminal removal.
3062
+ assertRestoreEvidenceCurrent();
3063
+ if (!transition.isCurrent() || getPath() !== path || mutationRevision !== persistedRevision) return;
2281
3064
  renameSync(tempPath, path);
2282
3065
  loadedPath = path;
2283
- records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
3066
+ if (transition.kind === "relocate") {
3067
+ const relocated = file.threads.find((record) => targetMatches(record.target, transition.nextTarget))!;
3068
+ records.set(getRecordOwnerKey(transition.owner), {
3069
+ ...relocated, profileKey: transition.owner.profileKey,
3070
+ });
3071
+ } else {
3072
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
3073
+ }
3074
+ workspaceRestore = structuredClone(file.workspaceRestore);
2284
3075
  syncObservations = file.syncObservations;
2285
3076
  workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
2286
3077
  getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
@@ -2288,17 +3079,21 @@ export function createTelegramTopicTargetStore(
2288
3079
  mutationRevision += 1;
2289
3080
  dirty = false;
2290
3081
  applied = true;
3082
+ if (transition.kind === "relocate") transition.onPublicationBoundary?.("after-rename");
2291
3083
  };
2292
- if (options.commitPersist) options.commitPersist(commit);
2293
- else if (!options.canPersist || options.canPersist()) commit();
3084
+ withTelegramFileTransaction(`${path}.transaction`, () => {
3085
+ if (commitPersist) commitPersist(commit);
3086
+ else if (!options.canPersist || options.canPersist()) commit();
3087
+ });
2294
3088
  if (!applied) await unlink(tempPath).catch(() => undefined);
2295
3089
  return applied;
2296
3090
  }
2297
- if (options.commitPersist) {
2298
- const committed = options.commitPersist(() => {
3091
+ if (commitPersist) {
3092
+ const committed = withTelegramFileTransaction(`${path}.transaction`, () => commitPersist(() => {
3093
+ assertRestoreEvidenceCurrent();
2299
3094
  renameSync(tempPath, path);
2300
3095
  chmodSync(path, 0o600);
2301
- });
3096
+ }));
2302
3097
  if (!committed) {
2303
3098
  await loadFromDisk();
2304
3099
  throw new Error(
@@ -2306,8 +3101,11 @@ export function createTelegramTopicTargetStore(
2306
3101
  );
2307
3102
  }
2308
3103
  } else {
2309
- await rename(tempPath, path);
2310
- await chmod(path, 0o600);
3104
+ withTelegramFileTransaction(`${path}.transaction`, () => {
3105
+ assertRestoreEvidenceCurrent();
3106
+ renameSync(tempPath, path);
3107
+ chmodSync(path, 0o600);
3108
+ });
2311
3109
  }
2312
3110
  } catch (error) {
2313
3111
  await unlink(tempPath).catch(() => undefined);
@@ -2324,6 +3122,7 @@ export function createTelegramTopicTargetStore(
2324
3122
 
2325
3123
  return {
2326
3124
  async load() {
3125
+ if (workspaceIO && dirty && !loadedScopeMatches(getPath())) throw new Error("Workspace projection must refresh after scope change.");
2327
3126
  if (dirty) return;
2328
3127
  await loadFromDisk();
2329
3128
  },
@@ -2333,7 +3132,8 @@ export function createTelegramTopicTargetStore(
2333
3132
  return refresh;
2334
3133
  },
2335
3134
  async persist() {
2336
- await persistSnapshot();
3135
+ const committed = await persistSnapshot();
3136
+ if (workspaceIO && !committed) throw new Error("Workspace snapshot lost captured publication authority.");
2337
3137
  },
2338
3138
  invalidateTarget(target, isCurrent, lastSyncError) {
2339
3139
  return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
@@ -2343,6 +3143,598 @@ export function createTelegramTopicTargetStore(
2343
3143
  if (!owner) return Promise.resolve(false);
2344
3144
  return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
2345
3145
  },
3146
+ assertWorkspaceRestoreRegistration(candidate) {
3147
+ withRestoreRegistration(candidate);
3148
+ },
3149
+ commitWorkspaceRestoreRegistration: withRestoreRegistration,
3150
+ listTemporaryThreadTargets() {
3151
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
3152
+ return (file.workspaceRestore?.temporaryThreads ?? []).flatMap(entry => entry.target ? [{ ...entry.target }] : []);
3153
+ },
3154
+ withWorkspaceRestoreSnapshot(expected, observe) {
3155
+ const path = getPath();
3156
+ withStoreTransaction(path, () => {
3157
+ const file = parseTopicTargetFile(readStoreSnapshot(path, false));
3158
+ observeWorkspaceRestore(file.workspaceRestore);
3159
+ if (getPath() !== path || !isDeepStrictEqual(file.workspaceRestore?.operations.find(
3160
+ intent => intent.request.operationId === expected.request.operationId), expected))
3161
+ throw new Error("Workspace Restore observation changed.");
3162
+ assertWorkspaceRestoreBindingProtection(file);
3163
+ assertRestoreRecoveryProtection(path, file.workspaceRestore!.operations, file.pendingProvisions ?? []);
3164
+ observe({ threads: file.threads, workspaceBindings: file.workspaceBindings });
3165
+ });
3166
+ },
3167
+ workspaceRestore(storageOptions) {
3168
+ const path = getPath(), profile = activeProfile();
3169
+ const now = storageOptions.getNowMs ?? Date.now;
3170
+ const { profileName, tokenSha256, isCurrentScope, legacyPath, onPublicationBoundary } = storageOptions;
3171
+ if (consolidated && profileName !== profile) throw new Error("Foreign Workspace Restore storage scope.");
3172
+ const maxBytes = Math.min(storageOptions.maxBytes ?? WORKSPACE_RESTORE_MAX_BYTES, WORKSPACE_RESTORE_MAX_BYTES);
3173
+ if (!restoreText(profileName) || !/^[a-f0-9]{64}$/u.test(tokenSha256) || !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
3174
+ throw new Error("Invalid Workspace Restore storage scope.");
3175
+ const empty: TelegramWorkspaceRestoreSnapshot = { version: 1, profileName, tokenSha256, revision: 0, operations: [] };
3176
+ let observed: TelegramWorkspaceRestoreSnapshot | undefined;
3177
+ const currentScope = () => getPath() === path && (!consolidated || scopeMatches(path, profile)) && (isCurrentScope?.() ?? true) && (options.canPersist?.() ?? true);
3178
+ const read = (): { file: Record<string, unknown>; state: TelegramWorkspaceRestoreSnapshot } => {
3179
+ if (legacyPath) {
3180
+ try { lstatSync(legacyPath); throw new Error("Unmigrated Workspace Restore file."); }
3181
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
3182
+ }
3183
+ const raw = readStoreSnapshot(path);
3184
+ if (raw !== undefined && (!restoreObject(raw) || raw.version !== 1)) throw new Error("Invalid Workspace snapshot.");
3185
+ const file = (raw ?? (consolidated ? { version: 1, source: "snapshot", writtenAtMs: 0, bot: { threadMode: "unknown" }, threads: [] }
3186
+ : { version: 1, records: [] })) as Record<string, unknown>;
3187
+ const state = parseTopicTargetFile(file).workspaceRestore;
3188
+ if (state && (state.profileName !== profileName || state.tokenSha256 !== tokenSha256))
3189
+ throw new Error("Invalid or foreign Workspace Restore evidence.");
3190
+ if (observed && (!state || state.revision < observed.revision ||
3191
+ (state.revision === observed.revision && !isDeepStrictEqual(state, observed))))
3192
+ throw new Error("Workspace Restore evidence moved backwards or changed without revision.");
3193
+ if (state && Buffer.byteLength(JSON.stringify(state)) > maxBytes) throw new Error("Workspace Restore byte capacity reached.");
3194
+ observed = structuredClone(state);
3195
+ if (loadedScopeMatches(path)) observeWorkspaceRestore(state);
3196
+ return { file, state: structuredClone(state ?? empty) };
3197
+ };
3198
+ const inspectTemporaryOwnership = (before: ReturnType<typeof read>, entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadTargetObservation => {
3199
+ if (entry.phase !== "created" || !entry.target) return { kind: "unknown" };
3200
+ const file = parseTopicTargetFile(before.file);
3201
+ // Normalization omissions never prove an unbound target.
3202
+ for (const key of ["threads", "workspaceBindings", "reservations", "pendingProvisions", "pendingCleanups", "workspaceRetirements"] as const) {
3203
+ const raw = before.file[key];
3204
+ if ((key === "threads" && raw === undefined) || (raw !== undefined &&
3205
+ (!Array.isArray(raw) || raw.length !== (file[key]?.length ?? 0))))
3206
+ throw new Error("Invalid temporary Thread target evidence.");
3207
+ }
3208
+ assertWorkspaceRestoreBindingProtection(file);
3209
+ assertRestoreRecoveryProtection(path, before.state.operations, file.pendingProvisions ?? [], true);
3210
+ const target = entry.target;
3211
+ const bindings = (file.workspaceBindings ?? []).filter(value => targetMatches(value.target, target));
3212
+ if (bindings.length === 1) return { kind: "bound", binding: structuredClone(bindings[0]!) };
3213
+ if (bindings.length > 1 || file.threads.some(value => targetMatches(value.target, target)) ||
3214
+ file.reservations?.some(value => targetMatches(value.target, target)) ||
3215
+ file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, target)) ||
3216
+ file.pendingCleanups?.some(value => targetMatches(value.target, target)) ||
3217
+ file.workspaceRetirements?.some(value => targetMatches(value.binding.target, target)) ||
3218
+ before.state.operations.some(value => targetMatches(value.request.target, target))) return { kind: "unknown" };
3219
+ return { kind: "temporary" };
3220
+ };
3221
+ const classifyTemporaryTarget = (before: ReturnType<typeof read>, entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadTargetObservation =>
3222
+ entry.cleanupIssued ? { kind: "unknown" } : inspectTemporaryOwnership(before, entry);
3223
+ const storage = {
3224
+ read: () => read().state,
3225
+ async relocate(input: TelegramWorkspaceRestoreIntent, isCurrent: () => boolean) {
3226
+ if (!currentScope() || !isCurrent()) return false;
3227
+ const intent = structuredClone(input);
3228
+ if (!currentScope() || !isCurrent() || !isWorkspaceRestoreIntent(intent) || intent.phase !== "relocated" || intent.revision !== 0 ||
3229
+ intent.recipient !== undefined || intent.routing !== undefined) return false;
3230
+ const state = read().state;
3231
+ if (state.operations.some(operation => workspaceRestoresConflict(operation.request, intent.request))) return false;
3232
+ if (state.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
3233
+ if (state.operations.length >= 26) throw new Error("Workspace Restore operation capacity reached.");
3234
+ const restore = { ...state, revision: state.revision + 1, operations: [...state.operations, intent] };
3235
+ const request = intent.request;
3236
+ return persistSnapshot({ kind: "relocate", operationId: request.operationId, binding: request.binding, owner: request.owner,
3237
+ target: { ...request.binding.target }, nextTarget: { ...request.target }, restore, maxBytes, onPublicationBoundary,
3238
+ isCurrent: () => currentScope() && isCurrent() });
3239
+ },
3240
+ update(expectedInput: TelegramWorkspaceRestoreSnapshot, nextInput: TelegramWorkspaceRestoreSnapshot, isCurrent: () => boolean) {
3241
+ const expected = structuredClone(expectedInput), next = structuredClone(nextInput);
3242
+ if (!currentScope() || !isCurrent()) return false;
3243
+ if (expected.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
3244
+ if (next.profileName !== profileName || next.tokenSha256 !== tokenSha256 || next.revision !== expected.revision + 1 || !parseWorkspaceRestore(next))
3245
+ throw new Error("Invalid Workspace Restore publication.");
3246
+ if (Buffer.byteLength(JSON.stringify(next)) > maxBytes) throw new Error("Workspace Restore byte capacity reached.");
3247
+ if (!workspaceIO) mkdirSync(dirname(path), { recursive: true });
3248
+ let committedBefore: ReturnType<typeof read> | undefined;
3249
+ let committedAfter: Record<string, unknown> | undefined;
3250
+ const committed = withStoreTransaction(path, () => {
3251
+ const before = read();
3252
+ if (!isDeepStrictEqual(before.state, expected) || !currentScope() || !isCurrent()) return false;
3253
+ for (const entry of next.temporaryThreads ?? []) {
3254
+ const prior = expected.temporaryThreads?.find(value => value.token === entry.token);
3255
+ if (!prior && entry.phase === "created" && classifyTemporaryTarget(before, entry).kind !== "temporary") return false;
3256
+ if (!entry.cleanupIssued) continue;
3257
+ if (!prior?.cleanupIssued && (!prior || classifyTemporaryTarget(before, prior).kind !== "temporary")) return false;
3258
+ }
3259
+ assertWorkspaceRestoreBindingProtection(before.file);
3260
+ const provisions = parseTopicTargetFile(before.file).pendingProvisions ?? [];
3261
+ assertRestoreRecoveryProtection(path, before.state.operations, provisions);
3262
+ const after = { ...before.file, workspaceRestore: next };
3263
+ // New temporary targets must be checked against the file they will be published into.
3264
+ assertWorkspaceRestoreBindingProtection(parseTopicTargetFile(after));
3265
+ const content = JSON.stringify(after);
3266
+ if (Buffer.byteLength(content) > WORKSPACE_SNAPSHOT_MAX_BYTES) throw new Error("Workspace snapshot byte capacity reached.");
3267
+ if (workspaceIO) {
3268
+ if (!workspaceFrame || !currentScope() || !isCurrent()) return false;
3269
+ assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
3270
+ committedBefore = before;
3271
+ committedAfter = JSON.parse(content);
3272
+ workspaceFrame.value = committedAfter;
3273
+ return true;
3274
+ }
3275
+ const temporary = `${path}.${randomUUID()}.tmp`;
3276
+ try {
3277
+ writeFileSync(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
3278
+ onPublicationBoundary?.("after-write-before-rename");
3279
+ let published = false;
3280
+ const publish = (): void => {
3281
+ if (!currentScope() || !isCurrent()) return;
3282
+ if (!isDeepStrictEqual(read().file, before.file)) throw new Error("Workspace Restore evidence changed before publication.");
3283
+ assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
3284
+ if (!currentScope() || !isCurrent()) return;
3285
+ renameSync(temporary, path);
3286
+ observed = structuredClone(next);
3287
+ // A read-only Restore view must never bless a stale binding/owner projection.
3288
+ if (loadedPath === path && isDeepStrictEqual(workspaceRestore, before.file.workspaceRestore)) workspaceRestore = structuredClone(next);
3289
+ mutationRevision += 1;
3290
+ published = true;
3291
+ onPublicationBoundary?.("after-rename");
3292
+ };
3293
+ const owned = commitPersist ? commitPersist(publish) : (publish(), true);
3294
+ return owned && published;
3295
+ } finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
3296
+ }, { isCurrent: () => currentScope() && isCurrent(), onPublicationBoundary: boundary => {
3297
+ if (boundary !== "before-write") onPublicationBoundary?.(boundary);
3298
+ } });
3299
+ if (workspaceIO && committed && committedBefore && committedAfter) {
3300
+ observed = structuredClone(next);
3301
+ if (loadedScopeMatches(path) && isDeepStrictEqual(workspaceRestore, committedBefore.file.workspaceRestore)) workspaceRestore = structuredClone(next);
3302
+ if (isDeepStrictEqual(observedWorkspaceSemantic, getTelegramStateSemanticSnapshot(committedBefore.file)))
3303
+ observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(committedAfter);
3304
+ mutationRevision += 1;
3305
+ }
3306
+ return committed;
3307
+ },
3308
+ };
3309
+ const mutate = <T extends TelegramWorkspaceRestoreIntent | TelegramTemporaryThreadEntry>(authority: TelegramWorkspaceRestoreAuthority,
3310
+ change: (file: TelegramWorkspaceRestoreSnapshot, executor: TelegramWorkspaceRestoreExecutor, operatorUserId: number) => T | undefined,
3311
+ ): T | undefined => {
3312
+ if (!authority.isCurrent()) return undefined;
3313
+ const executor = structuredClone(authority.executor);
3314
+ const operator = authority.operatorUserId;
3315
+ if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator)) return undefined;
3316
+ const current = (): boolean => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
3317
+ const before = storage.read();
3318
+ const file = structuredClone(before);
3319
+ const operation = change(file, executor, operator);
3320
+ if (!operation || !current()) return undefined;
3321
+ if (isDeepStrictEqual(file, before)) return structuredClone(operation);
3322
+ if (file.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
3323
+ file.revision += 1;
3324
+ return storage.update(before, file, current) ? structuredClone(operation) : undefined;
3325
+ };
3326
+ const advance = (expected: TelegramWorkspaceRestoreIntent, authority: TelegramWorkspaceRestoreAuthority,
3327
+ change: (operation: TelegramWorkspaceRestoreIntent) => boolean | "unchanged", adopt = false): TelegramWorkspaceRestoreIntent | undefined =>
3328
+ mutate(authority, (file, executor, operator) => {
3329
+ const operation = file.operations.find(candidate => candidate.request.operationId === expected.request.operationId);
3330
+ if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
3331
+ (!adopt && !isDeepStrictEqual(operation.executor, executor))) return undefined;
3332
+ const changed = change(operation);
3333
+ if (!changed) return undefined;
3334
+ if (changed === "unchanged") return operation;
3335
+ if (operation.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore operation revision exhausted.");
3336
+ const at = now();
3337
+ if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
3338
+ operation.executor = executor;
3339
+ operation.revision += 1;
3340
+ operation.updatedAtMs = Math.max(at, operation.updatedAtMs);
3341
+ return operation;
3342
+ });
3343
+ const advanceTemporary = (expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority,
3344
+ change: (entry: TelegramTemporaryThreadEntry, file: TelegramWorkspaceRestoreSnapshot, executor: TelegramWorkspaceRestoreExecutor) => boolean | "unchanged",
3345
+ adopt = false): TelegramTemporaryThreadEntry | undefined =>
3346
+ mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
3347
+ const entry = file.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
3348
+ if (!entry || entry.operatorUserId !== operator || (!adopt && !isDeepStrictEqual(entry.executor, executor))) return undefined;
3349
+ const changed = change(entry, file, executor);
3350
+ if (!changed) return undefined;
3351
+ if (changed === "unchanged") return entry;
3352
+ if (entry.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore operation revision exhausted.");
3353
+ const at = now();
3354
+ if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
3355
+ entry.executor = executor;
3356
+ entry.revision += 1;
3357
+ entry.updatedAtMs = Math.max(at, entry.updatedAtMs);
3358
+ return entry;
3359
+ });
3360
+ return {
3361
+ list: () => structuredClone(storage.read().operations),
3362
+ inspectTemporaryThreadTarget(expected, authority) {
3363
+ const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
3364
+ const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
3365
+ isDeepStrictEqual(authority.executor, executor);
3366
+ if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current()) return undefined;
3367
+ return withStoreTransaction(path, () => {
3368
+ if (!current()) return undefined;
3369
+ const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
3370
+ if (!entry || entry.operatorUserId !== operator || !current()) return undefined;
3371
+ const result = classifyTemporaryTarget(before, entry);
3372
+ return current() ? result : undefined;
3373
+ });
3374
+ },
3375
+ isTemporaryThreadCleanupCurrent(expected, authority) {
3376
+ const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
3377
+ const current = () => currentScope() && authority.isCurrent() && authority.operatorUserId === operator &&
3378
+ isDeepStrictEqual(authority.executor, executor);
3379
+ if (!expected.cleanupIssued || expected.operatorUserId !== operator || !isDeepStrictEqual(expected.executor, executor) ||
3380
+ !isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator) || !current()) return false;
3381
+ return withStoreTransaction(path, () => {
3382
+ if (!current()) return false;
3383
+ const before = read(), entry = before.state.temporaryThreads?.find(value => isDeepStrictEqual(value, expected));
3384
+ return !!entry && inspectTemporaryOwnership(before, entry).kind === "temporary" && current();
3385
+ });
3386
+ },
3387
+ /** The first durable state already includes both relocation and exact original references. */
3388
+ async commit(requestInput, authority) {
3389
+ if (!authority.isCurrent()) return undefined;
3390
+ const request = structuredClone(requestInput), executor = structuredClone(authority.executor), operatorUserId = authority.operatorUserId;
3391
+ const current = () => authority.isCurrent() && authority.operatorUserId === operatorUserId && isDeepStrictEqual(authority.executor, executor);
3392
+ if (!current() || !isTelegramWorkspaceRestoreRequest(request) || !isTelegramWorkspaceRestoreExecutor(executor) ||
3393
+ !restoreInteger(operatorUserId) || request.target.chatId !== operatorUserId) return undefined;
3394
+ const retained = (): TelegramWorkspaceRestoreIntent | undefined => {
3395
+ if (!current()) return undefined;
3396
+ const operation = storage.read().operations.find(value => value.request.operationId === request.operationId);
3397
+ return current() && operation?.phase === "relocated" && isDeepStrictEqual(operation.request, request) &&
3398
+ operation.operatorUserId === operatorUserId && isDeepStrictEqual(operation.executor, executor) ? operation : undefined;
3399
+ };
3400
+ const existing = retained();
3401
+ if (existing) return existing;
3402
+ await (dirty ? undefined : loadFromDisk());
3403
+ if (!current()) return undefined;
3404
+ const at = now();
3405
+ if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
3406
+ const intent: TelegramWorkspaceRestoreIntent = { request, executor, operatorUserId, revision: 0,
3407
+ createdAtMs: at, updatedAtMs: at, committedAtMs: at, phase: "relocated" };
3408
+ let failure: { error: unknown } | undefined;
3409
+ try { await storage.relocate(intent, current); } catch (error) { failure = { error }; }
3410
+ if (!current()) return undefined;
3411
+ // A lost rename reply is reconciled from the complete operation, never target equality.
3412
+ const committed = retained();
3413
+ if (!committed && failure) throw failure.error;
3414
+ return committed;
3415
+ },
3416
+ adopt(expected, authority) {
3417
+ return advance(expected, authority, () => !isDeepStrictEqual(expected.executor, authority.executor), true);
3418
+ },
3419
+ /** Caller validates the live recipient under admission. Only this fresh result grants one RPC issuance. */
3420
+ issueRecipient(expected, recipient, authority) {
3421
+ const intent = advance(expected, authority, operation => {
3422
+ if (operation.phase !== "relocated" || !isTelegramWorkspaceRestoreRecipient(recipient) || recipient.sessionId !== operation.request.binding.sessionId) return false;
3423
+ operation.phase = "recipient-issued";
3424
+ operation.recipient = structuredClone(recipient);
3425
+ return true;
3426
+ });
3427
+ return intent ? { issued: true, intent } : undefined;
3428
+ },
3429
+ /** One source-dispatch grant; uncertainty never resets it. Caller retains the original carriers. */
3430
+ issueRouting(expected, authority) {
3431
+ const intent = advance(expected, authority, operation => {
3432
+ if (operation.phase !== "ready" || operation.routing) return false;
3433
+ operation.routing = { settlements: [] };
3434
+ return true;
3435
+ });
3436
+ return intent ? { issued: true, intent } : undefined;
3437
+ },
3438
+ /** Caller owns the positive result and exact source hash; this grants neither source removal nor cleanup. */
3439
+ recordSourceAcceptance(expected, evidenceInput, authority) {
3440
+ if (!authority.isCurrent()) return undefined;
3441
+ const evidence = structuredClone(evidenceInput);
3442
+ return advance(expected, authority, operation => {
3443
+ if (!operation.routing || !isWorkspaceRestoreSourceAcceptance(evidence, operation)) return false;
3444
+ const existing = operation.routing.acceptances?.find(value => value.updateId === evidence.updateId);
3445
+ if (existing) return isDeepStrictEqual(existing, evidence) ? "unchanged" : false;
3446
+ if (operation.routing.cleanup !== undefined ||
3447
+ operation.routing.settlements.some(value => value.updateIds.includes(evidence.updateId)) ||
3448
+ !isDeepStrictEqual(evidence.recipient, operation.readyRecipient ?? operation.recipient)) return false;
3449
+ const acceptances = [...operation.routing.acceptances ?? [], evidence].sort((a, b) => a.updateId - b.updateId);
3450
+ if (!workspaceRestoreAcceptancesConsistent(acceptances)) return false;
3451
+ operation.routing.acceptances = acceptances;
3452
+ return true;
3453
+ });
3454
+ },
3455
+ /** Queue admission is nonterminal; only its exact journal-owner completion ACK may upgrade it. */
3456
+ recordSourceSettlement(expected, evidenceInput, authority) {
3457
+ const evidence = structuredClone(evidenceInput);
3458
+ return advance(expected, authority, operation => {
3459
+ if (!operation.routing || !isTelegramWorkspaceRestoreSettlement(evidence, operation.request.source) ||
3460
+ !workspaceRestoreSettlementMatchesAcceptance(evidence, operation.routing.acceptances ?? [])) return false;
3461
+ const overlaps = operation.routing.settlements.filter(value => value.updateIds.some(id => evidence.updateIds.includes(id)));
3462
+ if (overlaps.some(value => evidence.kind !== "queue-completed" || value.kind !== "queued" ||
3463
+ value.receiptId !== evidence.receiptId || value.queueKind !== evidence.queueKind)) return false;
3464
+ operation.routing.settlements = operation.routing.settlements.flatMap(value => {
3465
+ const updateIds = value.updateIds.filter(id => !evidence.updateIds.includes(id));
3466
+ return updateIds.length ? [{ ...value, updateIds }] : [];
3467
+ });
3468
+ operation.routing.settlements.push(evidence);
3469
+ return true;
3470
+ });
3471
+ },
3472
+ /** Existing cleanup owner retains deletion authority; this merely fences one invocation. */
3473
+ issueCleanup(expected, authority) {
3474
+ const intent = advance(expected, authority, operation => {
3475
+ if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || operation.routing.cleanup !== undefined) return false;
3476
+ operation.routing.cleanup = "issued";
3477
+ return true;
3478
+ });
3479
+ return intent ? { issued: true, intent } : undefined;
3480
+ },
3481
+ recordCleanup(expected, result, authority) {
3482
+ return advance(expected, authority, operation => {
3483
+ if (!operation.routing || !workspaceRestoreSourcesSettled(operation) || !isDeepStrictEqual(result.target, operation.request.binding.target) ||
3484
+ (result.kind === "completed" ? operation.routing.cleanup !== "issued" :
3485
+ result.kind !== "not-issued" || operation.routing.cleanup !== undefined)) return false;
3486
+ operation.routing.cleanup = result.kind;
3487
+ return true;
3488
+ });
3489
+ },
3490
+ /** Exact terminal CAS releases Restore protection only; it cannot settle or cancel recipient queue work. */
3491
+ retire(expected, authority) {
3492
+ return mutate(authority, (file, executor, operator) => {
3493
+ const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
3494
+ const operation = file.operations[index];
3495
+ if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
3496
+ !isDeepStrictEqual(operation.executor, executor) || !workspaceRestoreSourcesSettled(operation) ||
3497
+ !["completed", "not-issued"].includes(operation.routing?.cleanup ?? "")) return undefined;
3498
+ file.operations.splice(index, 1);
3499
+ return operation;
3500
+ });
3501
+ },
3502
+ listTemporaryThreads: () => structuredClone(storage.read().temporaryThreads ?? []),
3503
+ reserveTemporaryThread(sourceInput, token, authority) {
3504
+ const source = structuredClone(sourceInput);
3505
+ let reserved = false;
3506
+ const entry = mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
3507
+ const entries = file.temporaryThreads ?? [];
3508
+ const existing = entries.find(value => value.source.journalBindingKey === source.journalBindingKey &&
3509
+ value.source.updateId === source.updateId);
3510
+ // A retained entry proves an earlier attempt; it is never a license for another creation.
3511
+ if (existing) return existing.operatorUserId === operator ? existing : undefined;
3512
+ const at = now();
3513
+ if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
3514
+ const next: TelegramTemporaryThreadEntry = { source,
3515
+ inputs: [{ journalBindingKey: source.journalBindingKey, updateIds: [source.updateId] }],
3516
+ operatorUserId: operator, executor, token, phase: "creating", forwardProtocol: "one-shot-v1",
3517
+ revision: 0, createdAtMs: at, updatedAtMs: at };
3518
+ if (!isTemporaryThreadEntry(next) || entries.some(value => value.token === token ||
3519
+ getTelegramTemporaryThreadInputs(value).some(input => input.journalBindingKey === source.journalBindingKey &&
3520
+ input.updateIds.includes(source.updateId)))) return undefined;
3521
+ if (entries.length >= TEMPORARY_THREAD_CAPACITY) throw new Error("Temporary Thread capacity reached.");
3522
+ file.temporaryThreads = [...entries, next];
3523
+ reserved = true;
3524
+ return next;
3525
+ });
3526
+ return entry ? { reserved, entry } : undefined;
3527
+ },
3528
+ registerImplicitTemporaryThread(inputValue, targetInput, token, authority) {
3529
+ const input = structuredClone(inputValue), target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
3530
+ if (!isTemporaryThreadInput(input) || input.updateIds.length !== 1 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0) return undefined;
3531
+ return mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
3532
+ const entries = file.temporaryThreads ?? [];
3533
+ if (target.chatId !== operator || entries.some(value => value.token === token ||
3534
+ (value.target && targetMatches(value.target, target)) || getTelegramTemporaryThreadInputs(value).some(other =>
3535
+ other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id))))) return undefined;
3536
+ const at = now();
3537
+ if (!restoreInteger(at)) throw new Error("Invalid Workspace Restore clock.");
3538
+ const entry: TelegramTemporaryThreadEntry = { source: { journalBindingKey: input.journalBindingKey, updateId: input.updateIds[0]! },
3539
+ inputs: [input], target, operatorUserId: operator, executor, token, phase: "created", forwardProtocol: "one-shot-v1",
3540
+ revision: 0, createdAtMs: at, updatedAtMs: at };
3541
+ if (!isTemporaryThreadEntry(entry)) return undefined;
3542
+ if (entries.length >= TEMPORARY_THREAD_CAPACITY) throw new Error("Temporary Thread capacity reached.");
3543
+ file.temporaryThreads = [...entries, entry];
3544
+ return entry;
3545
+ });
3546
+ },
3547
+ acknowledgeTemporaryThread(expected, targetInput, authority) {
3548
+ const target = { chatId: targetInput.chatId, threadId: targetInput.threadId };
3549
+ return advanceTemporary(expected, authority, (entry, file) => {
3550
+ if (entry.phase !== "creating" || target.chatId !== entry.operatorUserId || !Number.isSafeInteger(target.threadId) ||
3551
+ target.threadId <= 0 || file.temporaryThreads!.some(value => !!value.target && targetMatches(value.target, target)) ||
3552
+ file.operations.some(({ request }) => targetMatches(request.target, target) || targetMatches(request.binding.target, target))) return false;
3553
+ entry.phase = "created";
3554
+ entry.target = target;
3555
+ return true;
3556
+ });
3557
+ },
3558
+ adoptTemporaryThread(expected, authority) {
3559
+ return advanceTemporary(expected, authority, (entry, _file, executor) => !isDeepStrictEqual(entry.executor, executor), true);
3560
+ },
3561
+ recordTemporaryThreadInput(expected, inputValue, authority) {
3562
+ const input = structuredClone(inputValue);
3563
+ if (!isTemporaryThreadInput(input)) return undefined;
3564
+ return advanceTemporary(expected, authority, (entry, file) => {
3565
+ if (entry.phase !== "created" || input.journalBindingKey !== entry.source.journalBindingKey ||
3566
+ file.operations.some(({ request }) => targetMatches(request.target, entry.target!))) return false;
3567
+ const inputs = getTelegramTemporaryThreadInputs(entry);
3568
+ if (inputs.some(value => isDeepStrictEqual(value, input))) return "unchanged";
3569
+ if (entry.cleanupIssued || inputs.some(value => value.updateIds.some(id => input.updateIds.includes(id))) ||
3570
+ file.temporaryThreads!.some(value => value !== entry && getTelegramTemporaryThreadInputs(value).some(other =>
3571
+ other.journalBindingKey === input.journalBindingKey && other.updateIds.some(id => input.updateIds.includes(id))))) return false;
3572
+ if (inputs.length >= TEMPORARY_THREAD_INPUT_CAPACITY) throw new Error("Temporary Thread input capacity reached.");
3573
+ entry.inputs = [...inputs, input];
3574
+ return true;
3575
+ });
3576
+ },
3577
+ recordTemporaryThreadInputCancellation(expected, inputValue, authority, inspect) {
3578
+ const input = structuredClone(inputValue);
3579
+ if (!isTemporaryThreadInput(input) || typeof inspect !== "function") return undefined;
3580
+ const operatorUserId = authority.operatorUserId, executor = structuredClone(authority.executor);
3581
+ const owned = (): boolean => authority.isCurrent() && authority.operatorUserId === operatorUserId &&
3582
+ isDeepStrictEqual(authority.executor, executor);
3583
+ // Every publication fence observes retained proof again; neither cached cancellation nor absence suffices.
3584
+ const current = (): boolean => owned() && input.updateIds.every(updateId => {
3585
+ const evidence = inspect(updateId);
3586
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
3587
+ evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
3588
+ }) && owned();
3589
+ const recorded = advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
3590
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
3591
+ file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
3592
+ request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
3593
+ const cancelled = entry.cancelledInputs ?? [];
3594
+ if (cancelled.some(candidate => isDeepStrictEqual(candidate, input))) return "unchanged";
3595
+ if (entry.completedInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
3596
+ entry.forwardedInputs?.some(candidate => isDeepStrictEqual(candidate, input))) return false;
3597
+ entry.cancelledInputs = [...cancelled, input];
3598
+ return true;
3599
+ });
3600
+ return recorded && current() ? recorded : undefined;
3601
+ },
3602
+ recordTemporaryThreadInputExpiry(expected, inputValue, authority, inspect) {
3603
+ const input = structuredClone(inputValue);
3604
+ if (!isTemporaryThreadInput(input) || typeof inspect !== "function") return undefined;
3605
+ const operator = authority.operatorUserId, executor = structuredClone(authority.executor);
3606
+ const bound = classifyTemporaryTarget(read(), expected).kind === "bound";
3607
+ const current = () => authority.isCurrent() && authority.operatorUserId === operator &&
3608
+ isDeepStrictEqual(authority.executor, executor) && input.updateIds.every(updateId => {
3609
+ const evidence = inspect(updateId);
3610
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
3611
+ evidence.operatorAuthorityId === `telegram-owner:${operator}`;
3612
+ }) && authority.isCurrent();
3613
+ return advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
3614
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(value => isDeepStrictEqual(value, input)) ||
3615
+ entry.completedInputs?.some(value => isDeepStrictEqual(value, input))) return false;
3616
+ const cancelled = entry.cancelledInputs ?? [];
3617
+ if (cancelled.some(value => isDeepStrictEqual(value, input))) return "unchanged";
3618
+ const operations = file.operations.filter(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
3619
+ request.source.updateIds.some(id => input.updateIds.includes(id)));
3620
+ if (operations.some(({ request }) => !request.source.updateIds.every(id => input.updateIds.includes(id)))) return false;
3621
+ // Forget only expired donor intent; canonical binding, recipient queue and immutable settlement proofs are untouched.
3622
+ file.operations = file.operations.filter(value => !operations.includes(value));
3623
+ // Once bound, this is no longer a disposable tab. Forget its temporary frame, not the binding or other journal sources.
3624
+ if (bound) {
3625
+ file.temporaryThreads = file.temporaryThreads?.filter(value => value !== entry);
3626
+ if (!file.temporaryThreads?.length) delete file.temporaryThreads;
3627
+ return true;
3628
+ }
3629
+ const forwarded = entry.forwardedInputs?.filter(value => !isDeepStrictEqual(value, input));
3630
+ if (forwarded?.length) entry.forwardedInputs = forwarded;
3631
+ else delete entry.forwardedInputs;
3632
+ entry.cancelledInputs = [...cancelled, input];
3633
+ return true;
3634
+ });
3635
+ },
3636
+ recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
3637
+ const input = structuredClone(inputValue);
3638
+ if (!isTemporaryThreadInput(input)) return undefined;
3639
+ return advanceTemporary(expected, authority, (entry, file) => {
3640
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
3641
+ [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? []), ...(entry.forwardedInputs ?? [])]
3642
+ .some(candidate => isDeepStrictEqual(candidate, input)) ||
3643
+ file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
3644
+ request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
3645
+ entry.forwardedInputs = [...(entry.forwardedInputs ?? []), input];
3646
+ return true;
3647
+ });
3648
+ },
3649
+ recordTemporaryThreadInputCompletion(expected, inputValue, authority) {
3650
+ const input = structuredClone(inputValue);
3651
+ if (!isTemporaryThreadInput(input)) return undefined;
3652
+ return advanceTemporary(expected, authority, (entry, file) => {
3653
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(candidate => isDeepStrictEqual(candidate, input)) ||
3654
+ entry.cancelledInputs?.some(candidate => isDeepStrictEqual(candidate, input)) ||
3655
+ file.operations.some(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
3656
+ request.source.updateIds.some(id => input.updateIds.includes(id)))) return false;
3657
+ const completed = entry.completedInputs ?? [];
3658
+ if (completed.some(candidate => isDeepStrictEqual(candidate, input))) return "unchanged";
3659
+ entry.completedInputs = [...completed, input];
3660
+ return true;
3661
+ });
3662
+ },
3663
+ issueTemporaryThreadCleanup(expected, authority) {
3664
+ const entry = advanceTemporary(expected, authority, value => {
3665
+ if (value.phase !== "created" || value.cleanupIssued || !isTelegramTemporaryThreadFullyResolved(value)) return false;
3666
+ value.cleanupIssued = true;
3667
+ return true;
3668
+ });
3669
+ return entry ? { issued: true, entry } : undefined;
3670
+ },
3671
+ retireTemporaryThread(expected, authority, completed) {
3672
+ return mutate<TelegramTemporaryThreadEntry>(authority, (file, executor, operator) => {
3673
+ const entries = file.temporaryThreads ?? [];
3674
+ const index = entries.findIndex(value => isDeepStrictEqual(value, expected));
3675
+ const entry = entries[index];
3676
+ if (!entry || entry.operatorUserId !== operator || !isDeepStrictEqual(entry.executor, executor) ||
3677
+ (getTelegramTemporaryThreadInputs(entry).length > 1 && !isTelegramTemporaryThreadFullyResolved(entry) &&
3678
+ !isTemporaryThreadReleasedByCompletion(entry, completed))) return undefined;
3679
+ const remaining = entries.filter((_value, position) => position !== index);
3680
+ if (remaining.length) file.temporaryThreads = remaining;
3681
+ else delete file.temporaryThreads;
3682
+ return entry;
3683
+ });
3684
+ },
3685
+ forgetPreviousWorld(authority, preserveTemporaryTokens = []) {
3686
+ if (!authority.isCurrent()) return undefined;
3687
+ const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
3688
+ if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator)) return undefined;
3689
+ const current = (): boolean => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
3690
+ const previous = (value: { operatorUserId: number; executor: TelegramWorkspaceRestoreExecutor }) =>
3691
+ value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
3692
+ const before = storage.read(), file = structuredClone(before);
3693
+ const forgetTemporary = (value: TelegramTemporaryThreadEntry) => previous(value) && !preserveTemporaryTokens.includes(value.token);
3694
+ const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(forgetTemporary);
3695
+ if (!current()) return undefined;
3696
+ if (!operations.length && !temporaryThreads.length) return { operations, temporaryThreads };
3697
+ file.operations = file.operations.filter(value => !previous(value));
3698
+ const remaining = (file.temporaryThreads ?? []).filter(value => !forgetTemporary(value));
3699
+ if (remaining.length) file.temporaryThreads = remaining;
3700
+ else delete file.temporaryThreads;
3701
+ if (file.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
3702
+ file.revision += 1;
3703
+ return storage.update(before, file, current) ? structuredClone({ operations, temporaryThreads }) : undefined;
3704
+ },
3705
+ /** Ends protection without cleanup: the old Thread is kept, and no source was ever dispatched. */
3706
+ retireAbandoned(expected, abandonedUpdateIds, authority) {
3707
+ const abandoned = [...new Set(abandonedUpdateIds)].sort((left, right) => left - right);
3708
+ return mutate(authority, (file, executor, operator) => {
3709
+ const index = file.operations.findIndex(value => value.request.operationId === expected.request.operationId);
3710
+ const operation = file.operations[index];
3711
+ if (!operation || !isDeepStrictEqual(operation, expected) || operation.operatorUserId !== operator ||
3712
+ !isDeepStrictEqual(operation.executor, executor) || operation.phase !== "ready" || operation.routing !== undefined ||
3713
+ !isDeepStrictEqual(abandoned, operation.request.source.updateIds)) return undefined;
3714
+ file.operations.splice(index, 1);
3715
+ return operation;
3716
+ });
3717
+ },
3718
+ /** Caller proves current canonical ownership and authenticates a read-only observation, including successors. */
3719
+ confirmInspectedReady(expected, observed, authority) {
3720
+ return advance(expected, authority, operation => {
3721
+ if ((operation.phase !== "recipient-issued" && operation.phase !== "ready") || !isTelegramWorkspaceRestoreRecipient(observed) ||
3722
+ observed.sessionId !== operation.request.binding.sessionId) return false;
3723
+ operation.phase = "ready";
3724
+ operation.readyRecipient = structuredClone(observed);
3725
+ return true;
3726
+ });
3727
+ },
3728
+ /** Caller authenticates the exact-generation ACK; stored metadata alone is never readiness proof. */
3729
+ confirmReady(expected, acknowledged, authority) {
3730
+ return advance(expected, authority, operation => {
3731
+ if (operation.phase !== "recipient-issued" || !isDeepStrictEqual(operation.recipient, acknowledged)) return false;
3732
+ operation.phase = "ready";
3733
+ return true;
3734
+ });
3735
+ },
3736
+ };
3737
+ },
2346
3738
  list() {
2347
3739
  return Array.from(records.values()).map(cloneRecord);
2348
3740
  },
@@ -2384,6 +3776,10 @@ export function createTelegramTopicTargetStore(
2384
3776
  }));
2385
3777
  },
2386
3778
  reserveThread(reservation) {
3779
+ if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, reservation)) ||
3780
+ conflictsWithTemporaryThread(workspaceRestore, reservation.target)) {
3781
+ throw new Error("Protected Workspace Restore provisioning conflict.");
3782
+ }
2387
3783
  const next = { ...reservation };
2388
3784
  reservations = reservations.filter(
2389
3785
  (existing) =>
@@ -2394,6 +3790,10 @@ export function createTelegramTopicTargetStore(
2394
3790
  markDirty();
2395
3791
  },
2396
3792
  upsertPendingProvision(provision) {
3793
+ if (workspaceRestore?.operations.some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision)) ||
3794
+ conflictsWithTemporaryThread(workspaceRestore, provision.target)) {
3795
+ throw new Error("Protected Workspace Restore provisioning conflict.");
3796
+ }
2397
3797
  const next = {
2398
3798
  ...provision,
2399
3799
  ...(provision.target ? { target: { ...provision.target } } : {}),
@@ -2408,9 +3808,9 @@ export function createTelegramTopicTargetStore(
2408
3808
  const path = getPath();
2409
3809
  const recoveryPath = getRecoveryPath(path);
2410
3810
  await mkdir(dirname(recoveryPath), { recursive: true });
2411
- withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
2412
- const recoveries = readProvisionRecoveries(path);
2413
- recoveries[provision.id] = {
3811
+ withStoreTransaction(path, () => {
3812
+ const recoveries = readProvisionRecoveries(path, true);
3813
+ const recovery = {
2414
3814
  instanceId: provision.instanceId,
2415
3815
  ...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
2416
3816
  ...(provision.leaderEpoch !== undefined
@@ -2418,6 +3818,11 @@ export function createTelegramTopicTargetStore(
2418
3818
  : {}),
2419
3819
  target: { ...target },
2420
3820
  };
3821
+ if (Object.hasOwn(recoveries, provision.id)) {
3822
+ if (!isDeepStrictEqual(recoveries[provision.id], recovery)) throw new Error("Conflicting Workspace provisioning recovery evidence.");
3823
+ return;
3824
+ }
3825
+ recoveries[provision.id] = recovery;
2421
3826
  const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
2422
3827
  writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
2423
3828
  encoding: "utf8",
@@ -2472,9 +3877,29 @@ export function createTelegramTopicTargetStore(
2472
3877
  markDirty();
2473
3878
  },
2474
3879
  setStatusSnapshot(snapshot) {
2475
- if (!loadedPath) loadedPath = getPath();
3880
+ if (!loadedPath) { loadedPath = getPath(); loadedProfile = activeProfile(); }
2476
3881
  statusSnapshot = { ...snapshot };
2477
- statusRevision += 1;
3882
+ },
3883
+ persistStatus() {
3884
+ if (runtimeProjection) return runtimeProjection.persist({ runtime: statusSnapshot.runtime ?? {},
3885
+ liveRoster: statusSnapshot.liveRoster ?? {}, diagnostics: statusSnapshot.diagnostics ?? {} }).then(() => undefined);
3886
+ const write = statusQueue.then(async () => {
3887
+ if (options.canPersist && !options.canPersist()) return;
3888
+ const body = { version: 1, source: "snapshot", ...statusSnapshot };
3889
+ // Normalize to wire JSON; object key order and omitted undefined are not changes.
3890
+ const semantic = JSON.parse(JSON.stringify(body)) as Record<string, unknown>;
3891
+ const statusPath = getStatusPath();
3892
+ if (isDeepStrictEqual(semantic, lastStatusSemantic) && statusPath === lastStatusPath) return;
3893
+ await mkdir(dirname(statusPath), { recursive: true });
3894
+ const tempPath = `${statusPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
3895
+ await writeFile(tempPath, `${JSON.stringify({ ...body, writtenAtMs: (options.getNowMs ?? Date.now)() }, null, 2)}\n`,
3896
+ { encoding: "utf8", flag: "wx", mode: 0o600 });
3897
+ try { renameSync(tempPath, statusPath); chmodSync(statusPath, 0o600); }
3898
+ catch (error) { await unlink(tempPath).catch(() => undefined); throw error; }
3899
+ lastStatusSemantic = semantic; lastStatusPath = statusPath;
3900
+ });
3901
+ statusQueue = write.then(() => undefined, () => undefined);
3902
+ return write;
2478
3903
  },
2479
3904
  getByProfileKey(profileKey) {
2480
3905
  const ownerKey = getTelegramThreadOwnerKey(
@@ -2547,7 +3972,7 @@ export function createTelegramTopicTargetStore(
2547
3972
  listWorkspaceRetirementIntents() {
2548
3973
  return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
2549
3974
  },
2550
- commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
3975
+ commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete, journalSources) {
2551
3976
  if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
2552
3977
  return undefined;
2553
3978
  }
@@ -2558,19 +3983,34 @@ export function createTelegramTopicTargetStore(
2558
3983
  typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512,
2559
3984
  )) return undefined;
2560
3985
  const keys = Array.from(new Set(journalBindingKeys));
3986
+ const sources = journalSources === undefined ? current.journalSources ?? [] : normalizeWorkspaceJournalSources(journalSources);
3987
+ if (!sources || sources.some(source => !(current.journalSources ?? []).some(retained => isDeepStrictEqual(retained, source)))) return undefined;
2561
3988
  if ((current.journalBindingsComplete === true) === complete &&
2562
- isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
3989
+ isDeepStrictEqual(current.journalBindingKeys ?? [], keys) && isDeepStrictEqual(current.journalSources ?? [], sources)) {
2563
3990
  return cloneWorkspaceBinding(current);
2564
3991
  }
2565
3992
  const next = { ...current, updatedAtMs: getNowMs() };
2566
- if (keys.length) next.journalBindingKeys = keys;
3993
+ // A complete empty set stays explicit so persisted completeness survives a lossless reload.
3994
+ if (keys.length || complete) next.journalBindingKeys = keys;
2567
3995
  else delete next.journalBindingKeys;
3996
+ if (sources.length) next.journalSources = sources.map(source => ({ ...source }));
3997
+ else delete next.journalSources;
2568
3998
  if (complete) next.journalBindingsComplete = true;
2569
3999
  else delete next.journalBindingsComplete;
2570
4000
  workspaceBindings.set(key, next);
2571
4001
  markDirty();
2572
4002
  return cloneWorkspaceBinding(next);
2573
4003
  },
4004
+ async persistWorkspaceJournalEvidence(expected, isCurrent) {
4005
+ const path = getPath();
4006
+ const frameCurrent = () => getPath() === path && isCurrent() && isDeepStrictEqual(
4007
+ workspaceBindings.get(getWorkspaceBindingMapKey(expected)), expected,
4008
+ );
4009
+ if (!frameCurrent()) return false;
4010
+ const current = () => frameCurrent() && options.canPersist?.() !== false;
4011
+ const published = await persistSnapshot(undefined, current);
4012
+ return published && current();
4013
+ },
2574
4014
  upsertWorkspaceRetirementIntent(intent) {
2575
4015
  const next = normalizeWorkspaceRetirementIntent(intent);
2576
4016
  if (!next) return false;
@@ -3188,6 +4628,17 @@ export function createTelegramTopicTargetStore(
3188
4628
  existing.bindingKey !== next.bindingKey &&
3189
4629
  targetMatches(existing.target, next.target),
3190
4630
  );
4631
+ const retainedSources = [
4632
+ ...(workspaceBindings.get(nextMapKey)?.journalSources ?? []),
4633
+ ...(replacedTargetBinding?.journalSources ?? []),
4634
+ ...(next.journalSources ?? []),
4635
+ ];
4636
+ if (retainedSources.length) {
4637
+ const uniqueSources = Array.from(new Map(retainedSources.map(source => [JSON.stringify(source), source])).values());
4638
+ const journalSources = normalizeWorkspaceJournalSources(uniqueSources);
4639
+ if (!journalSources) return undefined;
4640
+ next.journalSources = journalSources;
4641
+ }
3191
4642
  if (
3192
4643
  claimInstanceId && claim?.instanceId === claimInstanceId &&
3193
4644
  replacedTargetBinding?.slot
@@ -3233,7 +4684,7 @@ export function createTelegramTopicTargetStore(
3233
4684
  ...(previous.journalBindingKeys ?? []),
3234
4685
  ...(next.journalBindingKeys ?? []),
3235
4686
  ]));
3236
- if (journalBindingKeys.length) next.journalBindingKeys = journalBindingKeys;
4687
+ if (journalBindingKeys.length || previous.journalBindingsComplete) next.journalBindingKeys = journalBindingKeys;
3237
4688
  else delete next.journalBindingKeys;
3238
4689
  if (previous.journalBindingsComplete) next.journalBindingsComplete = true;
3239
4690
  else delete next.journalBindingsComplete;
@@ -3568,64 +5019,7 @@ function isTelegramTopicTargetSlotOccupied(
3568
5019
  return false;
3569
5020
  }
3570
5021
 
3571
- export function normalizeTelegramTopicTargetThreadName(
3572
- threadName: string,
3573
- ): string {
3574
- return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
3575
- }
3576
-
3577
- function getGraphemeSegments(value: string): string[] {
3578
- const segmenter = (
3579
- Intl as unknown as {
3580
- Segmenter?: new (
3581
- locale?: string,
3582
- options?: { granularity: "grapheme" },
3583
- ) => { segment(input: string): Iterable<{ segment: string }> };
3584
- }
3585
- ).Segmenter;
3586
- if (!segmenter) return Array.from(value);
3587
- return Array.from(
3588
- new segmenter(undefined, { granularity: "grapheme" }).segment(value),
3589
- (part) => part.segment,
3590
- );
3591
- }
3592
-
3593
- export function getTelegramTopicIdentityName(threadName: string): string {
3594
- return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
3595
- .join("")
3596
- .trim();
3597
- }
3598
-
3599
- const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
3600
- A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
3601
- B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
3602
- C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
3603
- D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
3604
- E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
3605
- F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
3606
- G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
3607
- H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
3608
- I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
3609
- J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
3610
- K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
3611
- L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
3612
- M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
3613
- N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
3614
- O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
3615
- P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
3616
- Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
3617
- R: ["River", "Raven", "Rune", "Reef", "Ridge"],
3618
- S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
3619
- T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
3620
- U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
3621
- V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
3622
- W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
3623
- X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
3624
- Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
3625
- Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
3626
- };
3627
-
3628
- export function listOccupiedTelegramThreadIdentities(input: {
5022
+ function listOccupiedTelegramThreadIdentities(input: {
3629
5023
  records: readonly TelegramTopicTargetRecord[];
3630
5024
  workspaceBindings?: readonly TelegramWorkspaceThreadBinding[];
3631
5025
  pendingProvisions?: readonly TelegramThreadPendingProvision[];
@@ -3664,47 +5058,6 @@ export function listOccupiedTelegramThreadIdentities(input: {
3664
5058
  return Array.from(occupied);
3665
5059
  }
3666
5060
 
3667
- export function chooseTelegramThreadName(input: {
3668
- slot: string | undefined;
3669
- entropy?: number | string;
3670
- getRandom?: () => number;
3671
- occupied?: readonly string[];
3672
- }): string | undefined {
3673
- if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
3674
- const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
3675
- if (!names || names.length === 0) return undefined;
3676
- const occupied = new Set(
3677
- (input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)),
3678
- );
3679
- const start = input.getRandom
3680
- ? Math.max(
3681
- 0,
3682
- Math.min(
3683
- names.length - 1,
3684
- Math.floor(input.getRandom() * names.length),
3685
- ),
3686
- )
3687
- : getTelegramThreadNameEntropyIndex(input.entropy, names.length);
3688
- for (let offset = 0; offset < names.length; offset += 1) {
3689
- const name = names[(start + offset) % names.length];
3690
- if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
3691
- }
3692
- for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
3693
- for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
3694
- if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
3695
- }
3696
- }
3697
- return undefined;
3698
- }
3699
-
3700
- function getTelegramThreadNameLeadingSlot(
3701
- threadName: string | undefined,
3702
- ): string | undefined {
3703
- if (!threadName) return undefined;
3704
- const first = getTelegramTopicIdentityName(threadName)[0];
3705
- return first && /^[A-Z]$/.test(first) ? first : undefined;
3706
- }
3707
-
3708
5061
  function getNextTelegramThreadNamePaletteSlot(
3709
5062
  records: readonly TelegramTopicTargetRecord[],
3710
5063
  fallbackSlot: string | undefined,
@@ -3723,95 +5076,6 @@ function getNextTelegramThreadNamePaletteSlot(
3723
5076
  return String.fromCharCode(code);
3724
5077
  }
3725
5078
 
3726
- function getTelegramThreadNameEntropyIndex(
3727
- entropy: number | string | undefined,
3728
- length: number,
3729
- ): number {
3730
- if (length <= 1) return 0;
3731
- if (typeof entropy === "number" && entropy < 1_000_000_000_000) return 0;
3732
- const value = entropy === undefined ? "0" : String(entropy);
3733
- let hash = 2166136261;
3734
- for (const char of value) {
3735
- hash ^= char.codePointAt(0) ?? 0;
3736
- hash = Math.imul(hash, 16777619) >>> 0;
3737
- }
3738
- return hash % length;
3739
- }
3740
-
3741
- export function getTelegramTopicThreadNameValidationError(
3742
- threadName: string,
3743
- _slot: string | undefined,
3744
- ): string | undefined {
3745
- const identity = getTelegramTopicIdentityName(threadName);
3746
- const reasons: string[] = [];
3747
- if (!identity) reasons.push("it is empty after trimming");
3748
- if (/\s/.test(identity)) reasons.push("it contains spaces");
3749
- if (/[^A-Za-z]/.test(identity)) {
3750
- reasons.push("it contains characters outside Latin A-Z letters");
3751
- }
3752
- if (!/^[A-Z]/.test(identity)) {
3753
- reasons.push("it does not start with an uppercase Latin letter");
3754
- }
3755
- const genericLabels = new Set(["telegram", "leader", "follower"]);
3756
- if (genericLabels.has(identity.toLowerCase())) {
3757
- reasons.push("it is a generic role label");
3758
- }
3759
- if (/^[A-Z]$/.test(identity)) reasons.push("it is only a bare slot letter");
3760
- if (reasons.length === 0) return undefined;
3761
- return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
3762
- }
3763
-
3764
- export function getTelegramManualThreadDisplayNameValidationError(
3765
- threadName: string,
3766
- ): string | undefined {
3767
- const trimmed = threadName.trim();
3768
- const normalized = trimmed.replace(/\s+/g, " ");
3769
- const reasons: string[] = [];
3770
- if (!trimmed) reasons.push("it is empty after trimming");
3771
- if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
3772
- reasons.push("it contains characters outside printable ASCII");
3773
- }
3774
- if (normalized.length > 96) reasons.push("it is longer than 96 characters");
3775
- if (/^[A-Z]$/.test(normalized)) {
3776
- reasons.push("a bare slot letter is reserved for reset to automatic");
3777
- }
3778
- if (reasons.length === 0) return undefined;
3779
- return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
3780
- }
3781
-
3782
- export function isTelegramTopicThreadNameValidForSlot(
3783
- threadName: string,
3784
- slot: string | undefined,
3785
- ): boolean {
3786
- return !getTelegramTopicThreadNameValidationError(threadName, slot);
3787
- }
3788
-
3789
- function applyTopicNameTemplate(
3790
- template: string,
3791
- request: TelegramTopicTargetProvisionRequest,
3792
- slot?: string,
3793
- ): string {
3794
- const threadName =
3795
- request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
3796
- let result = template
3797
- .replaceAll("{threadName}", threadName)
3798
- .replaceAll("{profileKey}", request.profileKey)
3799
- .replaceAll("{instanceId}", request.instanceId);
3800
- if (slot) result = result.replaceAll("{slot}", slot);
3801
- return result;
3802
- }
3803
-
3804
- export function getTelegramTopicName(
3805
- request: TelegramTopicTargetProvisionRequest,
3806
- template = "{slot}",
3807
- slot?: string,
3808
- ): string {
3809
- const name = applyTopicNameTemplate(template, request, slot)
3810
- .replace(/\s+/g, " ")
3811
- .trim();
3812
- return (name || slot || "Pi").slice(0, 128);
3813
- }
3814
-
3815
5079
  function asInteger(value: unknown): number | undefined {
3816
5080
  if (typeof value === "number" && Number.isInteger(value)) return value;
3817
5081
  if (typeof value !== "string" || value.trim() === "") return undefined;
@@ -4832,22 +6096,6 @@ export function isTelegramTopicModeUnavailableError(error: unknown): boolean {
4832
6096
  );
4833
6097
  }
4834
6098
 
4835
- export function getTelegramTopicTitleForThreadName(
4836
- threadName: string,
4837
- slot: string,
4838
- template = "{threadName}",
4839
- ): string {
4840
- return getTelegramTopicName(
4841
- {
4842
- instanceId: "",
4843
- profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
4844
- threadName,
4845
- },
4846
- template,
4847
- slot,
4848
- );
4849
- }
4850
-
4851
6099
  export function createTelegramTopicTargetRenamer(
4852
6100
  deps: TelegramTopicTargetRenamerDeps,
4853
6101
  ): (
@@ -5109,7 +6357,7 @@ export function createTelegramTopicTargetProvisioner(
5109
6357
  if (!projectedTitle?.trim()) {
5110
6358
  throw new Error("Telegram Thread display identity is missing or ambiguous.");
5111
6359
  }
5112
- displayTitle = getTelegramTopicName(
6360
+ displayTitle = buildTelegramTopicName(
5113
6361
  { ...request, threadName: projectedTitle },
5114
6362
  "{threadName}",
5115
6363
  slot,
@@ -5141,7 +6389,7 @@ export function createTelegramTopicTargetProvisioner(
5141
6389
  "createForumTopic",
5142
6390
  {
5143
6391
  chat_id: deps.topicChatId,
5144
- name: displayTitle ?? getTelegramTopicName(
6392
+ name: displayTitle ?? buildTelegramTopicName(
5145
6393
  {
5146
6394
  ...request,
5147
6395
  ...(requestThreadName ? { threadName: requestThreadName } : {}),