@wangmo-bit/ds-link 0.0.0-stage → 0.8.18

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/TESTING.md +59 -0
  4. package/THIRD_PARTY_NOTICES.md +313 -0
  5. package/assets/icon.svg +7 -0
  6. package/cordis.patch.yml +3 -0
  7. package/dist/client.js +6012 -0
  8. package/package.json +102 -4
  9. package/src/core/constants.js +188 -0
  10. package/src/core/downgrade.js +80 -0
  11. package/src/core/engine.js +4669 -0
  12. package/src/core/errors.js +51 -0
  13. package/src/core/ledger.js +1174 -0
  14. package/src/core/migration.js +741 -0
  15. package/src/core/render.js +61 -0
  16. package/src/core/settings.js +147 -0
  17. package/src/core/validation.js +454 -0
  18. package/src/host/adapter.js +1152 -0
  19. package/src/host/connection.js +113 -0
  20. package/src/host/entry-0.2.0.js +13 -0
  21. package/src/host/entry-0.3.0.js +13 -0
  22. package/src/host/entry-0.4.0.js +13 -0
  23. package/src/host/entry-0.8.10.js +13 -0
  24. package/src/host/entry-0.8.11.js +13 -0
  25. package/src/host/entry-0.8.12.js +13 -0
  26. package/src/host/entry-0.8.13.js +13 -0
  27. package/src/host/entry-0.8.14.js +13 -0
  28. package/src/host/entry-0.8.15.js +13 -0
  29. package/src/host/entry-0.8.16-pre.1.js +13 -0
  30. package/src/host/entry-0.8.16-pre.2.js +13 -0
  31. package/src/host/entry-0.8.16-pre.3.js +13 -0
  32. package/src/host/entry-0.8.16-pre.4.js +13 -0
  33. package/src/host/entry-0.8.16-pre.5.js +13 -0
  34. package/src/host/entry-0.8.18.js +13 -0
  35. package/src/host/entry-0.8.5.js +13 -0
  36. package/src/host/entry-0.8.6.js +13 -0
  37. package/src/host/entry-0.8.7.js +13 -0
  38. package/src/host/entry-0.8.8.js +13 -0
  39. package/src/host/entry-0.8.9.js +13 -0
  40. package/src/host/index.js +74 -0
  41. package/src/host/plugin.js +403 -0
  42. package/src/host/prompt.js +22 -0
  43. package/src/host/tools.js +312 -0
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Frozen schema chain: v0.1 validation, v1 -> v2 conversion, v2 -> v3 conversion.
3
+ *
4
+ * This module intentionally duplicates old-format rules with literal values.
5
+ * It must never read the current STATE_VERSION for a historical decision: a
6
+ * later protocol change (including 0.3's schema 3) must not silently redefine
7
+ * what v1/v2 data was allowed to contain or how it converted. V2_STATE_VERSION
8
+ * is frozen at 2 and every v2 validation/rollback path uses that literal, while
9
+ * the v0.3 conversion writes schema 3 explicitly.
10
+ */
11
+
12
+ import { createHash } from 'node:crypto'
13
+
14
+ import {
15
+ AUTO_HELD_REASONS,
16
+ RETIRED_CHAIN_PAUSE_REASONS,
17
+ V1_STATE_VERSION,
18
+ V2_BACKUP_PREFIX,
19
+ } from './constants.js'
20
+ import { fail } from './errors.js'
21
+ import { isPlainObject } from './validation.js'
22
+
23
+ /** Frozen schema version produced by the historical v1 -> v2 conversion. */
24
+ export const V2_STATE_VERSION = 2
25
+ /** Frozen schema version produced by the v0.3 v2 -> v3 conversion. */
26
+ export const V3_STATE_VERSION = 3
27
+
28
+ /** Frozen v0.1 settings vocabulary (nine keys, exactly as shipped in 0.1.0). */
29
+ export const V1_DEFAULT_SETTINGS = Object.freeze({
30
+ enabled: true,
31
+ emergencyPaused: false,
32
+ autoCollaborate: true,
33
+ maxMessages: 20,
34
+ maxHops: 8,
35
+ maxConcurrent: 2,
36
+ maxTextChars: 12000,
37
+ requestTimeoutMs: 30 * 60 * 1000,
38
+ retentionDays: 30,
39
+ })
40
+ export const V1_SETTINGS_KEYS = Object.freeze(Object.keys(V1_DEFAULT_SETTINGS))
41
+ const V1_SETTINGS_BOUNDS = Object.freeze({
42
+ maxMessages: { min: 2, max: 20 },
43
+ maxHops: { min: 1, max: 8 },
44
+ maxConcurrent: { min: 1, max: 2 },
45
+ maxTextChars: { min: 1000, max: 12000 },
46
+ requestTimeoutMs: { min: 60_000, max: 86_400_000 },
47
+ retentionDays: { min: 1, max: 90 },
48
+ })
49
+ const V1_MAX_MESSAGES = 10_000
50
+ const V1_CHAIN_MAX_MESSAGES = 20
51
+ const V1_CHAIN_MAX_HOPS = 8
52
+ const V1_DISPATCH_STATUSES = Object.freeze([
53
+ 'held',
54
+ 'pending',
55
+ 'dispatching',
56
+ 'accepted',
57
+ 'failed',
58
+ 'unknown',
59
+ 'cancelled',
60
+ ])
61
+ const V1_REPLY_STATUSES = Object.freeze(['none', 'waiting', 'answered', 'timed_out'])
62
+ const V1_MESSAGE_KINDS = Object.freeze(['notification', 'request', 'reply'])
63
+ const V1_CHAIN_STATES = Object.freeze(['open', 'paused', 'closed'])
64
+
65
+ /** @param {unknown} error @returns {never} */
66
+ function corrupt(message) {
67
+ throw fail('storage_corrupt', message)
68
+ }
69
+
70
+ /** @param {unknown} value @param {string} label */
71
+ function record(value, label) {
72
+ if (!isPlainObject(value)) corrupt(`${label} is invalid`)
73
+ return value
74
+ }
75
+
76
+ /** @param {unknown} value @param {string} label */
77
+ function nonEmptyString(value, label) {
78
+ if (typeof value !== 'string' || value.length === 0) corrupt(`${label} is invalid`)
79
+ }
80
+
81
+ /** @param {unknown} value @param {string} label @param {number} [min] */
82
+ function safeInteger(value, label, min = 0) {
83
+ if (!Number.isSafeInteger(value) || value < min) corrupt(`${label} is invalid`)
84
+ }
85
+
86
+ /** @param {unknown} value @param {string} label */
87
+ function optionalTime(value, label) {
88
+ if (value === undefined || value === null) return
89
+ safeInteger(value, label)
90
+ }
91
+
92
+ /** @param {unknown} value */
93
+ function validateV1Settings(value) {
94
+ const settings = record(value, 'ledger settings')
95
+ for (const key of V1_SETTINGS_KEYS) {
96
+ if (!(key in settings)) corrupt(`ledger settings are missing ${key}`)
97
+ }
98
+ for (const key of ['enabled', 'emergencyPaused', 'autoCollaborate']) {
99
+ if (typeof settings[key] !== 'boolean') corrupt('ledger settings are invalid')
100
+ }
101
+ for (const [key, bounds] of Object.entries(V1_SETTINGS_BOUNDS)) {
102
+ const number = settings[key]
103
+ if (!Number.isSafeInteger(number) || number < bounds.min || number > bounds.max) {
104
+ corrupt('ledger settings are invalid')
105
+ }
106
+ }
107
+ return {
108
+ enabled: settings.enabled,
109
+ emergencyPaused: settings.emergencyPaused,
110
+ autoCollaborate: settings.autoCollaborate,
111
+ maxMessages: settings.maxMessages,
112
+ maxHops: settings.maxHops,
113
+ maxConcurrent: settings.maxConcurrent,
114
+ maxTextChars: settings.maxTextChars,
115
+ requestTimeoutMs: settings.requestTimeoutMs,
116
+ retentionDays: settings.retentionDays,
117
+ }
118
+ }
119
+
120
+ /** @param {unknown} budget */
121
+ function validateV1Budget(budget) {
122
+ const value = record(budget, 'ledger budget')
123
+ if (!Array.isArray(value.events)) corrupt('ledger budget events are invalid')
124
+ if (value.events.length > V1_MAX_MESSAGES) corrupt('ledger budget event list is oversized')
125
+ for (const [index, event] of value.events.entries()) {
126
+ const entry = record(event, `ledger budget event ${index}`)
127
+ safeInteger(entry.at, `ledger budget event ${index}.at`)
128
+ nonEmptyString(entry.from, `ledger budget event ${index}.from`)
129
+ nonEmptyString(entry.to, `ledger budget event ${index}.to`)
130
+ }
131
+ const lastSubmitAt = record(value.lastSubmitAt, 'ledger budget lastSubmitAt')
132
+ for (const [sessionId, at] of Object.entries(lastSubmitAt)) {
133
+ nonEmptyString(sessionId, 'ledger budget lastSubmitAt session')
134
+ safeInteger(at, `ledger budget lastSubmitAt ${sessionId}`)
135
+ }
136
+ const daily = record(value.daily, 'ledger budget daily')
137
+ for (const [day, count] of Object.entries(daily)) {
138
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) corrupt('ledger daily key is invalid')
139
+ safeInteger(count, `ledger daily ${day}`)
140
+ }
141
+ }
142
+
143
+ /** @param {unknown} latches */
144
+ function validateV1Latches(latches) {
145
+ if (!Array.isArray(latches)) corrupt('ledger latches are invalid')
146
+ const ids = new Set()
147
+ for (const latch of latches) {
148
+ const entry = record(latch, 'ledger latch')
149
+ nonEmptyString(entry.id, 'ledger latch id')
150
+ if (ids.has(entry.id)) corrupt('ledger latch id is duplicated')
151
+ ids.add(entry.id)
152
+ nonEmptyString(entry.scope, 'ledger latch scope')
153
+ nonEmptyString(entry.reason, 'ledger latch reason')
154
+ safeInteger(entry.createdAt, 'ledger latch createdAt')
155
+ optionalTime(entry.releasedAt, 'ledger latch releasedAt')
156
+ if (entry.releasedAt !== undefined && entry.releasedAt < entry.createdAt) {
157
+ corrupt('ledger latch release precedes creation')
158
+ }
159
+ }
160
+ }
161
+
162
+ /** @param {string} id @param {unknown} chain */
163
+ function validateV1Chain(id, chain) {
164
+ const value = record(chain, `ledger chain ${id}`)
165
+ if (value.id !== id) corrupt(`ledger chain ${id} id mismatch`)
166
+ nonEmptyString(value.rootOwner, `ledger chain ${id} rootOwner`)
167
+ safeInteger(value.createdAt, `ledger chain ${id} createdAt`)
168
+ if (!V1_CHAIN_STATES.includes(value.state)) corrupt(`ledger chain ${id} state is invalid`)
169
+ safeInteger(value.maxMessages, `ledger chain ${id} maxMessages`, 1)
170
+ if (value.maxMessages > V1_CHAIN_MAX_MESSAGES) corrupt(`ledger chain ${id} maxMessages is oversized`)
171
+ safeInteger(value.maxHops, `ledger chain ${id} maxHops`, 1)
172
+ if (value.maxHops > V1_CHAIN_MAX_HOPS) corrupt(`ledger chain ${id} maxHops is oversized`)
173
+ if (value.messageCount !== undefined) safeInteger(value.messageCount, `ledger chain ${id} messageCount`)
174
+ optionalTime(value.pausedAt, `ledger chain ${id} pausedAt`)
175
+ optionalTime(value.closedAt, `ledger chain ${id} closedAt`)
176
+ }
177
+
178
+ /**
179
+ * @param {unknown} message
180
+ * @param {Set<string>} chainIds
181
+ * @param {Set<string>} messageIds
182
+ * @param {Record<string, any>} chains
183
+ */
184
+ function validateV1Message(message, chainIds, messageIds, chains) {
185
+ const value = record(message, 'ledger message')
186
+ nonEmptyString(value.id, 'ledger message id')
187
+ if (messageIds.has(value.id)) corrupt('ledger message id is duplicated')
188
+ messageIds.add(value.id)
189
+ nonEmptyString(value.from, `ledger message ${value.id} from`)
190
+ nonEmptyString(value.to, `ledger message ${value.id} to`)
191
+ if (value.from === value.to) corrupt(`ledger message ${value.id} targets itself`)
192
+ if (!V1_MESSAGE_KINDS.includes(value.kind)) corrupt(`ledger message ${value.id} kind is invalid`)
193
+ nonEmptyString(value.chainId, `ledger message ${value.id} chainId`)
194
+ if (!chainIds.has(value.chainId)) corrupt(`ledger message ${value.id} references an unknown chain`)
195
+ safeInteger(value.hop, `ledger message ${value.id} hop`)
196
+ safeInteger(value.createdAt, `ledger message ${value.id} createdAt`)
197
+ if (!V1_DISPATCH_STATUSES.includes(value.status)) corrupt(`ledger message ${value.id} status is invalid`)
198
+ if (!V1_REPLY_STATUSES.includes(value.replyStatus)) corrupt(`ledger message ${value.id} replyStatus is invalid`)
199
+ if (value.operationId !== undefined) nonEmptyString(value.operationId, `ledger message ${value.id} operationId`)
200
+ if (value.deliveryAttempts !== undefined) safeInteger(value.deliveryAttempts, `ledger message ${value.id} deliveryAttempts`)
201
+ if (value.durableMessageId !== undefined && value.durableMessageId !== null) {
202
+ nonEmptyString(value.durableMessageId, `ledger message ${value.id} durableMessageId`)
203
+ }
204
+ const content = record(value.content, `ledger message ${value.id} content`)
205
+ if (typeof content.text !== 'string') corrupt(`ledger message ${value.id} content.text is invalid`)
206
+ if (!Array.isArray(content.references)) corrupt(`ledger message ${value.id} references are invalid`)
207
+ for (const [index, reference] of content.references.entries()) {
208
+ const entry = record(reference, `ledger message ${value.id} reference ${index}`)
209
+ nonEmptyString(entry.sessionId, `ledger message ${value.id} reference sessionId`)
210
+ safeInteger(entry.seq, `ledger message ${value.id} reference seq`)
211
+ if (typeof entry.text !== 'string') corrupt(`ledger message ${value.id} reference text is invalid`)
212
+ }
213
+ if (!Array.isArray(content.paths) || content.paths.some((item) => typeof item !== 'string')) {
214
+ corrupt(`ledger message ${value.id} paths are invalid`)
215
+ }
216
+ if (value.error !== undefined && value.error !== null) {
217
+ const error = record(value.error, `ledger message ${value.id} error`)
218
+ nonEmptyString(error.code, `ledger message ${value.id} error.code`)
219
+ if (typeof error.message !== 'string') corrupt(`ledger message ${value.id} error.message is invalid`)
220
+ }
221
+ for (const [field, time] of [
222
+ ['acceptedAt', value.acceptedAt],
223
+ ['deadlineAt', value.deadlineAt],
224
+ ['timedOutAt', value.timedOutAt],
225
+ ['lateReplyAt', value.lateReplyAt],
226
+ ['heldAt', value.heldAt],
227
+ ['cancelledAt', value.cancelledAt],
228
+ ['budgetReservedAt', value.budgetReservedAt],
229
+ ]) {
230
+ optionalTime(time, `ledger message ${value.id} ${field}`)
231
+ }
232
+ if (value.budgetReservedDay !== undefined) {
233
+ if (typeof value.budgetReservedDay !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.budgetReservedDay)) {
234
+ corrupt(`ledger message ${value.id} budgetReservedDay is invalid`)
235
+ }
236
+ }
237
+ const chain = chains[value.chainId]
238
+ if (value.hop > chain.maxHops) {
239
+ corrupt(`ledger message ${value.id} hop exceeds its chain budget`)
240
+ }
241
+ }
242
+
243
+ /** @param {string} key @param {unknown} operation */
244
+ function validateV1Operation(key, operation) {
245
+ const value = record(operation, `ledger operation ${key}`)
246
+ if (value.key !== undefined && value.key !== key) corrupt('ledger operation key mismatch')
247
+ nonEmptyString(value.from, `ledger operation ${key} from`)
248
+ nonEmptyString(value.operationId, `ledger operation ${key} operationId`)
249
+ nonEmptyString(value.fingerprint, `ledger operation ${key} fingerprint`)
250
+ if (!Array.isArray(value.targets) || value.targets.length === 0) {
251
+ corrupt(`ledger operation ${key} targets are invalid`)
252
+ }
253
+ if (new Set(value.targets).size !== value.targets.length) corrupt(`ledger operation ${key} targets are duplicated`)
254
+ for (const target of value.targets) nonEmptyString(target, `ledger operation ${key} target`)
255
+ const messageIds = record(value.messageIds, `ledger operation ${key} messageIds`)
256
+ for (const target of value.targets) nonEmptyString(messageIds[target], `ledger operation ${key} messageId`)
257
+ safeInteger(value.at, `ledger operation ${key} at`)
258
+ }
259
+
260
+ /**
261
+ * Validate one v0.1 ledger with the frozen v0.1 rules. Returns a detached copy
262
+ * so migration cannot mutate the parsed source or its raw backup.
263
+ * @param {unknown} state
264
+ * @returns {Record<string, any>}
265
+ */
266
+ export function validateV1State(state) {
267
+ if (!isPlainObject(state)) corrupt('ledger state is not an object')
268
+ const value = structuredClone(state)
269
+ if (value.version !== V1_STATE_VERSION) corrupt(`ledger uses unsupported state version ${String(value.version)}`)
270
+ if (!Number.isSafeInteger(value.revision) || value.revision < 1) corrupt('ledger revision is invalid')
271
+ if (!Number.isSafeInteger(value.createdAt) || value.createdAt < 0) corrupt('ledger createdAt is invalid')
272
+ if (!Number.isSafeInteger(value.updatedAt) || value.updatedAt < 0) corrupt('ledger updatedAt is invalid')
273
+ if (!Array.isArray(value.messages)) corrupt('ledger messages are invalid')
274
+ if (value.messages.length > V1_MAX_MESSAGES) corrupt('ledger message count is oversized')
275
+ if (!isPlainObject(value.chains)) corrupt('ledger chains are invalid')
276
+ if (!isPlainObject(value.operations)) corrupt('ledger operations are invalid')
277
+ if (!Array.isArray(value.latches)) corrupt('ledger latches are invalid')
278
+ value.settings = validateV1Settings(value.settings)
279
+ validateV1Budget(value.budget)
280
+ validateV1Latches(value.latches)
281
+ const chainIds = new Set(Object.keys(value.chains))
282
+ for (const [id, chain] of Object.entries(value.chains)) validateV1Chain(id, chain)
283
+ const messageIds = new Set()
284
+ const messagesById = new Map()
285
+ for (const message of value.messages) {
286
+ validateV1Message(message, chainIds, messageIds, value.chains)
287
+ messagesById.set(message.id, message)
288
+ }
289
+ for (const message of value.messages) {
290
+ if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
291
+ corrupt(`ledger message ${message.id} replyTo mismatch`)
292
+ }
293
+ for (const field of ['parentMessageId', 'replyTo']) {
294
+ const parentId = message[field]
295
+ if (parentId === undefined || parentId === null) continue
296
+ const parent = messagesById.get(parentId)
297
+ if (parent === undefined) corrupt(`ledger message ${message.id} ${field} is dangling`)
298
+ if (parent.chainId !== message.chainId) corrupt(`ledger message ${message.id} ${field} crosses chains`)
299
+ if (message.hop !== parent.hop + 1) corrupt(`ledger message ${message.id} hop does not follow its parent`)
300
+ if (message.kind === 'reply') {
301
+ if (parent.kind !== 'request' || parent.to !== message.from) {
302
+ corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
303
+ }
304
+ } else if (parent.from !== message.from && parent.to !== message.from) {
305
+ corrupt(`ledger message ${message.id} parent does not involve the sender`)
306
+ }
307
+ }
308
+ }
309
+ for (const [key, operation] of Object.entries(value.operations)) validateV1Operation(key, operation)
310
+ return value
311
+ }
312
+
313
+ /** @param {Record<string, any>} settings */
314
+ function mapV1Settings(settings) {
315
+ return {
316
+ enabled: settings.enabled,
317
+ paused: settings.emergencyPaused === true || settings.autoCollaborate === false,
318
+ maxConcurrent: settings.maxConcurrent === 2 ? 3 : settings.maxConcurrent,
319
+ requestTimeoutMs: settings.requestTimeoutMs,
320
+ retentionDays: settings.retentionDays,
321
+ }
322
+ }
323
+
324
+ /**
325
+ * v0.1 Engine.send can create a held row with only error.code and no
326
+ * heldReason (emergencyPaused/autoCollaborate=false). Normalize that real old
327
+ * shape to the v2 vocabulary, but never let error.code override an explicit
328
+ * heldReason (manual/custom/unknown stays conservative).
329
+ */
330
+ const V1_AUTO_HELD_ERROR_CODES = Object.freeze([
331
+ 'emergency_paused',
332
+ 'auto_collaborate_off',
333
+ 'disabled',
334
+ 'held_full',
335
+ ])
336
+
337
+ /** @param {Record<string, any>} message */
338
+ function normalizeHeldReason(message) {
339
+ if (message.status !== 'held') return
340
+ if (message.heldReason !== undefined && message.heldReason !== null) return
341
+ const code = message.error?.code
342
+ if (typeof code === 'string' && V1_AUTO_HELD_ERROR_CODES.includes(code)) {
343
+ message.heldReason = code
344
+ }
345
+ }
346
+
347
+ /** @param {Record<string, any>} message */
348
+ function isAutomaticGlobalHeld(message) {
349
+ if (message.status !== 'held') return false
350
+ if (message.heldReason !== undefined && message.heldReason !== null) {
351
+ return AUTO_HELD_REASONS.includes(message.heldReason)
352
+ }
353
+ if (message.heldOverflow === true) return true
354
+ const code = message.error?.code
355
+ return typeof code === 'string' && V1_AUTO_HELD_ERROR_CODES.includes(code)
356
+ }
357
+
358
+ /** @param {Record<string, any>} message @param {Record<string, any>} chains */
359
+ function chainIsOpen(message, chains) {
360
+ return chains[message.chainId]?.state === 'open'
361
+ }
362
+
363
+ /**
364
+ * Derive a completion time only from explicit timestamps the v0.1 record
365
+ * already carries. Missing evidence is left absent so retention keeps the
366
+ * record conservatively.
367
+ * @param {Record<string, any>} message
368
+ * @param {Record<string, any>} chains
369
+ * @returns {number | undefined}
370
+ */
371
+ function derivedCompletedAt(message, chains) {
372
+ if (Number.isSafeInteger(message.completedAt)) return message.completedAt
373
+ if (message.status === 'failed') return message.failedAt
374
+ if (message.status === 'cancelled') return message.cancelledAt
375
+ if (message.kind === 'notification' || message.kind === 'reply') {
376
+ return message.status === 'accepted' && Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : undefined
377
+ }
378
+ if (message.kind === 'request') {
379
+ if (message.replyStatus === 'answered' && Number.isSafeInteger(message.answeredAt)) return message.answeredAt
380
+ if (message.replyStatus === 'timed_out' && Number.isSafeInteger(message.timedOutAt)) return message.timedOutAt
381
+ if (message.status === 'cancelled' && Number.isSafeInteger(message.cancelledAt)) return message.cancelledAt
382
+ if (message.status === 'accepted' && chains[message.chainId]?.state === 'closed' && Number.isSafeInteger(chains[message.chainId].closedAt)) {
383
+ return chains[message.chainId].closedAt
384
+ }
385
+ }
386
+ return undefined
387
+ }
388
+
389
+ /** @param {Record<string, any>} message @param {number} now @param {Record<string, any>} chains @param {Record<string, any>} settings */
390
+ function migrateMessage(message, now, chains, settings) {
391
+ const next = structuredClone(message)
392
+ if (!Number.isSafeInteger(next.contentBytes)) {
393
+ try {
394
+ next.contentBytes = Buffer.byteLength(JSON.stringify(next.content), 'utf8')
395
+ } catch {
396
+ next.contentBytes = 0
397
+ }
398
+ }
399
+ normalizeHeldReason(next)
400
+ const completedAt = derivedCompletedAt(next, chains)
401
+ if (completedAt !== undefined && !Number.isSafeInteger(next.completedAt)) next.completedAt = completedAt
402
+ if (isAutomaticGlobalHeld(next) && settings.enabled === true && settings.paused !== true && chainIsOpen(next, chains)) {
403
+ next.status = 'pending'
404
+ next.error = null
405
+ next.heldAt = null
406
+ next.heldReason = null
407
+ next.autoResumedAt = now
408
+ delete next.heldOverflow
409
+ if ('releaseAuthorized' in next) next.releaseAuthorized = false
410
+ }
411
+ // Historical budget reservations/release flags are audit-only in v2.
412
+ delete next.budgetReservedAt
413
+ delete next.budgetReservedDay
414
+ return next
415
+ }
416
+
417
+ /** @param {Record<string, any>} chain */
418
+ function migrateChain(chain) {
419
+ const next = structuredClone(chain)
420
+ if (next.state === 'paused' && RETIRED_CHAIN_PAUSE_REASONS.includes(next.pausedReason)) {
421
+ return {
422
+ chain: { ...next, state: 'open', pausedAt: undefined, pausedReason: undefined },
423
+ retired: { chainId: next.id, pausedReason: next.pausedReason, pausedAt: next.pausedAt ?? null },
424
+ }
425
+ }
426
+ return { chain: next, retired: undefined }
427
+ }
428
+
429
+ /** Sort object keys recursively so a digest does not depend on JSON order. */
430
+ export function canonicalJson(value) {
431
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`
432
+ if (isPlainObject(value)) {
433
+ const keys = Object.keys(value).sort()
434
+ return `{${keys
435
+ .filter((key) => value[key] !== undefined)
436
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
437
+ .join(',')}}`
438
+ }
439
+ if (value === undefined) return 'null'
440
+ return JSON.stringify(value)
441
+ }
442
+
443
+ /** @param {unknown} bytes @returns {string} */
444
+ export function sha256(bytes) {
445
+ return createHash('sha256').update(bytes).digest('hex')
446
+ }
447
+
448
+ /**
449
+ * Digest used for the conservative rollback gate: the persisted v2 snapshot
450
+ * excluding the mutable revision fields and the private marker itself.
451
+ * @param {Record<string, any>} state
452
+ * @returns {string}
453
+ */
454
+ export function baselineStateDigest(state) {
455
+ const copy = structuredClone(state)
456
+ delete copy.revision
457
+ delete copy.updatedAt
458
+ delete copy.migration
459
+ return sha256(Buffer.from(canonicalJson(copy), 'utf8'))
460
+ }
461
+
462
+ /**
463
+ * Digest for the v3 -> v2 rollback gate. Unlike the v1 marker digest this keeps
464
+ * the v1 migration marker (it is real v2 content) and ignores only the v3
465
+ * schema upgrade marker plus the mutable revision fields.
466
+ * @param {Record<string, any>} state
467
+ * @returns {string}
468
+ */
469
+ export function baselineV3StateDigest(state) {
470
+ const copy = structuredClone(state)
471
+ delete copy.revision
472
+ delete copy.updatedAt
473
+ delete copy.upgrade
474
+ return sha256(Buffer.from(canonicalJson(copy), 'utf8'))
475
+ }
476
+
477
+ /** Fixed legacy receipt created for every row that predates schema 3. */
478
+ function legacyReceipt() {
479
+ return {
480
+ policy: 'legacy-accepted',
481
+ state: 'uncertain',
482
+ receivedAt: null,
483
+ receivedSeq: null,
484
+ timeoutMs: null,
485
+ transfer: null,
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Pure v2 -> v3 conversion. It never deletes messages/chains/operations and
491
+ * never runs retention cleanup. Every old row receives a legacy-accepted
492
+ * receipt with no invented timing evidence; all IDs, associations, statuses,
493
+ * content, deadlines, timeout and answer history are preserved byte-for-value.
494
+ * @param {Record<string, any>} v2
495
+ * @param {{ now: number, fromStateSha256: string, backupPath: string }} options
496
+ * @returns {Record<string, any>}
497
+ */
498
+ export function migrateV2State(v2, options) {
499
+ const state = structuredClone(v2)
500
+ // v2 never owns these v3-only private fields; drop any unknown copy so a
501
+ // hand-edited v2 file cannot preload a journal or upgrade marker.
502
+ delete state.upgrade
503
+ delete state.transfers
504
+ for (const message of state.messages) message.receipt = legacyReceipt()
505
+ state.version = V3_STATE_VERSION
506
+ state.updatedAt = options.now
507
+ state.upgrade = {
508
+ fromVersion: V2_STATE_VERSION,
509
+ fromStateSha256: options.fromStateSha256,
510
+ migratedAt: options.now,
511
+ migratedRevision: v2.revision,
512
+ migratedSettingsRevision: v2.settingsRevision,
513
+ backupPath: options.backupPath,
514
+ baselineStateDigest: baselineV3StateDigest(state),
515
+ }
516
+ return state
517
+ }
518
+
519
+ /**
520
+ * Validate the private v2 -> v3 upgrade marker.
521
+ * @param {unknown} upgrade
522
+ * @returns {Record<string, any>}
523
+ */
524
+ export function validateUpgradeMarker(upgrade) {
525
+ const value = record(upgrade, 'ledger upgrade marker')
526
+ if (value.fromVersion !== V2_STATE_VERSION) corrupt('ledger upgrade fromVersion is invalid')
527
+ if (typeof value.fromStateSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.fromStateSha256)) {
528
+ corrupt('ledger upgrade fromStateSha256 is invalid')
529
+ }
530
+ safeInteger(value.migratedAt, 'ledger upgrade migratedAt')
531
+ safeInteger(value.migratedRevision, 'ledger upgrade migratedRevision', 1)
532
+ safeInteger(value.migratedSettingsRevision, 'ledger upgrade migratedSettingsRevision', 1)
533
+ if (typeof value.backupPath !== 'string' || value.backupPath.length === 0) {
534
+ corrupt('ledger upgrade backupPath is invalid')
535
+ }
536
+ const basename = value.backupPath.split(/[\\/]/).pop() ?? ''
537
+ if (!basename.startsWith(V2_BACKUP_PREFIX)) corrupt('ledger upgrade backupPath is not a v2 backup')
538
+ if (typeof value.baselineStateDigest !== 'string' || !/^[0-9a-f]{64}$/.test(value.baselineStateDigest)) {
539
+ corrupt('ledger upgrade baselineStateDigest is invalid')
540
+ }
541
+ return value
542
+ }
543
+
544
+ /**
545
+ * Pure v3 -> v2 preflight for Lead-owned offline scripts. It is deliberately
546
+ * conservative: any post-upgrade revision/settings change, receipt observation,
547
+ * message/operation change, or backup mismatch rejects the restore.
548
+ * @param {{ currentState: unknown, upgrade: unknown, backupRaw: string|Buffer }} request
549
+ * @returns {{ ok: boolean, reasons: string[] }}
550
+ */
551
+ export function v3ToV2Preflight(request) {
552
+ const reasons = []
553
+ const state = request?.currentState
554
+ if (!isPlainObject(state) || state.version !== V3_STATE_VERSION) {
555
+ return { ok: false, reasons: ['current state is not a v3 ledger'] }
556
+ }
557
+ let marker
558
+ try {
559
+ marker = validateUpgradeMarker(request.upgrade)
560
+ } catch {
561
+ return { ok: false, reasons: ['upgrade marker is invalid'] }
562
+ }
563
+ if (canonicalJson(marker) !== canonicalJson(state.upgrade)) {
564
+ return { ok: false, reasons: ['upgrade marker does not belong to the current state'] }
565
+ }
566
+ const raw = request.backupRaw
567
+ const digest = sha256(Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw ?? ''), 'utf8'))
568
+ if (digest !== marker.fromStateSha256) reasons.push('v2 backup digest does not match the upgrade marker')
569
+ if (state.revision !== marker.migratedRevision) reasons.push('ledger revision changed after the v2 upgrade')
570
+ if (state.settingsRevision !== marker.migratedSettingsRevision) reasons.push('settings changed after the v2 upgrade')
571
+ let currentDigest
572
+ try {
573
+ currentDigest = baselineV3StateDigest(state)
574
+ } catch {
575
+ reasons.push('current state cannot be canonicalized')
576
+ }
577
+ if (currentDigest !== undefined && currentDigest !== marker.baselineStateDigest) {
578
+ reasons.push('state content changed after the v2 upgrade')
579
+ }
580
+ return { ok: reasons.length === 0, reasons }
581
+ }
582
+
583
+ /**
584
+ * Pure v1 -> v2 conversion. It never deletes messages, chains, or operations
585
+ * and never runs retention cleanup; only the documented status/settings/pause
586
+ * transformations are applied.
587
+ * @param {Record<string, any>} v1
588
+ * @param {{ now: number, fromStateSha256: string, backupPath: string }} options
589
+ * @returns {Record<string, any>}
590
+ */
591
+ export function migrateV1State(v1, options) {
592
+ const settings = mapV1Settings(v1.settings)
593
+ const chains = {}
594
+ const retiredQuotaPauses = []
595
+ for (const [id, chain] of Object.entries(v1.chains)) {
596
+ const migrated = migrateChain(chain)
597
+ chains[id] = migrated.chain
598
+ if (migrated.retired !== undefined) retiredQuotaPauses.push(migrated.retired)
599
+ }
600
+ const messages = v1.messages.map((message) => migrateMessage(message, options.now, chains, settings))
601
+ // The converted state's held -> pending pass already happened per message;
602
+ // chain references above use the chain ids, not the messages.
603
+ for (const chain of Object.values(chains)) {
604
+ if (chain.pausedAt === undefined) delete chain.pausedAt
605
+ if (chain.pausedReason === undefined) delete chain.pausedReason
606
+ }
607
+ const operations = structuredClone(v1.operations)
608
+ const state = {
609
+ version: V2_STATE_VERSION,
610
+ revision: v1.revision,
611
+ settingsRevision: 1,
612
+ createdAt: v1.createdAt,
613
+ updatedAt: options.now,
614
+ settings,
615
+ messages,
616
+ chains,
617
+ operations,
618
+ }
619
+ const digest = baselineStateDigest(state)
620
+ state.migration = {
621
+ fromVersion: V1_STATE_VERSION,
622
+ fromStateSha256: options.fromStateSha256,
623
+ migratedAt: options.now,
624
+ migratedRevision: v1.revision,
625
+ migratedSettingsRevision: 1,
626
+ backupPath: options.backupPath,
627
+ settingsBefore: structuredClone(v1.settings),
628
+ retiredQuotaPauses,
629
+ retiredLatches: structuredClone(v1.latches),
630
+ baselineMessageIds: messages.map((message) => message.id),
631
+ baselineOperationKeys: Object.keys(operations),
632
+ baselineStateDigest: digest,
633
+ }
634
+ return state
635
+ }
636
+
637
+ /**
638
+ * Public, minimal migration summary for snapshot. The private marker retains
639
+ * backup paths, baseline lists, and retired audit arrays.
640
+ * @param {Record<string, any>} migration
641
+ * @returns {Record<string, any>}
642
+ */
643
+ export function publicMigration(migration) {
644
+ if (!isPlainObject(migration)) return undefined
645
+ return {
646
+ fromVersion: migration.fromVersion,
647
+ migratedAt: migration.migratedAt,
648
+ migratedRevision: migration.migratedRevision,
649
+ migratedSettingsRevision: migration.migratedSettingsRevision,
650
+ backupAvailable: typeof migration.backupPath === 'string' && migration.backupPath.length > 0,
651
+ retiredQuotaPauses: Array.isArray(migration.retiredQuotaPauses) ? migration.retiredQuotaPauses.length : 0,
652
+ retiredLatches: Array.isArray(migration.retiredLatches) ? migration.retiredLatches.length : 0,
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Validate the public migration summary/full marker enough for the v2 loader.
658
+ * @param {unknown} migration
659
+ */
660
+ export function validateMigrationMarker(migration) {
661
+ const value = record(migration, 'ledger migration marker')
662
+ if (value.fromVersion !== V1_STATE_VERSION) corrupt('ledger migration fromVersion is invalid')
663
+ if (typeof value.fromStateSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.fromStateSha256)) {
664
+ corrupt('ledger migration fromStateSha256 is invalid')
665
+ }
666
+ safeInteger(value.migratedAt, 'ledger migration migratedAt')
667
+ safeInteger(value.migratedRevision, 'ledger migration migratedRevision', 1)
668
+ safeInteger(value.migratedSettingsRevision, 'ledger migration migratedSettingsRevision', 1)
669
+ if (typeof value.backupPath !== 'string' || value.backupPath.length === 0) {
670
+ corrupt('ledger migration backupPath is invalid')
671
+ }
672
+ if (!isPlainObject(value.settingsBefore)) corrupt('ledger migration settingsBefore is invalid')
673
+ if (!Array.isArray(value.retiredQuotaPauses)) corrupt('ledger migration retiredQuotaPauses is invalid')
674
+ for (const [index, entry] of value.retiredQuotaPauses.entries()) {
675
+ const item = record(entry, `ledger migration retiredQuotaPauses ${index}`)
676
+ nonEmptyString(item.chainId, `ledger migration retiredQuotaPauses ${index}.chainId`)
677
+ nonEmptyString(item.pausedReason, `ledger migration retiredQuotaPauses ${index}.pausedReason`)
678
+ optionalTime(item.pausedAt, `ledger migration retiredQuotaPauses ${index}.pausedAt`)
679
+ }
680
+ if (!Array.isArray(value.retiredLatches)) corrupt('ledger migration retiredLatches is invalid')
681
+ validateV1Latches(value.retiredLatches)
682
+ if (!Array.isArray(value.baselineMessageIds)) corrupt('ledger migration baselineMessageIds is invalid')
683
+ for (const id of value.baselineMessageIds) nonEmptyString(id, 'ledger migration baselineMessageId')
684
+ if (!Array.isArray(value.baselineOperationKeys)) corrupt('ledger migration baselineOperationKeys is invalid')
685
+ for (const key of value.baselineOperationKeys) nonEmptyString(key, 'ledger migration baselineOperationKey')
686
+ if (typeof value.baselineStateDigest !== 'string' || !/^[0-9a-f]{64}$/.test(value.baselineStateDigest)) {
687
+ corrupt('ledger migration baselineStateDigest is invalid')
688
+ }
689
+ return value
690
+ }
691
+
692
+ /**
693
+ * Pure rollback preflight. Lead-owned offline scripts call this with the
694
+ * current v2 state, its private marker, and the exact v1 backup bytes; the
695
+ * function never writes anything. It is deliberately conservative: any new
696
+ * message, operation, status transition, or settings change rejects rollback.
697
+ * @param {{ currentState: unknown, migration: unknown, backupRaw: string|Buffer }} request
698
+ * @returns {{ ok: boolean, reasons: string[] }}
699
+ */
700
+ export function rollbackPreflight(request) {
701
+ const reasons = []
702
+ const state = request?.currentState
703
+ if (!isPlainObject(state) || state.version !== V2_STATE_VERSION) {
704
+ return { ok: false, reasons: ['current state is not a v2 ledger'] }
705
+ }
706
+ let marker
707
+ try {
708
+ marker = validateMigrationMarker(request.migration)
709
+ } catch {
710
+ return { ok: false, reasons: ['migration marker is invalid'] }
711
+ }
712
+ const raw = request.backupRaw
713
+ const digest = sha256(Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw ?? ''), 'utf8'))
714
+ if (digest !== marker.fromStateSha256) reasons.push('v1 backup digest does not match the migration marker')
715
+ if (state.settingsRevision !== marker.migratedSettingsRevision) reasons.push('settings changed after migration')
716
+ const stateIds = state.messages.map((message) => message.id)
717
+ if (
718
+ stateIds.length !== marker.baselineMessageIds.length ||
719
+ stateIds.some((id, index) => id !== marker.baselineMessageIds[index])
720
+ ) {
721
+ reasons.push('message IDs differ from the migration baseline')
722
+ }
723
+ const operationKeys = Object.keys(state.operations)
724
+ if (
725
+ operationKeys.length !== marker.baselineOperationKeys.length ||
726
+ operationKeys.some((key, index) => key !== marker.baselineOperationKeys[index])
727
+ ) {
728
+ reasons.push('operation keys differ from the migration baseline')
729
+ }
730
+ let currentDigest
731
+ try {
732
+ currentDigest = baselineStateDigest(state)
733
+ } catch {
734
+ reasons.push('current state cannot be canonicalized')
735
+ }
736
+ if (currentDigest !== undefined && currentDigest !== marker.baselineStateDigest) {
737
+ reasons.push('state content, IDs, associations, durable IDs, or statuses changed after migration')
738
+ }
739
+ if (state.revision !== marker.migratedRevision) reasons.push('ledger revision changed after migration')
740
+ return { ok: reasons.length === 0, reasons }
741
+ }