@gajae-code/coding-agent 0.12.10 → 0.12.12

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 (120) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +13 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +25 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/internal/managed-session-storage.d.ts +12 -2
  42. package/dist/types/session/session-manager.d.ts +40 -5
  43. package/dist/types/session/session-storage.d.ts +1 -0
  44. package/dist/types/tools/computer.d.ts +35 -2
  45. package/dist/types/tools/index.d.ts +8 -0
  46. package/dist/types/tools/todo-write.d.ts +2 -0
  47. package/dist/types/utils/npm-registry.d.ts +71 -0
  48. package/examples/extensions/README.md +38 -4
  49. package/examples/extensions/ooo-bridge.ts +15 -0
  50. package/package.json +8 -8
  51. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  52. package/src/async/job-manager.ts +9 -2
  53. package/src/cli/notify-cli.ts +226 -2
  54. package/src/cli/update-cli.ts +90 -23
  55. package/src/commands/notify.ts +27 -3
  56. package/src/commands/sdk.ts +1 -0
  57. package/src/config/file-lock-gc.ts +18 -0
  58. package/src/config/file-lock.ts +61 -16
  59. package/src/config/model-profiles.ts +31 -15
  60. package/src/coordinator/contract.ts +1 -0
  61. package/src/coordinator-mcp/question-state.ts +2 -0
  62. package/src/coordinator-mcp/server.ts +358 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  64. package/src/extensibility/extensions/index.ts +1 -0
  65. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  66. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  67. package/src/extensibility/extensions/runner.ts +15 -2
  68. package/src/extensibility/extensions/types.ts +2 -0
  69. package/src/gjc-runtime/launch-tmux.ts +53 -1
  70. package/src/internal-urls/docs-index.generated.ts +13 -12
  71. package/src/main.ts +203 -67
  72. package/src/modes/DESIGN.md +8 -5
  73. package/src/modes/components/footer.ts +10 -18
  74. package/src/modes/components/session-selector.ts +26 -3
  75. package/src/modes/components/status-line/gh.ts +23 -2
  76. package/src/modes/controllers/event-controller.ts +3 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  78. package/src/modes/controllers/input-controller.ts +11 -0
  79. package/src/modes/controllers/selector-controller.ts +8 -2
  80. package/src/modes/interactive-mode.ts +7 -1
  81. package/src/modes/utils/host-status.ts +38 -0
  82. package/src/prompts/tools/computer.md +6 -1
  83. package/src/runtime-mcp/types.ts +2 -0
  84. package/src/sdk/broker/lifecycle.ts +60 -3
  85. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  86. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  87. package/src/sdk/bus/chat-daemon-control.ts +18 -3
  88. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  89. package/src/sdk/bus/control-plane-frames.ts +51 -0
  90. package/src/sdk/bus/conversation-store.ts +29 -1
  91. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  92. package/src/sdk/bus/index.ts +70 -0
  93. package/src/sdk/bus/notification-service.ts +28 -1
  94. package/src/sdk/bus/rich-render.ts +3 -2
  95. package/src/sdk/bus/slack-daemon.ts +99 -4
  96. package/src/sdk/bus/slack-live-provider.ts +32 -2
  97. package/src/sdk/bus/slack-provider.ts +11 -0
  98. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  99. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  100. package/src/sdk/bus/telegram-daemon-contract.ts +25 -7
  101. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  102. package/src/sdk/bus/telegram-reference.ts +42 -6
  103. package/src/sdk/bus/topic-registry.ts +674 -181
  104. package/src/sdk/host/host.ts +180 -1
  105. package/src/sdk/lifecycle-session.ts +8 -2
  106. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  107. package/src/sdk/session-activation.ts +127 -0
  108. package/src/sdk/session.ts +139 -28
  109. package/src/sdk/startup-capability.ts +10 -1
  110. package/src/session/agent-session.ts +331 -30
  111. package/src/session/artifacts.ts +56 -13
  112. package/src/session/internal/managed-session-storage.ts +440 -243
  113. package/src/session/session-manager.ts +288 -44
  114. package/src/session/session-storage.ts +3 -0
  115. package/src/setup/provider-presets.json +1 -1
  116. package/src/task/index.ts +92 -44
  117. package/src/tools/computer.ts +322 -45
  118. package/src/tools/index.ts +8 -0
  119. package/src/tools/todo-write.ts +29 -11
  120. package/src/utils/npm-registry.ts +660 -0
@@ -1,12 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * Per-session forum-topic registry for the threaded session surface.
3
4
  *
4
- * Each GJC session owns one active Telegram forum topic in the paired private
5
- * DM. The topic is created via `createForumTopic`, reused while the session
6
- * remains active, and removed from the registry when the daemon deletes it on
7
- * shutdown. The registry also tracks whether the one-time identity header has
8
- * already been pinned, so it is sent exactly once per active topic, even across
9
- * reconnects.
5
+ * Each GJC session owns one active Telegram forum topic. Remote archive closes
6
+ * daemon-created topics without deleting their durable records; rotated
7
+ * successors move inactive records into retained history before creating a new
8
+ * active authority. The registry also tracks whether the one-time identity
9
+ * header has already been pinned.
10
10
  *
11
11
  * State is a plain serialisable map persisted beside the daemon state files;
12
12
  * topic creation is injected so this module is pure and unit-testable without a
@@ -14,9 +14,24 @@
14
14
  */
15
15
 
16
16
  /** Persisted record for one session's topic. */
17
+ export type TopicLifecycleState =
18
+ | "active"
19
+ | "disconnect_grace"
20
+ | "archive_pending"
21
+ | "archive_exhausted"
22
+ | "inactive"
23
+ | "legacy_quarantined"
24
+ /** Read-only input compatibility; normalized to archive_pending during load. */
25
+ | "delete_pending";
26
+
27
+ /** Persisted record for one immutable session identity's topic. */
17
28
  export interface TopicRecord {
18
29
  /** Telegram forum topic id (message_thread_id). */
19
30
  topicId: string;
31
+ /** Whether Telegram created this topic for the daemon or it was explicitly adopted from a user. */
32
+ topicOrigin: "daemon_created" | "user_created";
33
+ /** Immutable UUID record identity; never derive authority from a title or PID. */
34
+ sessionUuid?: string;
20
35
  /** Whether the one-time identity header has been sent/pinned. */
21
36
  identitySent: boolean;
22
37
  /** Creation timestamp (ms epoch). */
@@ -41,10 +56,8 @@ export interface TopicRecord {
41
56
  authorityEpoch?: number;
42
57
  /** Immutable authority epoch held when this remote topic create began. */
43
58
  creationLeaseEpoch?: number;
44
- /** An uncertain delete fences future creation and inbound routing. */
45
- authorityState?: "active" | "delete_pending";
46
- /** Provenance for topics created by the user and adopted by GJC. Missing means daemon-created. */
47
- topicOrigin?: "user_created";
59
+ /** Durable non-destructive lifecycle state. */
60
+ authorityState?: TopicLifecycleState;
48
61
  /** Telegram chat and endpoint authority last proven to use this topic. */
49
62
  chatId?: string;
50
63
  /** Canonical endpoint tuple (URL + token) that currently holds the lease. */
@@ -55,18 +68,70 @@ export interface TopicRecord {
55
68
  endpointGeneration?: number;
56
69
  /** Monotonic authenticated endpoint handoffs; legacy bindings begin at zero. */
57
70
  endpointIncarnation?: number;
71
+ /** Shared-authority lease owner (installation UUID), heartbeat, and expiry. */
72
+ leaseOwner?: string;
73
+ leaseHeartbeatAt?: number;
74
+ leaseExpiresAt?: number;
75
+ /** Durable archive initiator; a live foreign owner cannot be displaced. */
76
+ archiveHostId?: string;
77
+ /** Authority epoch captured by the archive initiator when it published the fence. */
78
+ archiveLeaseEpoch?: number;
79
+ disconnectGraceExpiresAt?: number;
58
80
  /** True when persisted binding fields were present but malformed; recovery must fail closed. */
59
81
  bindingMalformed?: true;
60
82
  }
83
+ /** Durable claim published before invoking createForumTopic. */
84
+ export interface TopicCreateClaim {
85
+ sessionId: string;
86
+ hostId?: string;
87
+ leaseOwner?: string;
88
+ authorityEpoch: number;
89
+ createdAt: number;
90
+ binding?: TopicEndpointBinding;
91
+ }
92
+
93
+ export interface ArchiveJob {
94
+ sessionId: string;
95
+ topicId: string;
96
+ /** Number of archive calls that have returned an ambiguous outcome. */
97
+ attempt: number;
98
+ /** First ambiguous result; bounds retry lifetime. */
99
+ firstAttemptAt?: number;
100
+ /** Compatibility read for pre-journal snapshots; normalized to `attempt`. */
101
+ retryCount?: number;
102
+ backoffMs: number;
103
+ nextAttemptAt: number;
104
+ safeDiagnostic?: string;
105
+ }
61
106
 
62
107
  /** Serialisable shape persisted to disk. */
63
108
  export interface TopicRegistryState {
109
+ /** Writer format. Missing is the quarantined legacy format; future versions fail closed. */
110
+ version?: 2;
111
+ /** Monotonically increasing snapshot generation used by shared CAS stores. */
112
+ registryGeneration?: number;
64
113
  /** sessionId -> record. */
65
114
  topics: Record<string, TopicRecord>;
66
- /** Durable deletion epochs retained after a definite delete. */
115
+ /** Durable lifecycle epochs retained after an archive starts. */
67
116
  fences?: Record<string, number>;
68
117
  /** Closed transport endpoint leases; unchanged endpoint discovery remains fenced across restart. */
69
118
  closedEndpoints?: Record<string, TopicEndpointBinding>;
119
+ /** Persistent host identity used to distinguish concurrent installations. */
120
+ installationHostId?: string;
121
+ /** Bounded durable archive work; no topic record is physically deleted. */
122
+ archiveJobs?: Record<string, ArchiveJob>;
123
+ /** Durable create claims. A claim fences concurrent creators before remote I/O. */
124
+ createClaims?: Record<string, TopicCreateClaim>;
125
+ /** Retained inactive predecessors keyed by logical session id. */
126
+ retiredTopics?: Record<string, TopicRecord[]>;
127
+ }
128
+ /**
129
+ * Shared registry authority. Filesystem atomic rename is sufficient for a single
130
+ * installation, but it cannot serialize two hosts sharing a state volume.
131
+ */
132
+ export interface TopicRegistryCasAuthority {
133
+ read(): Promise<TopicRegistryState | undefined>;
134
+ compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean>;
70
135
  }
71
136
 
72
137
  /** Authenticated runtime binding for a durable topic lease. */
@@ -84,7 +149,7 @@ export type TopicEndpointAuthority =
84
149
  | { state: "ambiguous" };
85
150
 
86
151
  /** Conditional rollback token for a delete fence publication. */
87
- export interface TopicDeleteAuthoritySnapshot {
152
+ export interface TopicArchiveAuthoritySnapshot {
88
153
  sessionId: string;
89
154
  topicId?: string;
90
155
  authorityEpoch?: number;
@@ -94,18 +159,6 @@ export interface TopicDeleteAuthoritySnapshot {
94
159
  record?: TopicRecord;
95
160
  }
96
161
 
97
- /**
98
- * Proof that a definite delete was settled in memory and still owes its durable
99
- * commit. Handed back only by a settlement that actually happened, so a refused
100
- * settlement structurally cannot be followed by a rollback.
101
- */
102
- export interface TopicSettledDelete {
103
- sessionId: string;
104
- topicId: string;
105
- /** Authority epoch proven current when the record was removed. */
106
- settledEpoch: number;
107
- }
108
-
109
162
  function isValidBindingString(value: unknown): value is string {
110
163
  return typeof value === "string" && value.trim().length > 0;
111
164
  }
@@ -147,19 +200,179 @@ function isValidTopicId(value: unknown): value is string {
147
200
  typeof value === "string" && /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0
148
201
  );
149
202
  }
150
-
151
- /**
152
- * Monotonic epoch successor that saturates instead of leaving the safe-integer
153
- * range. Past `Number.MAX_SAFE_INTEGER` two distinct generations collapse onto
154
- * the same IEEE-754 double, which would let a stale settlement clear a newer
155
- * fence; settlement refuses the saturated value instead of overflowing past it.
156
- */
157
203
  function nextAuthorityEpoch(current: number): number {
158
204
  return current >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : current + 1;
159
205
  }
160
206
 
161
207
  export function emptyTopicRegistryState(): TopicRegistryState {
162
- return { topics: {} };
208
+ return { version: 2, topics: {} };
209
+ }
210
+ /**
211
+ * Reject snapshots written by a newer daemon. Missing versions are preserved as
212
+ * evidence but quarantined: legacy records must never route or mutate remotely.
213
+ */
214
+ export function parseTopicRegistryState(value: unknown): TopicRegistryState | undefined {
215
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
216
+ const state = value as TopicRegistryState;
217
+ if (state.version !== undefined && state.version !== 2) throw new Error("unsupported future Telegram topic state");
218
+ if (!state.topics || typeof state.topics !== "object" || Array.isArray(state.topics)) return undefined;
219
+ if (state.version === undefined) {
220
+ if (state.registryGeneration !== undefined) throw new Error("malformed Telegram topic state");
221
+ return parseTopicRegistryState({
222
+ ...state,
223
+ version: 2,
224
+ registryGeneration: 0,
225
+ topics: Object.fromEntries(
226
+ Object.entries(state.topics).map(([sessionId, record]) => [
227
+ sessionId,
228
+ record && typeof record === "object"
229
+ ? {
230
+ ...record,
231
+ topicOrigin: (record as TopicRecord).topicOrigin ?? "daemon_created",
232
+ authorityState: "legacy_quarantined",
233
+ }
234
+ : record,
235
+ ]),
236
+ ),
237
+ });
238
+ }
239
+
240
+ const isObject = (candidate: unknown): candidate is Record<string, unknown> =>
241
+ !!candidate && typeof candidate === "object" && !Array.isArray(candidate);
242
+ const validTimestamp = (candidate: unknown): candidate is number =>
243
+ typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0;
244
+ const validEpoch = (candidate: unknown): candidate is number =>
245
+ typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0;
246
+ const validOptionalString = (candidate: unknown): boolean =>
247
+ candidate === undefined || isValidBindingString(candidate);
248
+ const validBinding = (candidate: unknown): candidate is TopicEndpointBinding =>
249
+ isObject(candidate) &&
250
+ isValidBindingString(candidate.chatId) &&
251
+ isValidBindingString(candidate.endpointKey) &&
252
+ isValidBindingString(candidate.endpointDigest) &&
253
+ (candidate.endpointGeneration === undefined || isValidBindingGeneration(candidate.endpointGeneration));
254
+ const malformed = (): never => {
255
+ throw new Error("malformed Telegram topic state");
256
+ };
257
+
258
+ if (
259
+ (state.registryGeneration !== undefined && !validEpoch(state.registryGeneration)) ||
260
+ !isObject(state.topics) ||
261
+ [state.fences, state.closedEndpoints, state.archiveJobs, state.createClaims, state.retiredTopics].some(
262
+ nested => nested !== undefined && !isObject(nested),
263
+ )
264
+ )
265
+ malformed();
266
+ for (const [sessionId, raw] of Object.entries(state.topics)) {
267
+ if (!isValidBindingString(sessionId) || !isObject(raw) || !isValidTopicId(raw.topicId)) malformed();
268
+ if (typeof raw.identitySent !== "boolean" || !validTimestamp(raw.createdAt)) malformed();
269
+ if (
270
+ !validOptionalString(raw.sessionUuid) ||
271
+ (raw.orphanedAt !== undefined && !validTimestamp(raw.orphanedAt)) ||
272
+ (raw.name !== undefined && typeof raw.name !== "string") ||
273
+ (raw.topicOrigin !== "daemon_created" && raw.topicOrigin !== "user_created") ||
274
+ (raw.nameOwner !== undefined && raw.nameOwner !== "user") ||
275
+ (raw.nameReconcilePending !== undefined && typeof raw.nameReconcilePending !== "boolean") ||
276
+ (raw.userNameUpdateId !== undefined && !validEpoch(raw.userNameUpdateId)) ||
277
+ !validOptionalString(raw.identityKey) ||
278
+ (raw.replayGeneration !== undefined &&
279
+ (!Number.isSafeInteger(raw.replayGeneration) || raw.replayGeneration < 1)) ||
280
+ (raw.replaySeq !== undefined && !validEpoch(raw.replaySeq)) ||
281
+ (raw.authorityEpoch !== undefined && !validEpoch(raw.authorityEpoch)) ||
282
+ (raw.creationLeaseEpoch !== undefined && !validEpoch(raw.creationLeaseEpoch)) ||
283
+ (raw.authorityState !== undefined &&
284
+ ![
285
+ "active",
286
+ "disconnect_grace",
287
+ "archive_pending",
288
+ "archive_exhausted",
289
+ "inactive",
290
+ "legacy_quarantined",
291
+ "delete_pending",
292
+ ].includes(raw.authorityState as string)) ||
293
+ !validOptionalString(raw.leaseOwner) ||
294
+ (raw.leaseHeartbeatAt !== undefined && !validTimestamp(raw.leaseHeartbeatAt)) ||
295
+ (raw.leaseExpiresAt !== undefined && !validTimestamp(raw.leaseExpiresAt)) ||
296
+ !validOptionalString(raw.archiveHostId) ||
297
+ (raw.archiveLeaseEpoch !== undefined && !validEpoch(raw.archiveLeaseEpoch)) ||
298
+ (raw.disconnectGraceExpiresAt !== undefined && !validTimestamp(raw.disconnectGraceExpiresAt)) ||
299
+ (raw.bindingMalformed !== undefined && raw.bindingMalformed !== true)
300
+ )
301
+ malformed();
302
+ const leaseFieldCount = [raw.leaseOwner, raw.leaseHeartbeatAt, raw.leaseExpiresAt].filter(
303
+ value => value !== undefined,
304
+ ).length;
305
+ if (leaseFieldCount !== 0 && leaseFieldCount !== 3) malformed();
306
+ if (
307
+ raw.authorityState === "disconnect_grace"
308
+ ? raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined
309
+ : raw.disconnectGraceExpiresAt !== undefined
310
+ )
311
+ malformed();
312
+ const hasBinding = hasAnyBinding(raw);
313
+ const hasArchiveOnlyChatIdentity =
314
+ (raw.authorityState === "archive_pending" ||
315
+ raw.authorityState === "archive_exhausted" ||
316
+ raw.authorityState === "inactive") &&
317
+ isValidBindingString(raw.chatId) &&
318
+ raw.endpointKey === undefined &&
319
+ raw.endpointDigest === undefined &&
320
+ raw.endpointGeneration === undefined &&
321
+ raw.endpointIncarnation === undefined;
322
+ if (
323
+ hasBinding &&
324
+ !hasArchiveOnlyChatIdentity &&
325
+ (!isValidBindingString(raw.chatId) ||
326
+ !isValidBindingString(raw.endpointKey) ||
327
+ !isValidBindingString(raw.endpointDigest) ||
328
+ (raw.endpointGeneration !== undefined && !isValidBindingGeneration(raw.endpointGeneration)) ||
329
+ (raw.endpointIncarnation !== undefined && !isValidBindingGeneration(raw.endpointIncarnation)))
330
+ )
331
+ malformed();
332
+ }
333
+ for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) {
334
+ if (!isValidBindingString(sessionId) || !Array.isArray(records)) malformed();
335
+ for (const [index, record] of records.entries()) {
336
+ parseTopicRegistryState({
337
+ version: 2,
338
+ registryGeneration: 0,
339
+ topics: { [`${sessionId}:retired:${index}`]: record },
340
+ });
341
+ }
342
+ }
343
+ for (const [sessionId, epoch] of Object.entries(state.fences ?? {}))
344
+ if (!isValidBindingString(sessionId) || !validEpoch(epoch)) malformed();
345
+ for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) {
346
+ if (
347
+ !isValidBindingString(sessionId) ||
348
+ !isObject(claim) ||
349
+ claim.sessionId !== sessionId ||
350
+ !validEpoch(claim.authorityEpoch) ||
351
+ !validTimestamp(claim.createdAt) ||
352
+ !validOptionalString(claim.hostId) ||
353
+ !validOptionalString(claim.leaseOwner) ||
354
+ (claim.binding !== undefined && !validBinding(claim.binding))
355
+ )
356
+ malformed();
357
+ }
358
+ for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) {
359
+ if (
360
+ !isValidBindingString(sessionId) ||
361
+ !isObject(job) ||
362
+ job.sessionId !== sessionId ||
363
+ !isValidTopicId(job.topicId) ||
364
+ !validEpoch(job.attempt) ||
365
+ !validEpoch(job.backoffMs) ||
366
+ !validTimestamp(job.nextAttemptAt) ||
367
+ (job.firstAttemptAt !== undefined && !validTimestamp(job.firstAttemptAt)) ||
368
+ (job.retryCount !== undefined && !validEpoch(job.retryCount)) ||
369
+ (job.safeDiagnostic !== undefined && typeof job.safeDiagnostic !== "string")
370
+ )
371
+ malformed();
372
+ }
373
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
374
+ if (!isValidBindingString(sessionId) || !validBinding(binding)) malformed();
375
+ return state;
163
376
  }
164
377
 
165
378
  /**
@@ -183,8 +396,14 @@ export class TopicRegistry {
183
396
  private readonly creatingBindings = new Map<string, TopicEndpointBinding>();
184
397
  /** Monotonic authority epochs, including deletion fences for absent records. */
185
398
  private readonly epochs = new Map<string, number>();
186
- /** Phase-1 settlements awaiting their durable clear; their topic ids stay quarantined. */
187
- readonly #settling = new Map<string, { topicId: string; settledEpoch: number; record: TopicRecord }>();
399
+ /** Archive work is retained and retryable; records are never physically removed. */
400
+ private readonly archiveJobs = new Map<string, ArchiveJob>();
401
+ /** Durable pre-create claims; remote creation is forbidden until published. */
402
+ private readonly createClaims = new Map<string, TopicCreateClaim>();
403
+ /** Inactive predecessor evidence retained across successor generations. */
404
+ private readonly retiredTopics = new Map<string, TopicRecord[]>();
405
+ /** Generation of the last loaded/published snapshot. */
406
+ private registryGeneration = 0;
188
407
 
189
408
  constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
190
409
  this.topics = new Map();
@@ -197,15 +416,76 @@ export class TopicRegistry {
197
416
  this.byTopic.clear();
198
417
  this.#ambiguousTopicIds.clear();
199
418
  this.epochs.clear();
200
- this.#settling.clear();
419
+ this.archiveJobs.clear();
420
+ this.createClaims.clear();
421
+ this.retiredTopics.clear();
201
422
  this.load(state);
202
423
  }
203
424
 
204
425
  /** Merge serialized state and normalize authority fields from older releases. */
205
426
  load(state: TopicRegistryState): void {
427
+ for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {}))
428
+ if (Array.isArray(records))
429
+ this.retiredTopics.set(
430
+ sessionId,
431
+ records.map(record => ({ ...record })),
432
+ );
433
+ for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) {
434
+ const attempt =
435
+ job && Number.isSafeInteger(job.attempt) && job.attempt >= 0
436
+ ? job.attempt
437
+ : job &&
438
+ typeof job.retryCount === "number" &&
439
+ Number.isSafeInteger(job.retryCount) &&
440
+ job.retryCount >= 0
441
+ ? job.retryCount
442
+ : undefined;
443
+ if (
444
+ job &&
445
+ job.sessionId === sessionId &&
446
+ isValidTopicId(job.topicId) &&
447
+ attempt !== undefined &&
448
+ Number.isFinite(job.nextAttemptAt)
449
+ )
450
+ this.archiveJobs.set(sessionId, {
451
+ sessionId,
452
+ topicId: job.topicId,
453
+ attempt: Math.min(8, attempt),
454
+ firstAttemptAt:
455
+ typeof job.firstAttemptAt === "number" && Number.isFinite(job.firstAttemptAt)
456
+ ? job.firstAttemptAt
457
+ : job.nextAttemptAt,
458
+ backoffMs:
459
+ Number.isSafeInteger(job.backoffMs) && job.backoffMs >= 0
460
+ ? Math.min(60_000, job.backoffMs)
461
+ : Math.min(60_000, 250 * 2 ** Math.min(8, attempt)),
462
+ nextAttemptAt: job.nextAttemptAt,
463
+ ...(typeof job.safeDiagnostic === "string" ? { safeDiagnostic: job.safeDiagnostic.slice(0, 256) } : {}),
464
+ });
465
+ }
466
+ if (Number.isSafeInteger(state.registryGeneration) && (state.registryGeneration ?? -1) >= 0)
467
+ this.registryGeneration = Math.max(this.registryGeneration, state.registryGeneration!);
206
468
  for (const [sessionId, epoch] of Object.entries(state.fences ?? {})) {
207
469
  if (Number.isSafeInteger(epoch) && epoch >= 0) this.epochs.set(sessionId, epoch);
208
470
  }
471
+ for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) {
472
+ if (
473
+ claim &&
474
+ claim.sessionId === sessionId &&
475
+ Number.isSafeInteger(claim.authorityEpoch) &&
476
+ claim.authorityEpoch >= 0 &&
477
+ Number.isFinite(claim.createdAt) &&
478
+ (claim.binding === undefined || hasValidBinding(claim.binding))
479
+ )
480
+ this.createClaims.set(sessionId, {
481
+ sessionId,
482
+ authorityEpoch: claim.authorityEpoch,
483
+ createdAt: claim.createdAt,
484
+ ...(isValidBindingString(claim.hostId) ? { hostId: claim.hostId } : {}),
485
+ ...(isValidBindingString(claim.leaseOwner) ? { leaseOwner: claim.leaseOwner } : {}),
486
+ ...(claim.binding ? { binding: claim.binding } : {}),
487
+ });
488
+ }
209
489
 
210
490
  for (const [sessionId, raw] of Object.entries(state.topics ?? {})) {
211
491
  if (!raw || !isValidTopicId(raw.topicId)) continue;
@@ -217,6 +497,7 @@ export class TopicRegistry {
217
497
  (typeof raw.userNameUpdateId === "number" &&
218
498
  Number.isSafeInteger(raw.userNameUpdateId) &&
219
499
  raw.userNameUpdateId >= 0));
500
+ const legacyDeletePending = (raw as { authorityState?: unknown }).authorityState === "delete_pending";
220
501
  const hasValidReplayCursor =
221
502
  typeof raw.replayGeneration === "number" &&
222
503
  Number.isSafeInteger(raw.replayGeneration) &&
@@ -239,6 +520,9 @@ export class TopicRegistry {
239
520
  const fenceSupersedesRecord = fenceEpoch > rawAuthorityEpoch;
240
521
  const record: TopicRecord = {
241
522
  topicId: raw.topicId,
523
+ topicOrigin: raw.topicOrigin === "user_created" ? "user_created" : "daemon_created",
524
+ sessionUuid:
525
+ typeof raw.sessionUuid === "string" && raw.sessionUuid.length > 0 ? raw.sessionUuid : randomUUID(),
242
526
  identitySent: raw.identitySent === true,
243
527
  createdAt: typeof raw.createdAt === "number" ? raw.createdAt : 0,
244
528
  ...(typeof raw.name === "string" ? { name: raw.name } : {}),
@@ -258,10 +542,26 @@ export class TopicRegistry {
258
542
  : {}),
259
543
  ...(hasValidReplayCursor ? { replayGeneration: raw.replayGeneration, replaySeq: raw.replaySeq } : {}),
260
544
  authorityEpoch: Math.max(rawAuthorityEpoch, fenceEpoch),
261
- ...(raw.authorityState === "delete_pending" || fenceSupersedesRecord
262
- ? { authorityState: "delete_pending" as const }
263
- : {}),
264
- ...(raw.topicOrigin === "user_created" ? { topicOrigin: "user_created" as const } : {}),
545
+ ...(raw.authorityState === "disconnect_grace" ||
546
+ raw.authorityState === "archive_pending" ||
547
+ raw.authorityState === "archive_exhausted" ||
548
+ raw.authorityState === "inactive" ||
549
+ raw.authorityState === "legacy_quarantined" ||
550
+ legacyDeletePending ||
551
+ fenceSupersedesRecord
552
+ ? {
553
+ authorityState:
554
+ raw.authorityState === "inactive"
555
+ ? ("inactive" as const)
556
+ : raw.authorityState === "legacy_quarantined"
557
+ ? ("legacy_quarantined" as const)
558
+ : raw.authorityState === "archive_exhausted"
559
+ ? ("archive_exhausted" as const)
560
+ : raw.authorityState === "disconnect_grace" && !fenceSupersedesRecord
561
+ ? ("disconnect_grace" as const)
562
+ : ("archive_pending" as const),
563
+ }
564
+ : { authorityState: "active" as const }),
265
565
  ...(isValidBindingString(raw.chatId) ? { chatId: raw.chatId } : {}),
266
566
  ...(isValidBindingString(raw.endpointKey) ? { endpointKey: raw.endpointKey } : {}),
267
567
  ...(isValidBindingString(raw.endpointDigest) ? { endpointDigest: raw.endpointDigest } : {}),
@@ -269,6 +569,18 @@ export class TopicRegistry {
269
569
  ...(isValidBindingGeneration(raw.endpointIncarnation)
270
570
  ? { endpointIncarnation: raw.endpointIncarnation }
271
571
  : {}),
572
+ ...(isValidBindingString(raw.leaseOwner) ? { leaseOwner: raw.leaseOwner } : {}),
573
+ ...(typeof raw.leaseHeartbeatAt === "number" && Number.isFinite(raw.leaseHeartbeatAt)
574
+ ? { leaseHeartbeatAt: raw.leaseHeartbeatAt }
575
+ : {}),
576
+ ...(typeof raw.leaseExpiresAt === "number" && Number.isFinite(raw.leaseExpiresAt)
577
+ ? { leaseExpiresAt: raw.leaseExpiresAt }
578
+ : {}),
579
+ ...(isValidBindingString(raw.archiveHostId) ? { archiveHostId: raw.archiveHostId } : {}),
580
+ ...(isValidBindingGeneration(raw.archiveLeaseEpoch) ? { archiveLeaseEpoch: raw.archiveLeaseEpoch } : {}),
581
+ ...(typeof raw.disconnectGraceExpiresAt === "number" && Number.isFinite(raw.disconnectGraceExpiresAt)
582
+ ? { disconnectGraceExpiresAt: raw.disconnectGraceExpiresAt }
583
+ : {}),
272
584
  ...(bindingMalformed ? { bindingMalformed: true as const } : {}),
273
585
  };
274
586
  this.epochs.set(sessionId, Math.max(fenceEpoch, record.authorityEpoch ?? 0));
@@ -284,14 +596,10 @@ export class TopicRegistry {
284
596
  private rebuildInboundRoutes(): void {
285
597
  this.byTopic.clear();
286
598
  this.#ambiguousTopicIds.clear();
287
- // A settled-but-not-yet-durable clear keeps its topic id quarantined: the
288
- // clear is not authoritative until persisted, so no colliding survivor may
289
- // become routable and no settled id may become adoptable during that write.
290
- for (const settling of this.#settling.values()) this.#ambiguousTopicIds.add(settling.topicId);
291
599
  const activeByTopic = new Map<string, string>();
292
600
 
293
601
  for (const [sessionId, record] of this.topics) {
294
- if (record.authorityState === "delete_pending" || record.bindingMalformed) {
602
+ if (record.authorityState !== "active" || record.bindingMalformed) {
295
603
  this.#ambiguousTopicIds.add(record.topicId);
296
604
  continue;
297
605
  }
@@ -307,13 +615,6 @@ export class TopicRegistry {
307
615
  }
308
616
  }
309
617
 
310
- /** Advance and publish the session authority epoch, saturating at the safe-integer bound. */
311
- #advanceAuthorityEpoch(sessionId: string, base: number): number {
312
- const epoch = nextAuthorityEpoch(base);
313
- this.epochs.set(sessionId, epoch);
314
- return epoch;
315
- }
316
-
317
618
  /** Resolve the owning session for a topic id (for fail-closed inbound routing). */
318
619
  sessionForTopic(topicId: string): string | undefined {
319
620
  return this.byTopic.get(topicId);
@@ -324,17 +625,50 @@ export class TopicRegistry {
324
625
  return [...this.topics.keys()];
325
626
  }
326
627
 
327
- /** Persisted remote deletes that must be reconciled before recovery can proceed. */
328
- deletePendingSessionIds(): string[] {
329
- return [...this.topics].flatMap(([sessionId, record]) =>
330
- record.authorityState === "delete_pending" ? [sessionId] : [],
331
- );
332
- }
333
-
334
628
  /** The existing topic record for a session, if any. */
335
629
  get(sessionId: string): TopicRecord | undefined {
336
630
  return this.topics.get(sessionId);
337
631
  }
632
+ /** Durable claims restored from disk require explicit authoritative reconciliation. */
633
+ pendingCreateClaims(): TopicCreateClaim[] {
634
+ return [...this.createClaims.values()].map(claim => ({
635
+ ...claim,
636
+ ...(claim.binding ? { binding: { ...claim.binding } } : {}),
637
+ }));
638
+ }
639
+
640
+ /**
641
+ * Resolve a durable pre-create claim only with authoritative topic evidence.
642
+ * An absent or malformed topic deliberately leaves the claim in place, fencing
643
+ * subsequent creators after a crash.
644
+ */
645
+ reconcileCreateClaim(sessionId: string, topic?: TopicRecord): boolean {
646
+ const claim = this.createClaims.get(sessionId);
647
+ if (!claim || !topic || !isValidTopicId(topic.topicId) || topic.authorityState !== "active") return false;
648
+ if (topic.authorityEpoch !== claim.authorityEpoch) return false;
649
+ if (
650
+ claim.binding &&
651
+ (!hasCompleteBinding(topic) ||
652
+ !hasValidBinding(claim.binding) ||
653
+ topic.chatId !== claim.binding.chatId ||
654
+ topic.endpointKey !== claim.binding.endpointKey ||
655
+ topic.endpointDigest !== claim.binding.endpointDigest ||
656
+ topic.endpointGeneration !== claim.binding.endpointGeneration)
657
+ )
658
+ return false;
659
+ this.topics.set(sessionId, { ...topic });
660
+ this.createClaims.delete(sessionId);
661
+ this.rebuildInboundRoutes();
662
+ return true;
663
+ }
664
+ /** Clear a claim only when the local create attempt proved no remote topic was accepted. */
665
+ abandonCreateClaim(sessionId: string, authorityEpoch: number): boolean {
666
+ const claim = this.createClaims.get(sessionId);
667
+ if (!claim || claim.authorityEpoch !== authorityEpoch) return false;
668
+ this.createClaims.delete(sessionId);
669
+ this.creatingBindings.delete(sessionId);
670
+ return true;
671
+ }
338
672
 
339
673
  /** Current immutable authority epoch for a creation lease. */
340
674
  authorityEpoch(sessionId: string): number {
@@ -344,7 +678,7 @@ export class TopicRegistry {
344
678
  /** Whether this session has an active, unambiguous topic authority. */
345
679
  isActiveUnambiguous(sessionId: string): boolean {
346
680
  const record = this.topics.get(sessionId);
347
- return record?.authorityState !== "delete_pending" && this.byTopic.get(record?.topicId ?? "") === sessionId;
681
+ return record?.authorityState === "active" && this.byTopic.get(record?.topicId ?? "") === sessionId;
348
682
  }
349
683
  /**
350
684
  * Pure read-only availability check for user-topic adoption. Rejects invalid
@@ -381,8 +715,13 @@ export class TopicRegistry {
381
715
  const creating = [...this.creatingBindings].filter(
382
716
  ([sessionId, claim]) => canClaim(claim) && !excludesClaimant(sessionId),
383
717
  );
384
- if (committed.length === 0 && staged.length === 0 && creating.length === 0) return { state: "none" };
385
- if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0) return { state: "ambiguous" };
718
+ const durableClaims = [...this.createClaims].filter(
719
+ ([sessionId, claim]) => claim.binding !== undefined && canClaim(claim.binding) && !excludesClaimant(sessionId),
720
+ );
721
+ if (committed.length === 0 && staged.length === 0 && creating.length === 0 && durableClaims.length === 0)
722
+ return { state: "none" };
723
+ if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0 || durableClaims.length !== 0)
724
+ return { state: "ambiguous" };
386
725
  const [sessionId, record] = committed[0]!;
387
726
  return record.chatId === binding.chatId &&
388
727
  record.endpointKey === binding.endpointKey &&
@@ -411,6 +750,30 @@ export class TopicRegistry {
411
750
  );
412
751
  }
413
752
 
753
+ /**
754
+ * Retire a remotely settled inactive topic only when an authenticated
755
+ * successor proves a different endpoint authority. The inactive predecessor
756
+ * is serialized into retained history before the active slot is released.
757
+ */
758
+ retireInactiveEndpointForSuccessor(sessionId: string, binding: TopicEndpointBinding): boolean {
759
+ const record = this.topics.get(sessionId);
760
+ if (
761
+ record?.authorityState !== "inactive" ||
762
+ record.bindingMalformed ||
763
+ !hasCompleteBinding(record) ||
764
+ !hasValidBinding(binding) ||
765
+ (record.endpointKey === binding.endpointKey && record.endpointDigest === binding.endpointDigest)
766
+ )
767
+ return false;
768
+ const history = this.retiredTopics.get(sessionId) ?? [];
769
+ history.push({ ...record });
770
+ this.retiredTopics.set(sessionId, history);
771
+ this.topics.delete(sessionId);
772
+ this.byTopic.delete(record.topicId);
773
+ this.archiveJobs.delete(sessionId);
774
+ this.createClaims.delete(sessionId);
775
+ return true;
776
+ }
414
777
  /**
415
778
  * Rebind an existing topic to an authenticated successor endpoint. The exact
416
779
  * logical session id is proved by replay before this method is called. A
@@ -426,11 +789,10 @@ export class TopicRegistry {
426
789
  ): "bound" | "unchanged" | "rejected" {
427
790
  const record = this.topics.get(sessionId);
428
791
  if (
429
- !record ||
430
- record.authorityState === "delete_pending" ||
792
+ (record?.authorityState !== "active" && record?.authorityState !== "disconnect_grace") ||
431
793
  record.bindingMalformed ||
432
794
  !hasValidBinding(binding) ||
433
- !this.isActiveUnambiguous(sessionId)
795
+ (record.authorityState === "active" && !this.isActiveUnambiguous(sessionId))
434
796
  )
435
797
  return "rejected";
436
798
  if (hasAnyBinding(record) && !hasCompleteBinding(record)) return "rejected";
@@ -460,12 +822,18 @@ export class TopicRegistry {
460
822
  record.endpointKey !== binding.endpointKey ||
461
823
  record.endpointDigest !== binding.endpointDigest ||
462
824
  record.endpointGeneration !== binding.endpointGeneration;
463
- if (!changed) return "unchanged";
825
+ if (!changed && record.authorityState === "active") return "unchanged";
464
826
  record.chatId = binding.chatId;
465
827
  record.endpointKey = binding.endpointKey;
466
828
  record.endpointDigest = binding.endpointDigest;
467
829
  record.endpointGeneration = binding.endpointGeneration;
468
830
  if (!sameEndpoint) record.endpointIncarnation = (record.endpointIncarnation ?? 0) + 1;
831
+ if (record.authorityState === "disconnect_grace") {
832
+ record.authorityState = "active";
833
+ delete record.orphanedAt;
834
+ delete record.disconnectGraceExpiresAt;
835
+ this.rebuildInboundRoutes();
836
+ }
469
837
  return "bound";
470
838
  }
471
839
 
@@ -512,29 +880,44 @@ export class TopicRegistry {
512
880
  topicOrigin?: TopicRecord["topicOrigin"],
513
881
  ): Promise<TopicRecord> {
514
882
  const existing = this.topics.get(sessionId);
515
- if (existing?.authorityState === "delete_pending") throw new Error("topic authority is deletion-fenced");
883
+ if (existing && existing.authorityState !== "active") throw new Error("topic authority is archive-fenced");
516
884
  if (existing?.bindingMalformed) throw new Error("topic authority binding is quarantined");
517
885
  if (existing) return existing;
518
886
  const pending = this.inflight.get(sessionId);
519
887
  if (pending) return pending;
888
+ // A claim loaded after a crash proves a create may have reached Telegram.
889
+ // It must not be replaced or retried until explicit authoritative recovery.
890
+ if (this.createClaims.has(sessionId)) throw new Error("topic create claim requires reconciliation");
520
891
  const epoch = this.epochs.get(sessionId) ?? 0;
521
- if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch exhausted");
892
+ if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch is exhausted");
522
893
  // Publish the compatible endpoint claim before invoking `create`: the callback
523
894
  // may immediately begin a remote create and identity-less recovery must never
524
895
  // observe a false absence during that await.
525
896
  if (binding) this.creatingBindings.set(sessionId, binding);
526
897
  this.transientClaimants.set(sessionId, transientClaimant);
898
+ this.createClaims.set(sessionId, {
899
+ sessionId,
900
+ authorityEpoch: epoch,
901
+ createdAt: now(),
902
+ ...(binding ? { binding } : {}),
903
+ });
527
904
  const promise = (async () => {
905
+ // Persist this ambiguity before createForumTopic; a crash must fence,
906
+ // rather than silently permit, a duplicate remote topic.
907
+ await commit?.();
528
908
  const topicId = await create();
529
909
  if (!isValidTopicId(topicId)) throw new Error("createForumTopic: invalid message_thread_id");
530
910
  const revoked = (this.epochs.get(sessionId) ?? 0) !== epoch;
531
911
  const record: TopicRecord = {
532
912
  topicId,
913
+ topicOrigin: topicOrigin ?? "daemon_created",
914
+ sessionUuid: randomUUID(),
533
915
  name,
534
916
  identitySent: false,
535
917
  createdAt: now(),
536
918
  authorityEpoch: revoked ? (this.epochs.get(sessionId) ?? 0) : epoch,
537
919
  creationLeaseEpoch: epoch,
920
+ authorityState: revoked ? "archive_pending" : "active",
538
921
  ...(binding
539
922
  ? {
540
923
  chatId: binding.chatId,
@@ -546,14 +929,13 @@ export class TopicRegistry {
546
929
  : { endpointGeneration: binding.endpointGeneration }),
547
930
  }
548
931
  : {}),
549
- ...(revoked ? { authorityState: "delete_pending" as const } : {}),
550
- ...(topicOrigin === "user_created" ? { topicOrigin: "user_created" as const } : {}),
551
932
  };
552
933
  if (revoked) {
553
934
  this.topics.set(sessionId, record);
554
935
  throw new Error("topic authority was revoked during creation");
555
936
  }
556
937
  this.staged.set(sessionId, record);
938
+ this.createClaims.delete(sessionId);
557
939
  try {
558
940
  await commit?.();
559
941
  } catch (error) {
@@ -563,7 +945,7 @@ export class TopicRegistry {
563
945
  this.staged.delete(sessionId);
564
946
  if ((this.epochs.get(sessionId) ?? 0) !== epoch) {
565
947
  record.authorityEpoch = this.epochs.get(sessionId) ?? 0;
566
- record.authorityState = "delete_pending";
948
+ record.authorityState = "archive_pending";
567
949
  this.topics.set(sessionId, record);
568
950
  throw new Error("topic authority was revoked during creation");
569
951
  }
@@ -584,6 +966,7 @@ export class TopicRegistry {
584
966
  this.inflight.delete(sessionId);
585
967
  this.creatingBindings.delete(sessionId);
586
968
  this.transientClaimants.delete(sessionId);
969
+ if (this.topics.has(sessionId)) this.createClaims.delete(sessionId);
587
970
  }
588
971
  }
589
972
 
@@ -592,6 +975,68 @@ export class TopicRegistry {
592
975
  const record = this.topics.get(sessionId);
593
976
  if (record) record.identitySent = true;
594
977
  }
978
+ /** Generation used as the compare value for a shared CAS publication. */
979
+ registryVersion(): number {
980
+ return this.registryGeneration;
981
+ }
982
+
983
+ /** Advance only after a successful shared compare-and-set publication. */
984
+ markRegistryPublished(generation: number): void {
985
+ if (!Number.isSafeInteger(generation) || generation < this.registryGeneration)
986
+ throw new Error("invalid topic registry generation");
987
+ this.registryGeneration = generation;
988
+ }
989
+
990
+ /**
991
+ * Acquire or renew a host lease. Another unexpired host is never displaced;
992
+ * a disconnected owner may resume the exact topic during its grace window.
993
+ */
994
+ acquireLease(sessionId: string, hostId: string, now: number, ttlMs: number, graceMs: number): boolean {
995
+ const record = this.topics.get(sessionId);
996
+ if (
997
+ !record ||
998
+ !isValidBindingString(hostId) ||
999
+ !Number.isFinite(now) ||
1000
+ ttlMs <= 0 ||
1001
+ graceMs < 0 ||
1002
+ record.authorityState === "archive_pending" ||
1003
+ record.authorityState === "archive_exhausted" ||
1004
+ record.authorityState === "inactive" ||
1005
+ record.authorityState === "legacy_quarantined"
1006
+ )
1007
+ return false;
1008
+ if (record.leaseOwner !== undefined && record.leaseOwner !== hostId && (record.leaseExpiresAt ?? 0) > now)
1009
+ return false;
1010
+ if (
1011
+ record.authorityState === "disconnect_grace" &&
1012
+ record.disconnectGraceExpiresAt !== undefined &&
1013
+ record.disconnectGraceExpiresAt < now
1014
+ )
1015
+ return false;
1016
+ record.leaseOwner = hostId;
1017
+ record.leaseHeartbeatAt = now;
1018
+ record.leaseExpiresAt = now + ttlMs;
1019
+ if (record.authorityState === "disconnect_grace") {
1020
+ record.authorityState = "active";
1021
+ delete record.orphanedAt;
1022
+ delete record.disconnectGraceExpiresAt;
1023
+ this.rebuildInboundRoutes();
1024
+ }
1025
+ return true;
1026
+ }
1027
+
1028
+ /** Record a disconnect without losing the topic identity needed for a grace resume. */
1029
+ releaseLeaseToGrace(sessionId: string, hostId: string, now: number, graceMs: number): boolean {
1030
+ const record = this.topics.get(sessionId);
1031
+ if (!record || record.leaseOwner !== hostId || record.authorityState !== "active" || graceMs < 0) return false;
1032
+ record.authorityState = "disconnect_grace";
1033
+ record.orphanedAt = now;
1034
+ record.leaseHeartbeatAt = now;
1035
+ record.leaseExpiresAt = now;
1036
+ record.disconnectGraceExpiresAt = now + graceMs;
1037
+ this.rebuildInboundRoutes();
1038
+ return true;
1039
+ }
595
1040
 
596
1041
  /** Whether the identity header still needs sending for this session. */
597
1042
  needsIdentity(sessionId: string): boolean {
@@ -609,15 +1054,20 @@ export class TopicRegistry {
609
1054
  /** Start the orphan grace clock on the first positive liveness-loss observation. */
610
1055
  markOrphaned(sessionId: string, now: number): boolean {
611
1056
  const record = this.topics.get(sessionId);
612
- if (!record || record.orphanedAt !== undefined) return false;
1057
+ if (record?.authorityState !== "active" || record.orphanedAt !== undefined) return false;
613
1058
  record.orphanedAt = now;
1059
+ record.authorityState = "disconnect_grace";
1060
+ record.disconnectGraceExpiresAt = now + 30_000;
1061
+ this.rebuildInboundRoutes();
614
1062
  return true;
615
1063
  }
616
1064
 
617
1065
  /** Clear a prior orphan observation after the endpoint is positively live again. */
618
1066
  clearOrphaned(sessionId: string): boolean {
619
1067
  const record = this.topics.get(sessionId);
620
- if (!record || record.orphanedAt === undefined) return false;
1068
+ if (record?.authorityState !== "disconnect_grace" || record.orphanedAt === undefined) return false;
1069
+ record.authorityState = "active";
1070
+ delete record.disconnectGraceExpiresAt;
621
1071
  delete record.orphanedAt;
622
1072
  return true;
623
1073
  }
@@ -697,8 +1147,8 @@ export class TopicRegistry {
697
1147
  record.nameReconcilePending = false;
698
1148
  }
699
1149
 
700
- /** Capture only authority fields that a failed delete publication may restore. */
701
- captureDeleteAuthority(sessionId: string): TopicDeleteAuthoritySnapshot {
1150
+ /** Capture only authority fields that a failed archive-fence publication may restore. */
1151
+ captureArchiveAuthority(sessionId: string): TopicArchiveAuthoritySnapshot {
702
1152
  const record = this.topics.get(sessionId);
703
1153
  return {
704
1154
  sessionId,
@@ -710,8 +1160,8 @@ export class TopicRegistry {
710
1160
  };
711
1161
  }
712
1162
 
713
- /** Restore a failed delete fence only while its exact authority mutation remains current. */
714
- restoreDeleteAuthority(snapshot: TopicDeleteAuthoritySnapshot): boolean {
1163
+ /** Restore a failed archive fence only while its exact authority mutation remains current. */
1164
+ restoreArchiveAuthority(snapshot: TopicArchiveAuthoritySnapshot): boolean {
715
1165
  const record = this.topics.get(snapshot.sessionId);
716
1166
  const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0);
717
1167
  if (authorityBase >= Number.MAX_SAFE_INTEGER) return false;
@@ -722,7 +1172,7 @@ export class TopicRegistry {
722
1172
  } else if (
723
1173
  !record ||
724
1174
  record.topicId !== snapshot.topicId ||
725
- record.authorityState !== "delete_pending" ||
1175
+ record.authorityState !== "archive_pending" ||
726
1176
  record.authorityEpoch !== deleteEpoch
727
1177
  ) {
728
1178
  return false;
@@ -736,17 +1186,13 @@ export class TopicRegistry {
736
1186
  return true;
737
1187
  }
738
1188
 
739
- /**
740
- * Restore the exact delete fence after a failed compensation publication.
741
- *
742
- * Settlement rebuilds derived routes, which can make a surviving colliding
743
- * record routable. Rebuild again on successful restoration so the reinstated
744
- * fence re-quarantines the topic id instead of leaving inbound routing open to
745
- * the collision partner despite the restored fence.
746
- */
747
- restoreDeleteFence(snapshot: TopicDeleteAuthoritySnapshot): boolean {
1189
+ /** Restore the exact archive fence after a failed compensation publication. */
1190
+ restoreArchiveFence(snapshot: TopicArchiveAuthoritySnapshot): boolean {
748
1191
  const record = this.topics.get(snapshot.sessionId);
749
- const deleteEpoch = nextAuthorityEpoch(Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0));
1192
+ const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0);
1193
+ if (authorityBase >= Number.MAX_SAFE_INTEGER) return false;
1194
+ const deleteEpoch = nextAuthorityEpoch(authorityBase);
1195
+ if (this.epochs.get(snapshot.sessionId) !== deleteEpoch) return false;
750
1196
  if (snapshot.topicId === undefined) {
751
1197
  if (record) return false;
752
1198
  } else if (!record) {
@@ -754,32 +1200,74 @@ export class TopicRegistry {
754
1200
  this.topics.set(snapshot.sessionId, {
755
1201
  ...snapshot.record,
756
1202
  authorityEpoch: deleteEpoch,
757
- authorityState: "delete_pending",
1203
+ authorityState: "archive_pending",
758
1204
  });
759
1205
  } else if (record.topicId !== snapshot.topicId) {
760
1206
  return false;
761
1207
  } else {
762
1208
  record.authorityEpoch = deleteEpoch;
763
- record.authorityState = "delete_pending";
1209
+ record.authorityState = "archive_pending";
1210
+ if (this.byTopic.get(record.topicId) === snapshot.sessionId) this.byTopic.delete(record.topicId);
764
1211
  }
765
1212
  this.epochs.set(snapshot.sessionId, deleteEpoch);
766
- this.rebuildInboundRoutes();
767
1213
  return true;
768
1214
  }
769
1215
 
770
- /** Fence new work before the remote delete starts, including an absent in-flight create. */
771
- beginDelete(sessionId: string): TopicRecord | undefined {
1216
+ /** Fence new work before the remote archive starts, including an absent in-flight create. */
1217
+ beginArchive(sessionId: string, hostId?: string, now = Date.now()): TopicRecord | undefined {
772
1218
  const record = this.topics.get(sessionId);
773
- const epoch = this.#advanceAuthorityEpoch(
774
- sessionId,
775
- Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0),
776
- );
1219
+ if (
1220
+ record?.topicOrigin === "user_created" ||
1221
+ ((record?.archiveHostId !== undefined || record?.leaseOwner !== undefined) &&
1222
+ record.archiveHostId !== hostId &&
1223
+ record.leaseOwner !== hostId &&
1224
+ (record.leaseExpiresAt ?? 0) > now)
1225
+ )
1226
+ return undefined;
1227
+ const authorityBase = Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0);
1228
+ if (authorityBase >= Number.MAX_SAFE_INTEGER) {
1229
+ this.epochs.set(sessionId, Number.MAX_SAFE_INTEGER);
1230
+ if (record) {
1231
+ record.authorityEpoch = Number.MAX_SAFE_INTEGER;
1232
+ record.authorityState = "archive_exhausted";
1233
+ if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
1234
+ }
1235
+ return undefined;
1236
+ }
1237
+ const epoch = nextAuthorityEpoch(authorityBase);
1238
+ this.epochs.set(sessionId, epoch);
777
1239
  if (!record) return undefined;
778
1240
  record.authorityEpoch = epoch;
779
- record.authorityState = "delete_pending";
1241
+ record.authorityState = "archive_pending";
1242
+ if (hostId) record.archiveHostId = hostId;
1243
+ record.archiveLeaseEpoch = epoch;
780
1244
  if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
781
1245
  return record;
782
1246
  }
1247
+ /** Verify the durable archive initiator immediately before remote dispatch. */
1248
+ archiveAuthorityAllows(sessionId: string, hostId: string, pairedChatId: string, now: number): boolean;
1249
+ /** @deprecated Production dispatch must provide the current paired chat id. */
1250
+ archiveAuthorityAllows(sessionId: string, hostId: string, now: number): boolean;
1251
+ archiveAuthorityAllows(
1252
+ sessionId: string,
1253
+ hostId: string,
1254
+ pairedChatIdOrNow: string | number,
1255
+ suppliedNow?: number,
1256
+ ): boolean {
1257
+ const record = this.topics.get(sessionId);
1258
+ const pairedChatId = typeof pairedChatIdOrNow === "string" ? pairedChatIdOrNow : record?.chatId;
1259
+ const now = typeof pairedChatIdOrNow === "number" ? pairedChatIdOrNow : suppliedNow;
1260
+ return (
1261
+ record?.topicOrigin === "daemon_created" &&
1262
+ record.chatId === pairedChatId &&
1263
+ typeof now === "number" &&
1264
+ record.authorityState === "archive_pending" &&
1265
+ record.archiveHostId === hostId &&
1266
+ record.archiveLeaseEpoch === record.authorityEpoch &&
1267
+ (record.authorityEpoch ?? Number.MAX_SAFE_INTEGER) < Number.MAX_SAFE_INTEGER &&
1268
+ (record.leaseOwner === undefined || record.leaseOwner === hostId || (record.leaseExpiresAt ?? 0) <= now)
1269
+ );
1270
+ }
783
1271
 
784
1272
  /** Retain an accepted create as deletion-fenced before remote compensation can begin. */
785
1273
  fenceAcceptedCreate(
@@ -788,15 +1276,19 @@ export class TopicRegistry {
788
1276
  now: () => number = Date.now,
789
1277
  name?: string,
790
1278
  binding?: TopicEndpointBinding,
1279
+ topicOrigin?: TopicRecord["topicOrigin"],
1280
+ archiveChatId?: string,
791
1281
  ): TopicRecord {
792
1282
  const epoch = Math.max(this.epochs.get(sessionId) ?? 0, this.topics.get(sessionId)?.authorityEpoch ?? 0);
793
1283
  const record: TopicRecord = {
794
1284
  topicId,
1285
+ topicOrigin: topicOrigin ?? this.topics.get(sessionId)?.topicOrigin ?? "daemon_created",
1286
+ sessionUuid: randomUUID(),
795
1287
  name,
796
1288
  identitySent: false,
797
1289
  createdAt: now(),
798
1290
  authorityEpoch: epoch,
799
- authorityState: "delete_pending",
1291
+ authorityState: "archive_pending",
800
1292
  ...(binding
801
1293
  ? {
802
1294
  chatId: binding.chatId,
@@ -807,7 +1299,9 @@ export class TopicRegistry {
807
1299
  ? {}
808
1300
  : { endpointGeneration: binding.endpointGeneration }),
809
1301
  }
810
- : {}),
1302
+ : archiveChatId
1303
+ ? { chatId: archiveChatId }
1304
+ : {}),
811
1305
  };
812
1306
  this.topics.set(sessionId, record);
813
1307
  if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
@@ -820,9 +1314,12 @@ export class TopicRegistry {
820
1314
  sessionId: string,
821
1315
  topicId: string,
822
1316
  creationLeaseEpoch: number,
1317
+ hostId: string,
823
1318
  now: () => number = Date.now,
824
1319
  name?: string,
825
1320
  binding?: TopicEndpointBinding,
1321
+ topicOrigin?: TopicRecord["topicOrigin"],
1322
+ archiveChatId?: string,
826
1323
  ): TopicRecord | undefined {
827
1324
  const record = this.topics.get(sessionId);
828
1325
  const matchesBinding =
@@ -836,9 +1333,12 @@ export class TopicRegistry {
836
1333
  : (this.epochs.get(sessionId) ?? 0) !== creationLeaseEpoch
837
1334
  )
838
1335
  return undefined;
839
- this.beginDelete(sessionId);
840
- const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding);
1336
+ const archiveFence = this.beginArchive(sessionId, hostId, now());
1337
+ if (record && !archiveFence) return undefined;
1338
+ const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding, topicOrigin, archiveChatId);
841
1339
  fenced.creationLeaseEpoch = creationLeaseEpoch;
1340
+ fenced.archiveHostId = hostId;
1341
+ fenced.archiveLeaseEpoch = fenced.authorityEpoch;
842
1342
  return fenced;
843
1343
  }
844
1344
 
@@ -848,99 +1348,92 @@ export class TopicRegistry {
848
1348
  }
849
1349
 
850
1350
  /**
851
- * Phase 1 of settling a definite remote delete: remove the record while
852
- * deliberately RETAINING its topic-id quarantine.
853
- *
854
- * `dispatchedAuthorityEpoch` is the authority epoch the caller held when it
855
- * dispatched the remote delete. Settlement requires that epoch to still equal
856
- * both the record's own authority epoch and the registry's current epoch for
857
- * the session, so a held earlier delete can never settle a newer fence: if a
858
- * scan or close-started delete re-fenced the same session/topic after this
859
- * delete was dispatched, the stale definite result is refused and the newer
860
- * `delete_pending` record plus its topic-id quarantine stay intact.
861
- *
862
- * An epoch outside the safe-integer range cannot identify a generation, and at
863
- * `Number.MAX_SAFE_INTEGER` epoch advancement has saturated so distinct
864
- * generations are no longer distinguishable. Both fail closed: settlement can
865
- * no longer be proven fresh, so the fence is kept.
866
- *
867
- * Derived routing tables are deliberately NOT rebuilt here. Until the cleared
868
- * snapshot is durably persisted the clear is not authoritative, so publishing
869
- * routes now would make a colliding survivor routable (and the settled id
870
- * adoptable) during the held write, and a failed persist would re-quarantine
871
- * too late. Publish with {@link commitSettledDelete} once the persist
872
- * succeeds, or undo with {@link rollbackSettledDelete} when it fails.
1351
+ * Retain a topic record after a definite remote archive only while the exact
1352
+ * dispatched authority epoch is still current.
873
1353
  */
874
- settleDelete(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): TopicSettledDelete | undefined {
875
- if (!Number.isSafeInteger(dispatchedAuthorityEpoch) || dispatchedAuthorityEpoch < 0) return undefined;
876
- if (this.#settling.has(sessionId)) return undefined;
1354
+ settleArchive(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): boolean {
1355
+ if (
1356
+ !Number.isSafeInteger(dispatchedAuthorityEpoch) ||
1357
+ dispatchedAuthorityEpoch < 0 ||
1358
+ dispatchedAuthorityEpoch >= Number.MAX_SAFE_INTEGER
1359
+ )
1360
+ return false;
877
1361
  const record = this.topics.get(sessionId);
878
- if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return undefined;
879
- if ((record.authorityEpoch ?? 0) !== dispatchedAuthorityEpoch) return undefined;
880
- const currentEpoch = this.authorityEpoch(sessionId);
881
- if (currentEpoch !== dispatchedAuthorityEpoch) return undefined;
882
- if (currentEpoch >= Number.MAX_SAFE_INTEGER) return undefined;
883
- this.topics.delete(sessionId);
884
- this.#settling.set(sessionId, { topicId, settledEpoch: dispatchedAuthorityEpoch, record: { ...record } });
885
- this.#ambiguousTopicIds.add(topicId);
886
- if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
887
- return { sessionId, topicId, settledEpoch: dispatchedAuthorityEpoch };
888
- }
889
-
890
- /**
891
- * Phase 2: publish derived routing tables once the cleared state is durable.
892
- *
893
- * Only here does the settled topic id lose its quarantine, so a surviving
894
- * colliding record becomes routable and a settled id becomes adoptable without
895
- * waiting for a daemon restart.
896
- */
897
- commitSettledDelete(settled: TopicSettledDelete): boolean {
898
- const pending = this.#settling.get(settled.sessionId);
899
- if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch)
1362
+ if (
1363
+ !record ||
1364
+ record.topicId !== topicId ||
1365
+ record.authorityState !== "archive_pending" ||
1366
+ record.authorityEpoch !== dispatchedAuthorityEpoch ||
1367
+ this.authorityEpoch(sessionId) !== dispatchedAuthorityEpoch
1368
+ )
900
1369
  return false;
901
- this.#settling.delete(settled.sessionId);
902
- this.rebuildInboundRoutes();
1370
+ record.authorityState = "inactive";
1371
+ if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
1372
+ this.archiveJobs.delete(sessionId);
903
1373
  return true;
904
1374
  }
905
1375
 
906
- /**
907
- * Compare-and-swap undo for a settlement whose durable clear failed.
908
- *
909
- * Restoration applies only while the registry still holds exactly the state
910
- * that this settlement produced: the settlement is still awaiting commit, the
911
- * session still has no record, and the session epoch is still the settled
912
- * epoch. Any mismatch means a newer generation intervened, so the restore is
913
- * refused and the newer state is left untouched. A refused settlement produced
914
- * no token, so it can never reach this path.
915
- */
916
- rollbackSettledDelete(settled: TopicSettledDelete): boolean {
917
- const pending = this.#settling.get(settled.sessionId);
918
- if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch)
919
- return false;
920
- if (this.topics.has(settled.sessionId)) return false;
921
- if (this.authorityEpoch(settled.sessionId) !== settled.settledEpoch) return false;
922
- this.#settling.delete(settled.sessionId);
923
- this.topics.set(settled.sessionId, {
924
- ...pending.record,
925
- authorityEpoch: settled.settledEpoch,
926
- authorityState: "delete_pending",
1376
+ /** Durable archive jobs that are eligible for a retry at `now`. */
1377
+ archivePendingSessionIds(now = Date.now()): string[] {
1378
+ return [...this.topics].flatMap(([sessionId, record]) => {
1379
+ if (record.authorityState === "archive_exhausted") {
1380
+ const currentEpoch = Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0);
1381
+ if (currentEpoch < Number.MAX_SAFE_INTEGER) {
1382
+ const epoch = Math.min(currentEpoch + 1, Number.MAX_SAFE_INTEGER - 1);
1383
+ record.authorityEpoch = epoch;
1384
+ record.archiveLeaseEpoch = epoch;
1385
+ record.authorityState = "archive_pending";
1386
+ }
1387
+ }
1388
+ return (record.authorityState === "archive_pending" || record.authorityState === "archive_exhausted") &&
1389
+ (this.archiveJobs.get(sessionId)?.nextAttemptAt ?? 0) <= now
1390
+ ? [sessionId]
1391
+ : [];
927
1392
  });
928
- this.epochs.set(settled.sessionId, settled.settledEpoch);
929
- this.rebuildInboundRoutes();
930
- return true;
1393
+ }
1394
+ /** Durable/manual recovery candidates that exceeded the automatic archive retry budget. */
1395
+ archiveExhaustedSessionIds(): string[] {
1396
+ return [...this.topics].flatMap(([sessionId, record]) =>
1397
+ record.authorityState === "archive_exhausted" ? [sessionId] : [],
1398
+ );
931
1399
  }
932
1400
 
933
- /** Remove a topic record immediately for local/test cleanup compatibility. */
934
- delete(sessionId: string): boolean {
1401
+ /** Persist an indefinitely discoverable retry after an ambiguous archive result. */
1402
+ scheduleArchiveRetry(sessionId: string, now: number, diagnostic?: string): ArchiveJob | undefined {
935
1403
  const record = this.topics.get(sessionId);
936
- if (!record) return false;
937
- this.#advanceAuthorityEpoch(sessionId, Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0));
938
- if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
939
- return this.topics.delete(sessionId);
1404
+ if (record?.authorityState !== "archive_pending" && record?.authorityState !== "archive_exhausted")
1405
+ return undefined;
1406
+ const previous = this.archiveJobs.get(sessionId);
1407
+ const firstAttemptAt = previous?.firstAttemptAt ?? now;
1408
+ const attempt = (previous?.attempt ?? 0) + 1;
1409
+ const exhausted = attempt > 8 || now - firstAttemptAt > 24 * 60 * 60 * 1000;
1410
+ const effectiveAttempt = Math.min(attempt, 8);
1411
+ const backoffMs = exhausted ? 1_000 : Math.min(60_000, 250 * 2 ** effectiveAttempt);
1412
+ const job = {
1413
+ sessionId,
1414
+ topicId: record.topicId,
1415
+ attempt: effectiveAttempt,
1416
+ firstAttemptAt,
1417
+ backoffMs,
1418
+ nextAttemptAt: now + backoffMs,
1419
+ ...(diagnostic ? { safeDiagnostic: diagnostic.slice(0, 256) } : {}),
1420
+ ...(exhausted ? { safeDiagnostic: "archive retry remains discoverable after retry budget" } : {}),
1421
+ };
1422
+ record.authorityState = "archive_pending";
1423
+ this.archiveJobs.set(sessionId, job);
1424
+ return job;
940
1425
  }
941
1426
 
942
1427
  /** Serialise active records plus unpublished staged creates for atomic commit. */
943
1428
  serialize(): TopicRegistryState {
944
- return { topics: Object.fromEntries([...this.topics, ...this.staged]), fences: Object.fromEntries(this.epochs) };
1429
+ return {
1430
+ version: 2,
1431
+ registryGeneration: this.registryGeneration,
1432
+ topics: Object.fromEntries([...this.topics, ...this.staged]),
1433
+ fences: Object.fromEntries(this.epochs),
1434
+ archiveJobs: Object.fromEntries(this.archiveJobs),
1435
+ createClaims: Object.fromEntries(this.createClaims),
1436
+ retiredTopics: Object.fromEntries(this.retiredTopics),
1437
+ };
945
1438
  }
946
1439
  }