dsh-llm-fallbacks 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1227 @@
1
+ /**
2
+ * rules.ts — the session-log refusal rule registry and its built-in rules.
3
+ *
4
+ * A session log reaches the GUI only through the FROZEN released migration
5
+ * chain, so a pre-V3 log written by an older release (or by a plugin that
6
+ * merged a custom `MessageSource.kind`) can be refused before it is read. Every
7
+ * such refusal belongs to one class, and each class is one rule here.
8
+ *
9
+ * Rule shape: `detect` reports the refusals (read-only); `normalize` returns a
10
+ * NEW row array with the class repaired, or `{ refused: reason }` when the
11
+ * repaired shape is not provably admissible. The released catalog stays the
12
+ * final oracle (a candidate generation is read back with
13
+ * `validation: 'current'`); a rule never claims more than its own proof covers.
14
+ *
15
+ * Released formats this file encodes (SSOT paths are in the `deepseek-harness`
16
+ * checkout at `dsh 0.1.5-rc.1`, the build the running harness uses):
17
+ * - `source-kind`:
18
+ * `packages/session/session-format-v2-to-v3/src/payload.ts` —
19
+ * `SOURCE_KINDS` (the first-party vocabulary) and `assertSource` (the
20
+ * message-source positions that check it). The sanctioned target shape is
21
+ * the `plugin` arm DSH itself uses
22
+ * (`packages/llm/llm/src/message.ts` `MessageSourceMap['plugin']`); the
23
+ * members that arm admits are enforced by
24
+ * `packages/session/session-format-v0-to-v1/src/payload-validation.ts`
25
+ * `pluginSourceValue`.
26
+ * - `subagent-descriptor-version`:
27
+ * `packages/session/session-format-v0-to-v1/src/validation.ts` — the V0→V1
28
+ * edge admits only descriptor `version: 3`. The admitted payload key set
29
+ * per `mode` is `packages/subagent/subagent/src/descriptor.ts`
30
+ * (`ONE_SHOT_DESCRIPTOR_KEYS` / `CONTINUABLE_DESCRIPTOR_KEYS`), and the
31
+ * value shape is `subagentDescriptorValue` in the payload-validation file
32
+ * above.
33
+ * - `unknown-event-type`: the same V0→V1 edge refuses an event type outside
34
+ * the released inventory even when the row carries `ignorable: true`, so
35
+ * the legacy `fallbacks/switch` rows are NEVER repairable by the default
36
+ * registry — the only in-repo recovery is the LOSSY `drop-legacy-events`
37
+ * rule below, which an explicit opt-in must add to a run.
38
+ * - `drop-legacy-events` (OPT-IN, lossy, deliberately NOT in `BUILT_IN_RULES`):
39
+ * removes exactly the `fallbacks/switch` rows, renumbers the survivors (the
40
+ * released V0→V1 codec requires `seq === eventCount` for every row, so a
41
+ * removed mid-sequence row leaves every later row with a stale `seq`), and
42
+ * refuses the whole file when that renumber would change what a surviving
43
+ * row's Session-seq reference points at.
44
+ *
45
+ * Purity: no rule mutates its input. `detect` only reads; `normalize` always
46
+ * returns a NEW row array, and copies a row (plus every container on the path of
47
+ * a change) only when it actually changes that row — a pass-through row keeps its
48
+ * identity.
49
+ * Fail closed: a rule refuses whenever admissibility is unproven — it never
50
+ * bumps a value because the change "looks" safe.
51
+ */
52
+
53
+ export type RefusalClass =
54
+ | 'ok'
55
+ | 'source-kind'
56
+ | 'subagent-descriptor-version'
57
+ | 'unknown-event-type'
58
+ | 'other-refusal'
59
+ | 'decompress-failed'
60
+
61
+ /**
62
+ * Every {@link RefusalClass} member, as a COMPILE-CHECKED record: a new class
63
+ * added to the union without a member here fails to compile, so the vocabulary
64
+ * cannot grow silently.
65
+ */
66
+ export const REFUSAL_CLASS_MEMBERS: Readonly<Record<RefusalClass, true>> = {
67
+ ok: true,
68
+ 'source-kind': true,
69
+ 'subagent-descriptor-version': true,
70
+ 'unknown-event-type': true,
71
+ 'other-refusal': true,
72
+ 'decompress-failed': true,
73
+ }
74
+
75
+ /**
76
+ * The refusal vocabulary in REPORT order — the single source of truth for the
77
+ * `--class` values, the report's per-class table and the usage text. Kept
78
+ * member-for-member with {@link REFUSAL_CLASS_MEMBERS} by that record's type and
79
+ * by an equality pin in the suite, so the next class fails loudly in both places
80
+ * instead of yielding a `NaN` report row.
81
+ */
82
+ export const REFUSAL_CLASSES: readonly RefusalClass[] = [
83
+ 'ok',
84
+ 'source-kind',
85
+ 'subagent-descriptor-version',
86
+ 'unknown-event-type',
87
+ 'other-refusal',
88
+ 'decompress-failed',
89
+ ]
90
+
91
+ /** One decoded JSONL row of a session log (the physical envelope only). */
92
+ export interface ParsedRow {
93
+ type: string
94
+ seq: number
95
+ time?: number
96
+ data?: unknown
97
+ }
98
+
99
+ /**
100
+ * One refusal (`detect`) or one applied rewrite (`normalize`).
101
+ * `detail` is human-facing evidence for the CLI report.
102
+ */
103
+ export interface Finding {
104
+ ruleId: string
105
+ class: RefusalClass
106
+ detail: string
107
+ }
108
+
109
+ /**
110
+ * One refusal class as a rule.
111
+ *
112
+ * `detect` is ROW-LOCAL: it reports the refusals carried by the rows it is
113
+ * given, in row order. `classifyRows` calls it once per row (a single-row
114
+ * slice) so the class of a log's FIRST refused row is preserved; `normalize` is
115
+ * called once with the whole log.
116
+ *
117
+ * `normalize` is all-or-nothing per rule: when any finding of that rule cannot
118
+ * be repaired it returns `{ refused }` and the caller must discard the rows it
119
+ * would otherwise have produced.
120
+ *
121
+ * `repairable` is the report's own predicate for "this rule can make a log of its
122
+ * class load AT ALL": a detector that never rewrites anything answers `false`, and
123
+ * a rule whose repair scope depends on the rows (the opt-in lossy rule repairs a
124
+ * log only when it actually carries a removable row) answers per call. The CLI
125
+ * derives its repairable-class set from this member rather than from a literal, so
126
+ * adding a rule is enough to make its class repairable.
127
+ */
128
+ export interface LogRule {
129
+ id: string
130
+ class: RefusalClass
131
+ detect(rows: readonly ParsedRow[]): Finding[]
132
+ normalize(rows: readonly ParsedRow[]): { rows: ParsedRow[]; findings: Finding[] } | { refused: string }
133
+ repairable(rows: readonly ParsedRow[]): boolean
134
+ }
135
+
136
+ /* ------------------------------------------------------------------ */
137
+ /* shared helpers */
138
+ /* ------------------------------------------------------------------ */
139
+
140
+ function isRecord(value: unknown): value is Record<string, unknown> {
141
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
142
+ }
143
+
144
+ function isNonEmptyString(value: unknown): value is string {
145
+ return typeof value === 'string' && value.length > 0
146
+ }
147
+
148
+ /**
149
+ * Render a JSON value for a diagnostic message.
150
+ *
151
+ * Not total: `JSON.stringify` throws on a `BigInt` member, so the only values
152
+ * this is called with are the streamed JSON payload members of a parsed row
153
+ * (which can never carry one). A violation surfaces as a loud rule failure, not
154
+ * as a silent misreport.
155
+ */
156
+ function describe(value: unknown): string {
157
+ return JSON.stringify(value) ?? String(value)
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ */
161
+ /* source-kind */
162
+ /* ------------------------------------------------------------------ */
163
+
164
+ /**
165
+ * The released first-party message-source vocabulary.
166
+ *
167
+ * SSOT: `packages/session/session-format-v2-to-v3/src/payload.ts` `SOURCE_KINDS`
168
+ * (`deepseek-harness`, `dsh 0.1.5-rc.1`). A V2 message whose `source.kind` is
169
+ * outside this set is refused with
170
+ * `cannot safely transform unclassified message source`; the GUI surfaces that
171
+ * as a failed session load.
172
+ *
173
+ * Exported so the vocabulary itself is pinned by a test (exact set equality),
174
+ * not just its use: one spurious EXTRA kind here would make this registry stop
175
+ * detecting the RCA's dominant driver (`source-kind`) for that value, silently.
176
+ */
177
+ export const RELEASED_SOURCE_KINDS: ReadonlySet<string> = new Set([
178
+ 'user',
179
+ 'plugin',
180
+ 'model',
181
+ 'tool',
182
+ 'agent-instructions',
183
+ 'session-reference',
184
+ 'team-message',
185
+ 'goal',
186
+ 'skill-invocation',
187
+ 'skill-catalog',
188
+ 'coordinator',
189
+ 'subagent-report',
190
+ 'subagent-settled',
191
+ 'webhook',
192
+ 'agent-message',
193
+ ])
194
+
195
+ /**
196
+ * Whether one value is a member of the released message-source vocabulary
197
+ * ({@link RELEASED_SOURCE_KINDS}).
198
+ *
199
+ * The one predicate the write-surface guard (`tests/session-write-surface.spec.ts`)
200
+ * checks this repo's own `MessageSource.kind` literals against, so "released" has
201
+ * exactly one definition in this repository.
202
+ */
203
+ export function isReleasedSourceKind(value: unknown): value is string {
204
+ return typeof value === 'string' && RELEASED_SOURCE_KINDS.has(value)
205
+ }
206
+
207
+ /**
208
+ * Forms the released `plugin` arm admits (SSOT: `ContextFormed` in
209
+ * `packages/llm/llm/src/message.ts`, enforced by `pluginSourceValue`).
210
+ */
211
+ export const RELEASED_PLUGIN_FORMS: ReadonlySet<string> = new Set([
212
+ 'instructions',
213
+ 'catalog',
214
+ 'snapshot',
215
+ 'notice',
216
+ 'relay',
217
+ 'recall',
218
+ ])
219
+
220
+ /** `source` member path inside `row.data`, e.g. `['message', 'source']`. */
221
+ type SourcePath = readonly (string | number)[]
222
+
223
+ /**
224
+ * Message-source positions of one row: exactly the rows the released V2→V3
225
+ * edge classifies (`assertSource`, called from `assertEvent` in
226
+ * `session-format-v2-to-v3/src/payload.ts`):
227
+ * - `user/message` → `data.source`
228
+ * - `assistant/message` / `tool/result` → `data.message.source`
229
+ * - `agent/inbox/spliced` → every `data.inserted[i].source`
230
+ * - `session/title-llm-request` → every `data.messages[i].source`
231
+ *
232
+ * A `kind` member anywhere else is NOT what refuses a log — content blocks
233
+ * carry their own `type`/`kind` — so such members are deliberately left alone.
234
+ */
235
+ function sourcePaths(row: ParsedRow): readonly SourcePath[] {
236
+ if (!isRecord(row.data)) return []
237
+ switch (row.type) {
238
+ case 'user/message':
239
+ return [['source']]
240
+ case 'assistant/message':
241
+ case 'tool/result':
242
+ return [['message', 'source']]
243
+ case 'agent/inbox/spliced':
244
+ return memberSourcePaths(row.data, 'inserted')
245
+ case 'session/title-llm-request':
246
+ return memberSourcePaths(row.data, 'messages')
247
+ default:
248
+ return []
249
+ }
250
+ }
251
+
252
+ function memberSourcePaths(data: Record<string, unknown>, member: string): SourcePath[] {
253
+ const list = data[member]
254
+ if (!Array.isArray(list)) return []
255
+ return list.map((_member, index) => [member, index, 'source'])
256
+ }
257
+
258
+ function pathLabel(path: SourcePath): string {
259
+ return path.map((step) => String(step)).join('.')
260
+ }
261
+
262
+ function readPath(root: unknown, path: SourcePath): unknown {
263
+ let node = root
264
+ for (const step of path) {
265
+ if (typeof step === 'number') {
266
+ if (!Array.isArray(node)) return undefined
267
+ node = node[step]
268
+ } else {
269
+ if (!isRecord(node)) return undefined
270
+ node = node[step]
271
+ }
272
+ }
273
+ return node
274
+ }
275
+
276
+ /**
277
+ * Copy-as-you-go write at `path`: every container on the path is re-created, so
278
+ * the input tree is never mutated. Only called for a path `readPath` resolved.
279
+ */
280
+ function writePath(node: unknown, path: SourcePath, value: unknown): unknown {
281
+ const [step, ...rest] = path
282
+ if (typeof step === 'number') {
283
+ const copy: unknown[] = Array.isArray(node) ? [...node] : []
284
+ copy[step] = rest.length === 0 ? value : writePath(copy[step], rest, value)
285
+ return copy
286
+ }
287
+ const copy: Record<string, unknown> = isRecord(node) ? { ...node } : {}
288
+ copy[step] = rest.length === 0 ? value : writePath(copy[step], rest, value)
289
+ return copy
290
+ }
291
+
292
+ /** A source is foreign when its `kind` is absent or outside the vocabulary. */
293
+ function isForeignKind(value: unknown): boolean {
294
+ return !isReleasedSourceKind(value)
295
+ }
296
+
297
+ /**
298
+ * Rewrite one foreign source into the released `plugin` arm, or explain why its
299
+ * shape cannot be carried over:
300
+ * - `plugin` records the original kind (stable identity, DSH's own
301
+ * precedent);
302
+ * - `form` / `summary` / `sections` survive when the released
303
+ * `pluginSourceValue` admits the combination (`summary` with the `notice`
304
+ * form, `sections` with `snapshot` as exact `{ name, text }` members);
305
+ * - every other member is dropped, because the released arm admits none.
306
+ *
307
+ * This is a CONSERVATIVE SUBSET of that released arm, deliberately stricter:
308
+ * `pluginSourceValue` returns early when `form` is absent, so it would admit a
309
+ * foreign `summary` / `sections` payload with no `form` at all — this rewrite
310
+ * refuses those instead, because the missing `form` is what gave those members
311
+ * their meaning, and a rewrite whose result is not provably the same message is
312
+ * a rewrite this rule must not make.
313
+ */
314
+ function toPluginSource(source: Record<string, unknown>): { source: Record<string, unknown> } | { refusal: string } {
315
+ const kind = source['kind']
316
+ if (!isNonEmptyString(kind)) {
317
+ return { refusal: `source.kind ${describe(kind)} is not a non-empty string, so no stable plugin id can replace it` }
318
+ }
319
+ if (kind === 'compact') {
320
+ // `compact` is a reserved plugin id whose arm additionally requires
321
+ // compactionId / sourceCommandId — members this rewrite drops.
322
+ return { refusal: 'source.kind "compact" is the reserved compact plugin id, whose arm requires dropped members' }
323
+ }
324
+ const form = source['form']
325
+ if (form !== undefined && !(typeof form === 'string' && RELEASED_PLUGIN_FORMS.has(form))) {
326
+ return { refusal: `source.form ${describe(form)} is outside the released plugin forms, so the form cannot be preserved` }
327
+ }
328
+ const summary = source['summary']
329
+ if (summary !== undefined && (form !== 'notice' || typeof summary !== 'string')) {
330
+ return { refusal: 'source.summary is admitted only by the notice form, as a string' }
331
+ }
332
+ const sections = source['sections']
333
+ if (sections !== undefined && (form !== 'snapshot' || !isSnapshotSections(sections))) {
334
+ return { refusal: 'source.sections are admitted only by the snapshot form, as { name, text } members' }
335
+ }
336
+ if (form === 'snapshot' && sections === undefined) return { refusal: 'the snapshot form requires source.sections' }
337
+ if (form === 'notice' && summary === undefined) return { refusal: 'the notice form requires source.summary' }
338
+
339
+ const next: Record<string, unknown> = { kind: 'plugin', plugin: kind }
340
+ if (form !== undefined) next['form'] = form
341
+ if (summary !== undefined) next['summary'] = summary
342
+ if (sections !== undefined) next['sections'] = sections
343
+ return { source: next }
344
+ }
345
+
346
+ /** `pluginSourceValue` admits snapshot sections as exactly `{ name, text }`. */
347
+ function isSnapshotSections(value: unknown): boolean {
348
+ if (!Array.isArray(value)) return false
349
+ return value.every(
350
+ (section) =>
351
+ isRecord(section) &&
352
+ Object.keys(section).length === 2 &&
353
+ isNonEmptyString(section['name']) &&
354
+ typeof section['text'] === 'string',
355
+ )
356
+ }
357
+
358
+ function detectSourceKinds(rows: readonly ParsedRow[]): Finding[] {
359
+ const findings: Finding[] = []
360
+ for (const row of rows) {
361
+ for (const path of sourcePaths(row)) {
362
+ const source = readPath(row.data, path)
363
+ if (!isRecord(source) || !isForeignKind(source['kind'])) continue
364
+ findings.push({
365
+ ruleId: 'source-kind',
366
+ class: 'source-kind',
367
+ detail: `${row.type} ${row.seq} ${pathLabel(path)} carries source.kind ${describe(source['kind'])}, outside the released first-party vocabulary`,
368
+ })
369
+ }
370
+ }
371
+ return findings
372
+ }
373
+
374
+ function normalizeSourceKinds(
375
+ rows: readonly ParsedRow[],
376
+ ): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
377
+ const out: ParsedRow[] = []
378
+ const findings: Finding[] = []
379
+ for (const row of rows) {
380
+ let next = row
381
+ for (const path of sourcePaths(row)) {
382
+ const source = readPath(next.data, path)
383
+ if (!isRecord(source) || !isForeignKind(source['kind'])) continue
384
+ const rewritten = toPluginSource(source)
385
+ if ('refusal' in rewritten) {
386
+ return { refused: `${row.type} ${row.seq} ${pathLabel(path)}: ${rewritten.refusal}` }
387
+ }
388
+ next = { ...next, data: writePath(next.data, path, rewritten.source) }
389
+ findings.push({
390
+ ruleId: 'source-kind',
391
+ class: 'source-kind',
392
+ detail: `${row.type} ${row.seq} ${pathLabel(path)}: source.kind ${describe(source['kind'])} → { kind: "plugin", plugin: ${JSON.stringify(source['kind'])} }`,
393
+ })
394
+ }
395
+ out.push(next)
396
+ }
397
+ return { rows: out, findings }
398
+ }
399
+
400
+ /* ------------------------------------------------------------------ */
401
+ /* subagent-descriptor-version */
402
+ /* ------------------------------------------------------------------ */
403
+
404
+ const SUBAGENT_DESCRIPTOR_TYPE = 'subagent/descriptor'
405
+ const RELEASED_DESCRIPTOR_VERSION = 3
406
+
407
+ type DescriptorMode = 'one-shot' | 'continuable'
408
+
409
+ /**
410
+ * Version-3 admitted descriptor payload keys per `mode`.
411
+ *
412
+ * SSOT: `packages/subagent/subagent/src/descriptor.ts` — `DESCRIPTOR_BASE_KEYS`
413
+ * is `version, mode, provider, label`; `CONTINUABLE_DESCRIPTOR_KEYS` adds
414
+ * `agentProvider, agentModel, agentReasoningEffort, persona, toolFilter`.
415
+ *
416
+ * The observed legacy payloads are subsets of these sets, which is what makes a
417
+ * `2 → 3` bump shape-preserving:
418
+ * - `2|one-shot|{label, mode, provider, version}`
419
+ * - `2|continuable|{agentModel, agentProvider, label, mode, provider, version}`
420
+ */
421
+ export const DESCRIPTOR_V3_KEYS_BY_MODE: Readonly<Record<DescriptorMode, readonly string[]>> = {
422
+ 'one-shot': ['version', 'mode', 'provider', 'label'],
423
+ continuable: [
424
+ 'version',
425
+ 'mode',
426
+ 'provider',
427
+ 'label',
428
+ 'agentProvider',
429
+ 'agentModel',
430
+ 'agentReasoningEffort',
431
+ 'persona',
432
+ 'toolFilter',
433
+ ],
434
+ }
435
+
436
+ /**
437
+ * Prove that a descriptor payload of an older `version` becomes admissible by
438
+ * changing `version` to 3 and nothing else. Key-subset membership is the
439
+ * primary proof; the value checks mirror the released
440
+ * `subagentDescriptorValue` so a bump can never trade one refusal for another.
441
+ * `null` means "provably admissible".
442
+ */
443
+ function descriptorBumpRefusal(data: Record<string, unknown>): string | null {
444
+ const mode = data['mode']
445
+ if (mode !== 'one-shot' && mode !== 'continuable') {
446
+ return `mode ${describe(mode)} is neither of the released descriptor modes`
447
+ }
448
+ const admitted = DESCRIPTOR_V3_KEYS_BY_MODE[mode]
449
+ const unexpected = Object.keys(data).filter((key) => !admitted.includes(key))
450
+ if (unexpected.length > 0) {
451
+ return `payload key(s) ${unexpected.join(', ')} are outside the version-3 admitted set for mode ${mode} (${admitted.join(', ')})`
452
+ }
453
+ if (!isNonEmptyString(data['provider'])) return 'provider is not a non-empty string'
454
+ if (mode === 'one-shot') {
455
+ if (data['label'] !== undefined && typeof data['label'] !== 'string') return 'label is not a string'
456
+ return null
457
+ }
458
+ if (!isNonEmptyString(data['label'])) return 'label is not a non-empty string'
459
+ for (const key of ['agentProvider', 'agentModel', 'agentReasoningEffort', 'persona']) {
460
+ if (data[key] !== undefined && !isNonEmptyString(data[key])) return `${key} is not a non-empty string`
461
+ }
462
+ if ((data['agentProvider'] === undefined) !== (data['agentModel'] === undefined)) {
463
+ return 'agentProvider and agentModel must be paired'
464
+ }
465
+ return toolFilterRefusal(data['toolFilter'])
466
+ }
467
+
468
+ /** `subagentDescriptorValue` admits `toolFilter` as a non-empty allow/deny. */
469
+ function toolFilterRefusal(value: unknown): string | null {
470
+ if (value === undefined) return null
471
+ if (!isRecord(value)) return 'toolFilter is not an object'
472
+ if (Object.keys(value).some((key) => key !== 'allow' && key !== 'deny')) {
473
+ return 'toolFilter admits only the allow/deny members'
474
+ }
475
+ if (value['allow'] === undefined && value['deny'] === undefined) {
476
+ return 'toolFilter requires allow or deny'
477
+ }
478
+ for (const key of ['allow', 'deny']) {
479
+ const list = value[key]
480
+ if (list === undefined) continue
481
+ if (!Array.isArray(list) || !list.every(isNonEmptyString)) {
482
+ return `toolFilter ${key} is not an array of non-empty strings`
483
+ }
484
+ }
485
+ return null
486
+ }
487
+
488
+ function detectDescriptorVersions(rows: readonly ParsedRow[]): Finding[] {
489
+ const findings: Finding[] = []
490
+ for (const row of rows) {
491
+ if (row.type !== SUBAGENT_DESCRIPTOR_TYPE) continue
492
+ if (!isRecord(row.data) || row.data['version'] === RELEASED_DESCRIPTOR_VERSION) continue
493
+ findings.push({
494
+ ruleId: 'subagent-descriptor-version',
495
+ class: 'subagent-descriptor-version',
496
+ detail: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq} uses unsupported descriptor version ${describe(row.data['version'])}`,
497
+ })
498
+ }
499
+ return findings
500
+ }
501
+
502
+ function normalizeDescriptorVersions(
503
+ rows: readonly ParsedRow[],
504
+ ): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
505
+ const out: ParsedRow[] = []
506
+ const findings: Finding[] = []
507
+ for (const row of rows) {
508
+ if (row.type !== SUBAGENT_DESCRIPTOR_TYPE) {
509
+ out.push(row)
510
+ continue
511
+ }
512
+ const data = row.data
513
+ if (!isRecord(data) || data['version'] === RELEASED_DESCRIPTOR_VERSION) {
514
+ out.push(row)
515
+ continue
516
+ }
517
+ const version = data['version']
518
+ if (version !== 1 && version !== 2) {
519
+ return {
520
+ refused: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: version ${describe(version)} is not a released earlier version (1 or 2), so a bump to ${RELEASED_DESCRIPTOR_VERSION} is not provable`,
521
+ }
522
+ }
523
+ const reason = descriptorBumpRefusal(data)
524
+ if (reason !== null) {
525
+ return { refused: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: ${reason}` }
526
+ }
527
+ out.push({ ...row, data: { ...data, version: RELEASED_DESCRIPTOR_VERSION } })
528
+ findings.push({
529
+ ruleId: 'subagent-descriptor-version',
530
+ class: 'subagent-descriptor-version',
531
+ detail: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: version ${describe(version)} → ${RELEASED_DESCRIPTOR_VERSION} (payload keys are a subset of the version-3 ${describe(data['mode'])} set)`,
532
+ })
533
+ }
534
+ return { rows: out, findings }
535
+ }
536
+
537
+ /* ------------------------------------------------------------------ */
538
+ /* fallbacks-switch (legacy, never repairable by default) */
539
+ /* ------------------------------------------------------------------ */
540
+
541
+ const FALLBACKS_SWITCH_TYPE = 'fallbacks/switch'
542
+
543
+ const FALLBACKS_SWITCH_REFUSAL =
544
+ 'fallbacks/switch is an unknown historical event type: the frozen V0→V1 edge refuses unknown historical events even when the row carries ignorable: true, so no rewrite can make such a log load'
545
+
546
+ function detectFallbacksSwitch(rows: readonly ParsedRow[]): Finding[] {
547
+ return rows
548
+ .filter((row) => row.type === FALLBACKS_SWITCH_TYPE)
549
+ .map((row) => ({
550
+ ruleId: 'fallbacks-switch',
551
+ class: 'unknown-event-type' as const,
552
+ detail: `${FALLBACKS_SWITCH_TYPE} ${row.seq} is outside the released event inventory; ${FALLBACKS_SWITCH_REFUSAL}`,
553
+ }))
554
+ }
555
+
556
+ /* ------------------------------------------------------------------ */
557
+ /* drop-legacy-events (OPT-IN, lossy, never in the registry) */
558
+ /* ------------------------------------------------------------------ */
559
+
560
+ const DROP_LEGACY_EVENTS_RULE_ID = 'drop-legacy-events'
561
+
562
+ const DROP_LEGACY_EVENTS_LOSS =
563
+ 'no rewrite can make a fallbacks/switch row load, so the row can only be removed — which loses the '
564
+ + 'provider/model switch audit entry it carries'
565
+
566
+ /**
567
+ * Whether one PARSED row is a removable legacy event.
568
+ *
569
+ * The decision is the parsed record's own `type` member compared for exact
570
+ * equality — never a substring/pattern match on the serialized line, because the
571
+ * string `fallbacks/switch` legitimately occurs inside user data and inside
572
+ * message bodies. A row that did not parse never reaches a rule at all: the
573
+ * reader refuses the whole log (`decompress-failed`) before any rule runs.
574
+ */
575
+ function isRemovableLegacyEvent(row: ParsedRow): boolean {
576
+ return row.type === FALLBACKS_SWITCH_TYPE
577
+ }
578
+
579
+ function detectDroppableLegacyEvents(rows: readonly ParsedRow[]): Finding[] {
580
+ return rows
581
+ .filter(isRemovableLegacyEvent)
582
+ .map((row) => ({
583
+ ruleId: DROP_LEGACY_EVENTS_RULE_ID,
584
+ class: 'unknown-event-type' as const,
585
+ detail: `${FALLBACKS_SWITCH_TYPE} ${row.seq} is dropped, not repaired: ${DROP_LEGACY_EVENTS_LOSS}`,
586
+ }))
587
+ }
588
+
589
+ /**
590
+ * The drop's own proof, run over the rule's own input/output pair rather than
591
+ * assumed from the loop that produced it: walk the input rows in order, re-
592
+ * serialize each one (`JSON.stringify`) and match it against the next survivor.
593
+ * A survivor whose bytes differ from the row it came from, or a removed row that
594
+ * is NOT a parsed `fallbacks/switch` row, refuses the whole drop — so the
595
+ * survivor count is provably `input − dropped`.
596
+ *
597
+ * Exported because this is the rule's load-bearing safety property: the pinning
598
+ * tests feed it a tampered survivor / a removed foreign row directly, which the
599
+ * rule's own construction path can never produce.
600
+ *
601
+ * @returns the reason to refuse, or `null` when the drop is proven.
602
+ */
603
+ export function legacyDropRefusal(
604
+ input: readonly ParsedRow[],
605
+ survivors: readonly ParsedRow[],
606
+ ): string | null {
607
+ let cursor = 0
608
+ for (const [index, row] of input.entries()) {
609
+ const survivor = survivors[cursor]
610
+ if (survivor !== undefined && JSON.stringify(survivor) === JSON.stringify(row)) {
611
+ cursor += 1
612
+ continue
613
+ }
614
+ if (isRemovableLegacyEvent(row)) continue
615
+ return `row ${index + 1} (${row.type}) is neither kept byte-identically nor a removable ${FALLBACKS_SWITCH_TYPE} row, so it must not be dropped`
616
+ }
617
+ if (cursor !== survivors.length) {
618
+ return `the drop produced ${survivors.length} survivor(s) from ${input.length} row(s), but only ${cursor} match the input in order`
619
+ }
620
+ return null
621
+ }
622
+
623
+ /**
624
+ * Split one log into the drop's candidate rows and the survivors it keeps —
625
+ * `rows` minus every parsed legacy row, in order, with the survivor rows
626
+ * themselves untouched.
627
+ *
628
+ * This is the ONLY place the drop's row policy is written down: `normalize` and
629
+ * any caller that wants to re-derive the renumber (the CLI's pre-write proof does)
630
+ * both go through it, so neither can disagree about which rows are removable.
631
+ *
632
+ * @param rows parsed log rows (header first).
633
+ * @returns the survivors (byte-identical source rows) and the dropped row count.
634
+ */
635
+ export function legacyDropSplit(rows: readonly ParsedRow[]): { survivors: ParsedRow[]; dropped: number } {
636
+ const survivors = rows.filter((row) => !isRemovableLegacyEvent(row))
637
+ return { survivors, dropped: rows.length - survivors.length }
638
+ }
639
+
640
+ /**
641
+ * Remove every `fallbacks/switch` row and nothing else, in row order; one
642
+ * finding per removed row, so `findings.length` IS the dropped-event count (see
643
+ * {@link droppedEventCount}).
644
+ *
645
+ * All-or-nothing: when {@link legacyDropRefusal} cannot prove the survivors
646
+ * byte-identical to their source rows the rule refuses, and the caller writes
647
+ * nothing. The drop itself is not enough to make such a log load — the released
648
+ * V0→V1 codec requires `seq === eventCount` for every row — so the drop is
649
+ * followed by {@link renumberSurvivingEvents}, which renumbers the survivors,
650
+ * refuses to change what any surviving Session-seq reference points at, and
651
+ * proves every written byte is one of its audited remap steps.
652
+ */
653
+ function normalizeDroppedLegacyEvents(
654
+ rows: readonly ParsedRow[],
655
+ ): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
656
+ const { survivors } = legacyDropSplit(rows)
657
+ const refusal = legacyDropRefusal(rows, survivors)
658
+ if (refusal !== null) return { refused: refusal }
659
+ const renumbered = renumberSurvivingEvents(rows, survivors)
660
+ if ('refused' in renumbered) return { refused: renumbered.refused }
661
+ return { rows: renumbered.rows, findings: detectDroppableLegacyEvents(rows) }
662
+ }
663
+
664
+ /**
665
+ * The rows the opt-in lossy rule removes from one log: its `normalize` reports
666
+ * exactly one finding per dropped row, so counting them is exact.
667
+ */
668
+ export function droppedEventCount(findings: readonly Finding[]): number {
669
+ return findings.filter((finding) => finding.ruleId === DROP_LEGACY_EVENTS_RULE_ID).length
670
+ }
671
+
672
+ /* ------------------------------------------------------------------ */
673
+ /* the Session-seq reference surface (the renumber's field list) */
674
+ /* ------------------------------------------------------------------ */
675
+
676
+ /**
677
+ * Row-relative JSON path of one audited seq member. The `SourcePath` alias is
678
+ * the same structural type; naming it here keeps the intent readable.
679
+ */
680
+ type SeqPath = SourcePath
681
+
682
+ /**
683
+ * Packed Assistant runs of the released v0 physical layout.
684
+ *
685
+ * SSOT: `PACKED_TAGS` in `session-format-v0-to-v1/src/codec.ts`. A packed row
686
+ * carries `seq0` (the FIRST event seq of the run) and no `seq` at all; the number
687
+ * of events it occupies IS its payload length (`data.texts` for `text-chunks` /
688
+ * `reasoning-chunks`, `data.args` for `tool-call-chunks`), and the codec advances
689
+ * its running count by exactly that (`codec.ts` `eventCount += run.eventCount`).
690
+ */
691
+ const PACKED_RUN_TYPES: ReadonlySet<string> = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
692
+
693
+ /**
694
+ * One audited Session-seq reference inside one row.
695
+ *
696
+ * `positions` is what the renumber REWRITES; `spans` is every old seq the
697
+ * reference NAMES (a `[start, end]` range covers its whole interior, not just its
698
+ * endpoints), which is what the integrity gate tests against the dropped seqs.
699
+ */
700
+ interface SeqReference {
701
+ /** Diagnostic label, e.g. `sourceEventSeqs.3` or `data.messageSeqs.0`. */
702
+ label: string
703
+ /** The writable members of this reference (a range has two). */
704
+ positions: readonly { path: SeqPath; value: number }[]
705
+ /** Inclusive old-seq spans this reference names. */
706
+ spans: readonly { start: number; end: number }[]
707
+ }
708
+
709
+ /** The physical envelope member one row carries (`ParsedRow` is the logical view). */
710
+ function envelopeValue(row: ParsedRow, member: string): unknown {
711
+ return (row as unknown as Record<string, unknown>)[member]
712
+ }
713
+
714
+ /** Whether one row is a packed Assistant run rather than one single-event row. */
715
+ function isPackedRun(row: ParsedRow): boolean {
716
+ return PACKED_RUN_TYPES.has(row.type)
717
+ }
718
+
719
+ /** Events one row occupies, or `null` when a packed run's payload is malformed. */
720
+ function rowEventCount(row: ParsedRow): number | null {
721
+ if (!isPackedRun(row)) return 1
722
+ const data = row.data
723
+ if (!isRecord(data)) return null
724
+ const payload = data[row.type === 'tool-call-chunks' ? 'args' : 'texts']
725
+ // The released codec requires a NON-EMPTY string array; anything else is a row
726
+ // it refuses outright, so this rule models no extent for it.
727
+ return Array.isArray(payload) && payload.length > 0 ? payload.length : null
728
+ }
729
+
730
+ /** The event position one row DECLARES (`seq`, or `seq0` for a packed run). */
731
+ function declaredEventSeq(row: ParsedRow): unknown {
732
+ return isPackedRun(row) ? envelopeValue(row, 'seq0') : row.seq
733
+ }
734
+
735
+ /** The physical record of one row, mirroring `rowRecord` in `catalog.ts`. */
736
+ function rowPayload(row: ParsedRow): unknown {
737
+ const data = row.data
738
+ if (row.type === 'session' && isRecord(data) && typeof data['version'] === 'number' && typeof data['id'] === 'string') {
739
+ return data
740
+ }
741
+ return row
742
+ }
743
+
744
+ /**
745
+ * The header's seed cut: the number of leading events the released V0 header
746
+ * declares as inherited (`seedLength`, `codec.ts` `decodePhysicalHeader`).
747
+ * Events below it belong to the inherited lifecycle, so renumbering across it
748
+ * would silently re-classify the first own event as inherited.
749
+ */
750
+ function headerSeedCut(rows: readonly ParsedRow[]): number {
751
+ const header = rows[0]
752
+ if (header === undefined) return 0
753
+ const payload = rowPayload(header)
754
+ if (!isRecord(payload)) return 0
755
+ const seedLength = payload['seedLength']
756
+ return typeof seedLength === 'number' ? seedLength : 0
757
+ }
758
+
759
+ /**
760
+ * Every Session-seq reference the released reader resolves inside one row.
761
+ *
762
+ * The field list is the union of the two released remappers and the validators
763
+ * that reject what they miss:
764
+ * - `row.seq` / `row.seq0` — the row's own coordinate (see the renumber below);
765
+ * - `row.sourceEventSeqs` — absolute seqs, `[start, end]` ranges allowed
766
+ * (`codec.ts` `decodeSeqRanges`), each `< row.seq` and unique
767
+ * (`validation.ts` `assertReleasedSurfaceMetadata`), must cover the shadowed
768
+ * surface span (`relationships.ts` `applySurface`);
769
+ * - `row.surfaceOp.start` / `.end` (a `{ op: 'replace', … }` marker) — same
770
+ * family, `< row.seq` (`validation.ts`), must be on the current surface
771
+ * (`relationships.ts`) and index the message-id map by seq (`migration.ts`);
772
+ * - `data.sourceEventSeq` — `command/done`, an event index
773
+ * (`payload-validation.ts` `earlierSeq`, `relationships.ts`);
774
+ * - `data.shadowedRange.start` / `.end` and `data.shadowedSeqs` —
775
+ * `compaction/prune` / `compaction/summary` (`payload-validation.ts`
776
+ * `shadowedValue`, `relationships.ts` `assertCurrentSurfaceSpan`);
777
+ * - `data.messageSeqs` — `session/title` / `session/title-llm-request`
778
+ * (`payload-validation.ts` `seqArray`, `relationships.ts` `assertTitleSources`).
779
+ *
780
+ * Deliberately NOT references:
781
+ * - `data.throughSeq` (`session-log-deepseek/delivery-accepted`) — a cursor over
782
+ * THIS log's own delivered stream: `session-log-deepseek/src/index.ts:119-148`
783
+ * folds it as an accepted-sequence watermark and `:168` emits the last seq of
784
+ * this snapshot, and the shipped invariant is exactly "must identify an earlier
785
+ * event" (`session-log-deepseek/src/invariant.ts:34-44`). It is left opaque
786
+ * because the released remappers do not shift it
787
+ * (`session-format-v2-to-v3/src/references.ts:31-50` — the same switch that
788
+ * renumbers the seven members above — and its V1→V2 twin
789
+ * `session-format-v1-to-v2/src/migration.ts:566-661`), the released V2→V3
790
+ * migration only *inspects* the delivery marker (`migration.ts:64-67,88-91`),
791
+ * and an invalid value is refused by `earlierSeq`
792
+ * (`payload-validation.ts:179`) → the pre-write strict restore fails the whole
793
+ * file closed (reason `other`). Known residual, deliberately not code-guarded:
794
+ * a drop *preceding* a still-valid `throughSeq` leaves it naming a different,
795
+ * later event (0 occurrences in the measured corpus).
796
+ * - `data.seq` (`tool-workflow/*`) — a workflow-local counter;
797
+ * - `data.start` (`agent/inbox/spliced`) — an inbox position, only counted
798
+ * (`payload-validation.ts` `countValue`), never used as an event index.
799
+ * A member that is present but not a safe integer is skipped: the released codec
800
+ * refuses such a row outright, and this rule must not "repair" a reference it
801
+ * cannot read. A Session seq is non-negative by contract, but these scalars are
802
+ * admitted as any safe integer, so an already-invalid NEGATIVE member (`-1`) is
803
+ * enumerated too and the remap's boundary check is one-sided for it (it passes
804
+ * `after < newSeq`, is skipped because `after === value`, and is written nowhere
805
+ * — see {@link renumberSurvivingEvents}). Not a regression: neither this
806
+ * enumeration nor the remap can create one, and the CLI's strict pre-write
807
+ * restore refuses such a row upstream.
808
+ */
809
+ function seqReferences(row: ParsedRow): SeqReference[] {
810
+ const references: SeqReference[] = []
811
+ const addScalar = (path: SeqPath, value: unknown): void => {
812
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) return
813
+ references.push({ label: pathLabel(path), positions: [{ path, value }], spans: [{ start: value, end: value }] })
814
+ }
815
+ const sourceEventSeqs = envelopeValue(row, 'sourceEventSeqs')
816
+ if (Array.isArray(sourceEventSeqs)) {
817
+ sourceEventSeqs.forEach((entry, index) => {
818
+ const path: SeqPath = ['sourceEventSeqs', index]
819
+ const endpoints = Array.isArray(entry) && entry.length === 2 ? entry : null
820
+ if (endpoints === null) {
821
+ addScalar(path, entry)
822
+ return
823
+ }
824
+ const [start, end] = endpoints as [unknown, unknown]
825
+ if (typeof start !== 'number' || typeof end !== 'number'
826
+ || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
827
+ return
828
+ }
829
+ references.push({
830
+ label: pathLabel(path),
831
+ positions: [{ path: [...path, 0], value: start }, { path: [...path, 1], value: end }],
832
+ spans: [{ start, end }],
833
+ })
834
+ })
835
+ }
836
+ const operation = envelopeValue(row, 'surfaceOp')
837
+ if (isRecord(operation) && operation['op'] === 'replace') {
838
+ addScalar(['surfaceOp', 'start'], operation['start'])
839
+ addScalar(['surfaceOp', 'end'], operation['end'])
840
+ }
841
+ const data = row.data
842
+ if (!isRecord(data)) return references
843
+ switch (row.type) {
844
+ case 'command/done':
845
+ addScalar(['data', 'sourceEventSeq'], data['sourceEventSeq'])
846
+ break
847
+ case 'compaction/prune':
848
+ case 'compaction/summary': {
849
+ const range = data['shadowedRange']
850
+ if (isRecord(range)) {
851
+ addScalar(['data', 'shadowedRange', 'start'], range['start'])
852
+ addScalar(['data', 'shadowedRange', 'end'], range['end'])
853
+ }
854
+ const shadowedSeqs = data['shadowedSeqs']
855
+ if (Array.isArray(shadowedSeqs)) {
856
+ shadowedSeqs.forEach((value, index) => addScalar(['data', 'shadowedSeqs', index], value))
857
+ }
858
+ break
859
+ }
860
+ case 'session/title':
861
+ case 'session/title-llm-request': {
862
+ const messageSeqs = data['messageSeqs']
863
+ if (Array.isArray(messageSeqs)) {
864
+ messageSeqs.forEach((value, index) => addScalar(['data', 'messageSeqs', index], value))
865
+ }
866
+ break
867
+ }
868
+ default:
869
+ break
870
+ }
871
+ return references
872
+ }
873
+
874
+ /** One leaf difference between a source row and its renumbered survivor. */
875
+ interface RowDifference {
876
+ path: SeqPath
877
+ before: unknown
878
+ after: unknown
879
+ }
880
+
881
+ /**
882
+ * Every leaf difference between two JSON trees, in path order.
883
+ *
884
+ * This is the renumber's byte-identity proof: the renumber writes through
885
+ * {@link writePath} copies, so anything it changed that it did not plan shows up
886
+ * here as an unaudited difference, and any planned write that did not land shows
887
+ * up as a missing one.
888
+ */
889
+ function rowDifferences(before: unknown, after: unknown, path: SeqPath = []): RowDifference[] {
890
+ if (before === after) return []
891
+ if (Array.isArray(before) && Array.isArray(after)) {
892
+ if (before.length !== after.length) return [{ path, before: before.length, after: after.length }]
893
+ return before.flatMap((member, index) => rowDifferences(member, after[index], [...path, index]))
894
+ }
895
+ if (isRecord(before) && isRecord(after)) {
896
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
897
+ return [...keys].flatMap((key) => rowDifferences(before[key], after[key], [...path, key]))
898
+ }
899
+ return [{ path, before, after }]
900
+ }
901
+
902
+ /** Key of one written/observed member, unique per path (paths may contain `.`). */
903
+ function writeKey(path: SeqPath): string {
904
+ return path.map((step) => String(step)).join('\u0000')
905
+ }
906
+
907
+ /** One audited write the renumber performed. */
908
+ interface RenumberWrite {
909
+ path: SeqPath
910
+ before: number
911
+ after: number
912
+ }
913
+
914
+ /** Whether one observed difference IS one audited write. */
915
+ function isWritten(write: RenumberWrite, difference: RowDifference): boolean {
916
+ return writeKey(write.path) === writeKey(difference.path)
917
+ && write.before === difference.before
918
+ && write.after === difference.after
919
+ }
920
+
921
+ /** How many dropped events precede `seq` — the monotone shift of that seq. */
922
+ function droppedBefore(dropped: readonly number[], seq: number): number {
923
+ let low = 0
924
+ let high = dropped.length
925
+ while (low < high) {
926
+ const middle = (low + high) >>> 1
927
+ if ((dropped[middle] as number) < seq) low = middle + 1
928
+ else high = middle
929
+ }
930
+ return low
931
+ }
932
+
933
+ /** Prefix of the refusal that means "a survivor names a dropped seq". */
934
+ const REFERENCE_INTEGRITY_REFUSAL_PREFIX = 'reference integrity: '
935
+
936
+ /** Prefix of the refusal that means "the renumber would cross the seed cut". */
937
+ const SEED_CUT_REFUSAL_PREFIX = 'the renumber crosses the header seed cut: '
938
+
939
+ /** Why the opt-in lossy recovery refused one log (see `lossyRefusalReason`). */
940
+ export type LossyRefusalReason = 'reference-integrity' | 'seed-cut' | 'other'
941
+
942
+ /**
943
+ * The category of one lossy refusal, from the rule's own refusal text. The two
944
+ * prefixes are built from the constants above, so this classifies what
945
+ * {@link renumberSurvivingEvents} raises by construction — never by guessing.
946
+ */
947
+ export function lossyRefusalReason(refusal: string): LossyRefusalReason {
948
+ if (refusal.includes(REFERENCE_INTEGRITY_REFUSAL_PREFIX)) return 'reference-integrity'
949
+ if (refusal.includes(SEED_CUT_REFUSAL_PREFIX)) return 'seed-cut'
950
+ return 'other'
951
+ }
952
+
953
+ /**
954
+ * Renumber the survivors of a proven drop so the released V0→V1 edge accepts
955
+ * them again, or refuse the whole file.
956
+ *
957
+ * WHY: the codec requires `seq === eventCount` for every row, and a packed run's
958
+ * `firstSeq` to equal the event count reached so far, so removing any row that is
959
+ * not at the tail leaves every later row with a stale coordinate. Each surviving
960
+ * event's coordinate becomes its 0-based position in the SURVIVING event stream
961
+ * (a packed run keeps its extent and moves its `seq0`), and every audited
962
+ * reference is shifted monotonically by the number of dropped events before it.
963
+ *
964
+ * The gate runs over the whole file first and is fail closed:
965
+ * - every source row (kept or dropped) must declare exactly the event position
966
+ * it occupies, or the survivors are not densely numbered and no remap is
967
+ * provable;
968
+ * - no surviving reference may name a dropped seq (or cover one inside a
969
+ * `[start, end]` range) — that reference would silently point at a different
970
+ * event after the renumber;
971
+ * - no dropped event may precede the header's seed cut, which would move the
972
+ * first own event into the inherited region.
973
+ * Then every written member is proven to be exactly one audited remap step
974
+ * (see {@link rowDifferences}), and every written reference is re-proven at the
975
+ * remap boundary: it names no dropped seq, and its new coordinate is still an
976
+ * EARLIER surviving event (`after < newSeq`).
977
+ *
978
+ * Exported because this is the load-bearing half of the opt-in lossy rule: the
979
+ * CLI re-derives it over the drop rule's own output before any write.
980
+ *
981
+ * @param input the unmodified log rows (header first), exactly as parsed.
982
+ * @param survivors the drop's output: `input` minus the removable legacy rows.
983
+ * @returns the renumbered rows and how many surviving events moved, or the reason
984
+ * the file must not be written.
985
+ */
986
+ export function renumberSurvivingEvents(
987
+ input: readonly ParsedRow[],
988
+ survivors: readonly ParsedRow[],
989
+ ): { rows: ParsedRow[]; renumberedEventCount: number } | { refused: string } {
990
+ const [header, ...events] = input
991
+ const [survivorHeader, ...survivorEvents] = survivors
992
+ if (header === undefined) return { refused: 'the log carries no header row' }
993
+ if (survivorHeader === undefined || JSON.stringify(survivorHeader) !== JSON.stringify(header)) {
994
+ return { refused: 'the renumber would change or remove the session header row' }
995
+ }
996
+
997
+ interface Kept {
998
+ source: ParsedRow
999
+ survivor: ParsedRow
1000
+ oldSeq: number
1001
+ newSeq: number
1002
+ events: number
1003
+ }
1004
+ const kept: Kept[] = []
1005
+ const dropped: number[] = []
1006
+ let position = 0
1007
+ let newPosition = 0
1008
+ let cursor = 0
1009
+ for (const [offset, row] of events.entries()) {
1010
+ const events_ = rowEventCount(row)
1011
+ if (events_ === null) {
1012
+ return {
1013
+ refused: `${row.type} ${describe(envelopeValue(row, 'seq0'))} is a packed Assistant run whose payload is not the string array the released codec requires`,
1014
+ }
1015
+ }
1016
+ const declared = declaredEventSeq(row)
1017
+ if (declared !== position) {
1018
+ return {
1019
+ refused: `row ${offset + 2} (${row.type}) declares seq ${describe(declared)} at event position ${position}, so the survivors are not densely numbered`,
1020
+ }
1021
+ }
1022
+ const survivor = survivorEvents[cursor]
1023
+ if (survivor !== undefined && JSON.stringify(survivor) === JSON.stringify(row)) {
1024
+ kept.push({ source: row, survivor, oldSeq: position, newSeq: newPosition, events: events_ })
1025
+ cursor += 1
1026
+ newPosition += events_
1027
+ } else if (isRemovableLegacyEvent(row)) {
1028
+ if (events_ !== 1) {
1029
+ return { refused: `the removable ${FALLBACKS_SWITCH_TYPE} row ${position} spans ${events_} events` }
1030
+ }
1031
+ dropped.push(position)
1032
+ } else {
1033
+ return {
1034
+ refused: `row ${offset + 2} (${row.type}) is neither kept byte-identically nor a removable ${FALLBACKS_SWITCH_TYPE} row, so the renumber cannot be proven`,
1035
+ }
1036
+ }
1037
+ position += events_
1038
+ }
1039
+ if (cursor !== survivorEvents.length) {
1040
+ return { refused: `only ${cursor} of the ${survivorEvents.length} survivor event(s) match the input rows in order` }
1041
+ }
1042
+
1043
+ for (const { source, oldSeq } of kept) {
1044
+ for (const reference of seqReferences(source)) {
1045
+ for (const span of reference.spans) {
1046
+ // Binary search over the ASCENDING dropped positions: a span holds a
1047
+ // dropped seq exactly when the count below its end exceeds the count
1048
+ // below its start. Linear in the reference surface, not in the drop count.
1049
+ if (droppedBefore(dropped, span.end + 1) !== droppedBefore(dropped, span.start)) {
1050
+ const hit = dropped[droppedBefore(dropped, span.start)] as number
1051
+ return {
1052
+ refused: `${REFERENCE_INTEGRITY_REFUSAL_PREFIX}${source.type} ${oldSeq} ${reference.label} names the dropped ${FALLBACKS_SWITCH_TYPE} seq ${hit}`,
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ const seedCut = headerSeedCut(input)
1060
+ const belowCut = dropped.find((seq) => seq < seedCut)
1061
+ if (belowCut !== undefined) {
1062
+ return {
1063
+ refused: `${SEED_CUT_REFUSAL_PREFIX}the dropped ${FALLBACKS_SWITCH_TYPE} seq ${belowCut} precedes the header's seedLength ${seedCut}, `
1064
+ + 'so renumbering outside it would move the first own event into the inherited region',
1065
+ }
1066
+ }
1067
+
1068
+ const droppedSeqs = new Set(dropped)
1069
+ const rows: ParsedRow[] = [header]
1070
+ let renumberedEventCount = 0
1071
+ for (const { source, survivor, oldSeq, newSeq, events: extent } of kept) {
1072
+ let next = survivor
1073
+ const written: RenumberWrite[] = []
1074
+ if (newSeq !== oldSeq) {
1075
+ const path: SeqPath = [isPackedRun(survivor) ? 'seq0' : 'seq']
1076
+ const before = declaredEventSeq(survivor)
1077
+ next = writePath(next, path, newSeq) as ParsedRow
1078
+ written.push({ path, before: before as number, after: newSeq })
1079
+ renumberedEventCount += extent
1080
+ }
1081
+ for (const reference of seqReferences(survivor)) {
1082
+ for (const { path, value } of reference.positions) {
1083
+ const after = value - droppedBefore(dropped, value)
1084
+ // The post-remap assertion, at the remap boundary and in the only
1085
+ // coordinate space where each half means something:
1086
+ // - the OLD value must not be a dropped seq (`droppedSeqs`; the gate above
1087
+ // already proves it for every span, this re-proves it per written member);
1088
+ // - the NEW value must still name an EARLIER surviving event (`after < newSeq`).
1089
+ // Both halves are one-sided for an already-invalid NEGATIVE member: `-1`
1090
+ // is not a dropped seq and passes `after < newSeq`, then `after === value`
1091
+ // skips it, so it is written nowhere (see `seqReferences`).
1092
+ // `after` is a NEW position, so testing it against the dropped OLD positions
1093
+ // would be a category error: a surviving event legitimately moves down onto a
1094
+ // dropped event's old index.
1095
+ if (droppedSeqs.has(value) || after >= newSeq) {
1096
+ return {
1097
+ refused: `${REFERENCE_INTEGRITY_REFUSAL_PREFIX}${survivor.type} ${oldSeq} ${pathLabel(path)} names ${value}, `
1098
+ + `which is not an earlier surviving event (remapped to ${after}, before ${newSeq} required)`,
1099
+ }
1100
+ }
1101
+ if (after === value) continue
1102
+ next = writePath(next, path, after) as ParsedRow
1103
+ written.push({ path, before: value, after })
1104
+ }
1105
+ }
1106
+ // Two-sided proof, keyed by path: a row can carry hundreds of thousands of
1107
+ // reference members, so neither side may scan the other linearly.
1108
+ const differences = rowDifferences(source, next)
1109
+ const writesByPath = new Map(written.map((write) => [writeKey(write.path), write]))
1110
+ const unaudited = differences.find((difference) => {
1111
+ const write = writesByPath.get(writeKey(difference.path))
1112
+ return write === undefined || !isWritten(write, difference)
1113
+ })
1114
+ if (unaudited !== undefined) {
1115
+ return {
1116
+ refused: `the renumber changed ${pathLabel(unaudited.path)} (${describe(unaudited.before)} → ${describe(unaudited.after)}) outside its audited remap fields`,
1117
+ }
1118
+ }
1119
+ const differencesByPath = new Map(differences.map((difference) => [writeKey(difference.path), difference]))
1120
+ const missing = written.find((write) => {
1121
+ const difference = differencesByPath.get(writeKey(write.path))
1122
+ return difference === undefined || !isWritten(write, difference)
1123
+ })
1124
+ if (missing !== undefined) {
1125
+ return { refused: `the renumber did not apply its own ${pathLabel(missing.path)} step` }
1126
+ }
1127
+ rows.push(next)
1128
+ }
1129
+
1130
+ // Output-side invariant, independent of the input-side walk above: the rows this
1131
+ // function RETURNS must satisfy the released edge's own `seq === eventCount` rule
1132
+ // (packed runs included), so a shared bug in the input walk cannot produce a
1133
+ // successor that merely looks renumbered.
1134
+ let densePosition = 0
1135
+ for (const row of rows.slice(1)) {
1136
+ const extent = rowEventCount(row)
1137
+ const declared = declaredEventSeq(row)
1138
+ if (extent === null || declared !== densePosition) {
1139
+ return {
1140
+ refused: `the renumber produced ${row.type} at event position ${densePosition} declaring ${describe(declared)}; the released edge requires dense seqs`,
1141
+ }
1142
+ }
1143
+ densePosition += extent
1144
+ }
1145
+ return { rows, renumberedEventCount }
1146
+ }
1147
+
1148
+ /* ------------------------------------------------------------------ */
1149
+ /* registry */
1150
+ /* ------------------------------------------------------------------ */
1151
+
1152
+ export const sourceKindRule: LogRule = {
1153
+ id: 'source-kind',
1154
+ class: 'source-kind',
1155
+ detect: detectSourceKinds,
1156
+ normalize: normalizeSourceKinds,
1157
+ // A foreign message source is always rewritable into the released plugin arm.
1158
+ repairable: () => true,
1159
+ }
1160
+
1161
+ export const subagentDescriptorVersionRule: LogRule = {
1162
+ id: 'subagent-descriptor-version',
1163
+ class: 'subagent-descriptor-version',
1164
+ detect: detectDescriptorVersions,
1165
+ normalize: normalizeDescriptorVersions,
1166
+ // An earlier descriptor version is bumped only when its payload proves admissible,
1167
+ // but the class itself is repairable: it always rewrites or refuses, never drops.
1168
+ repairable: () => true,
1169
+ }
1170
+
1171
+ export const fallbacksSwitchRule: LogRule = {
1172
+ id: 'fallbacks-switch',
1173
+ class: 'unknown-event-type',
1174
+ detect: detectFallbacksSwitch,
1175
+ normalize(rows) {
1176
+ const findings = detectFallbacksSwitch(rows)
1177
+ if (findings.length > 0) return { refused: FALLBACKS_SWITCH_REFUSAL }
1178
+ return { rows: [...rows], findings: [] }
1179
+ },
1180
+ // A detector, never a repair: this rule exists so the class is REPORTED when the
1181
+ // lossy opt-in is absent.
1182
+ repairable: () => false,
1183
+ }
1184
+
1185
+ /**
1186
+ * The OPT-IN lossy recovery for the legacy `fallbacks/switch` rows: `normalize`
1187
+ * removes exactly those rows, renumbers the survivors so the released V0→V1 edge
1188
+ * accepts them again, and reports one finding per removed row (so
1189
+ * {@link droppedEventCount} stays the drop count; the renumbered-event count is
1190
+ * {@link renumberSurvivingEvents}'s, not a finding).
1191
+ *
1192
+ * DELIBERATELY NOT a member of {@link BUILT_IN_RULES}: the default registry stays
1193
+ * strictly non-lossy, so this rule can only enter a run through the CLI's
1194
+ * explicit `--drop-legacy-events`. Scope is exact and self-proven — only a row
1195
+ * whose PARSED `type` is `fallbacks/switch` is removed, every survivor is
1196
+ * re-serialized and matched against its source row before the rule returns, a
1197
+ * row of any other unknown event type is left in place (which leaves such a log
1198
+ * unrepairable rather than silently lossy), and the renumber refuses the file
1199
+ * outright when a surviving reference names a dropped seq. It therefore runs
1200
+ * FIRST in a lossy chain, so its input is the unmodified log. Replacing the
1201
+ * detector it stands beside (`fallbacks-switch`) is the caller's decision: both
1202
+ * policies cover the same rows, and the detector refuses what this rule removes.
1203
+ */
1204
+ export const dropLegacyEventsRule: LogRule = {
1205
+ id: DROP_LEGACY_EVENTS_RULE_ID,
1206
+ class: 'unknown-event-type',
1207
+ detect: detectDroppableLegacyEvents,
1208
+ normalize: normalizeDroppedLegacyEvents,
1209
+ // Repairable only for a log that actually carries a row this rule may remove: a
1210
+ // differently-unknown event type stays unrepairable even under the opt-in, which
1211
+ // is what the CLI's class gate used to special-case.
1212
+ repairable: (rows) => rows.some(isRemovableLegacyEvent),
1213
+ }
1214
+
1215
+ /**
1216
+ * Built-in rules in check order: the first refused row decides the log's class,
1217
+ * and within one row the first rule here decides it.
1218
+ *
1219
+ * Strictly non-lossy by construction: every rule either proves a shape-preserving
1220
+ * rewrite or refuses, so an unrepairable log is never silently modified. The
1221
+ * lossy `dropLegacyEventsRule` is absent here on purpose.
1222
+ */
1223
+ export const BUILT_IN_RULES: readonly LogRule[] = [
1224
+ sourceKindRule,
1225
+ subagentDescriptorVersionRule,
1226
+ fallbacksSwitchRule,
1227
+ ]