cctally 1.92.2 → 1.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -0,0 +1,959 @@
1
+ """Durable selector state — the pure kernel (#496 S5b Stage 1, spec §3).
2
+
3
+ `_lib_journal.resolve_effective_events` accumulates six things over the record
4
+ stream — per-evt candidates, batch markers, batch actions, protocol resolutions,
5
+ tainted batches with their distinct violations, and a per-fingerprint
6
+ `violation_available_after` minimum — and returns only a summary. Every live
7
+ tick that meets a correction record therefore re-derives them by reading the
8
+ whole journal prefix. This module turns those six into durable rows and merges a
9
+ delta into them, which is what lets a validated generation continue the fold
10
+ instead of restarting it.
11
+
12
+ Two rules govern what is stored, and both are load-bearing:
13
+
14
+ **No per-candidate table.** Same-revision containment needs only, per event id,
15
+ the winning revision, the lowest-sequence winner and the set of distinct content
16
+ hashes observed at that revision. `journal_effective_events` is already keyed
17
+ that way, so two added columns complete it.
18
+
19
+ **Action cores are retained while a batch is `begin_only` OR `tainted`, and
20
+ dropped only on `completed`.** An early taint does not end a batch's record
21
+ stream: later actions and a commit can still establish a further violation such
22
+ as `manifest_actions_hash_mismatch`, whose derivation hashes every first-seen
23
+ action core. Dropping cores at taint would leave that underivable. Dropping on
24
+ `completed` is safe in both directions — a duplicate that matches changes
25
+ nothing, and one that conflicts is detected from the retained whole-record
26
+ digest, which taints the batch and forces a rebuild that re-derives from the
27
+ journal.
28
+
29
+ This module imports nothing outside the stdlib and `_lib_journal`, and in
30
+ particular never imports `_cctally_journal`, so it is unit-testable without a
31
+ journal on disk — the same rule `bin/_lib_journal_router.py` follows.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from dataclasses import dataclass, replace
37
+
38
+ import _lib_journal as _jl
39
+
40
+
41
+ #: Bumped whenever the durable row shapes or the merge semantics change. A
42
+ #: mismatch is one of the states that falls back to full selection silently.
43
+ SELECTOR_VERSION = 1
44
+
45
+ #: What `_lib_journal.decode_line` returns for a line that is not a record. A
46
+ #: malformed line produces NO decoded entry, so it consumes no sequence number.
47
+ MALFORMED = None
48
+
49
+ _BEGIN_ONLY = "begin_only"
50
+ _COMPLETED = "completed"
51
+ _TAINTED = "tainted"
52
+
53
+
54
+ class IncrementalSelectionUnavailable(Exception):
55
+ """The delta contains something an incremental merge may not decide alone.
56
+
57
+ Two things reach it:
58
+
59
+ - a `journal_protocol_resolution` op, because acknowledging a violation
60
+ requires an exact length-framed raw-prefix SHA-256 and no semantic summary
61
+ can reconstruct that hash, so the caller must fall back to full selection
62
+ rather than accept a claimed one; and
63
+ - a batch whose durable status is `completed` gaining a marker phase or an
64
+ action sequence the durable rows do not hold. Its action cores were
65
+ dropped at completion, so its verdict can only be carried forward, and
66
+ carrying it forward over a record that a full pass would have folded into
67
+ the verdict is exactly how the two paths diverge.
68
+ """
69
+
70
+
71
+ # --------------------------------------------------------------------------
72
+ # row shapes
73
+ # --------------------------------------------------------------------------
74
+
75
+ @dataclass(frozen=True)
76
+ class SelectorStateRow:
77
+ """`journal_selector_state` — one row."""
78
+
79
+ next_sequence: int
80
+ selector_version: int = SELECTOR_VERSION
81
+ covered_segment: "str | None" = None
82
+ covered_offset: "int | None" = None
83
+ #: Written at PUBLICATION, not at scratch construction: the publication
84
+ #: stamp does not exist while the scratch is being built, so a row populated
85
+ #: then cannot carry the identity it will publish under.
86
+ generation_record_path: "str | None" = None
87
+ generation_stamped_at_utc: "str | None" = None
88
+ #: `cutover_seen` distinguishes "no cutover op exists" from "the op exists
89
+ #: and recorded no account". A plain NULL cannot carry both answers.
90
+ cutover_seen: bool = False
91
+ cutover_account_key: "str | None" = None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class SelectorBatchRow:
96
+ """`journal_selector_batches` — one row per correction batch."""
97
+
98
+ batch_id: str
99
+ status: str
100
+ action_count: "int | None" = None
101
+ action_set_hash: "str | None" = None
102
+ begin_segment: "str | None" = None
103
+ begin_offset: "int | None" = None
104
+ earliest_commit_segment: "str | None" = None
105
+ earliest_commit_offset: "int | None" = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class SelectorBatchRecordRow:
110
+ """`journal_selector_batch_records` — one row per marker and per action."""
111
+
112
+ batch_id: str
113
+ kind: str # "marker" | "action"
114
+ key: str # the phase, or the action sequence rendered as text
115
+ record_digest: str
116
+ sequence: int
117
+ identity_digest: "str | None" = None
118
+ action_core_json: "str | None" = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class SelectorEffectiveRow:
123
+ """`journal_effective_events` — one row per event id."""
124
+
125
+ event_id: str
126
+ rev: int
127
+ status: str
128
+ content_hash: str
129
+ batch_id: "str | None"
130
+ event_json: "str | None"
131
+ winning_sequence: int
132
+ conflict_hashes_json: "str | None" = None
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class SelectorViolationRow:
137
+ """`journal_protocol_violations` — one row per distinct violation."""
138
+
139
+ fingerprint: str
140
+ batch_id: str
141
+ kind: str
142
+ violation_json: str
143
+ available_after: "int | None" = None
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class SelectorRows:
148
+ """One generation's durable selector state, as comparable value objects."""
149
+
150
+ state: SelectorStateRow
151
+ batches: tuple = ()
152
+ batch_records: tuple = ()
153
+ effective: tuple = ()
154
+ violations: tuple = ()
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class TaintTransition:
159
+ """A batch whose durable status was `completed` and is now `tainted`.
160
+
161
+ `causal_sequence` is the sequence of the FIRST record that established the
162
+ taint — never the batch's earliest commit. A rebuild bounded at the commit
163
+ excludes the tainting record, faithfully reproduces the completed
164
+ correction, and meets the same taint on the next tick, which is a livelock
165
+ rather than a recovery (spec §3.7).
166
+ """
167
+
168
+ batch_id: str
169
+ causal_sequence: int
170
+
171
+
172
+ # --------------------------------------------------------------------------
173
+ # the decoded-entry counting primitive
174
+ # --------------------------------------------------------------------------
175
+
176
+ def decoded_entry_count(decode_results) -> int:
177
+ """How many `decoded` entries a stream of PHYSICAL LINES produces.
178
+
179
+ ``decode_results`` is one element per physical journal line — exactly what
180
+ `_lib_journal.decode_line` returned for it — so ``None`` here means the line
181
+ FAILED TO DECODE. That is the opposite of the `None` in the rebuild's
182
+ `decoded` list, which is a placeholder for a valid non-retained record; the
183
+ two conventions are distinct and this primitive takes the first.
184
+
185
+ Mirrors the rebuild read loop's branching exactly: a decoded record produces
186
+ ONE entry whether or not its type is retained, because the loop appends an
187
+ explicit `None` PLACEHOLDER for a valid non-retained record; a line that
188
+ failed to decode produces none, because the loop skips and counts it
189
+ separately.
190
+
191
+ That distinction is not bookkeeping. `resolve_effective_events` numbers
192
+ candidates with `enumerate(records)`, three of the seven structural
193
+ violation kinds put that number inside `ProtocolViolation.evidence`, and the
194
+ fingerprint hashes it. The fingerprint is durable — it lands in
195
+ `journal_protocol_violations` and is referenced by name from a
196
+ `journal_protocol_resolution` op — so renumbering would make a previously
197
+ acknowledged violation unresolvable and raise on every later rebuild. An
198
+ elided segment therefore contributes exactly this count in its stead.
199
+ """
200
+ return sum(1 for result in decode_results if isinstance(result, dict))
201
+
202
+
203
+ # --------------------------------------------------------------------------
204
+ # full selection -> durable rows
205
+ # --------------------------------------------------------------------------
206
+
207
+ def _batch_status(fold, batch_id: str) -> str:
208
+ if batch_id in fold.tainted_batches:
209
+ return _TAINTED
210
+ if batch_id in fold.completed:
211
+ return _COMPLETED
212
+ return _BEGIN_ONLY
213
+
214
+
215
+ def _coordinate(coordinates, sequence):
216
+ if not coordinates or sequence is None:
217
+ return (None, None)
218
+ found = coordinates.get(sequence)
219
+ return (None, None) if found is None else (found[0], int(found[1]))
220
+
221
+
222
+ def _canonical_json(value) -> str:
223
+ return json.dumps(value, separators=(",", ":"), sort_keys=True,
224
+ ensure_ascii=False)
225
+
226
+
227
+ def _batch_rows(fold, coordinates):
228
+ batches = []
229
+ records = []
230
+ for batch_id in sorted(set(fold.markers) | set(fold.actions)):
231
+ status = _batch_status(fold, batch_id)
232
+ markers = fold.markers.get(batch_id, {})
233
+ begin = markers.get("begin")
234
+ commit = markers.get("commit")
235
+ begin_segment, begin_offset = _coordinate(
236
+ coordinates, begin[3] if begin else None)
237
+ commit_segment, commit_offset = _coordinate(
238
+ coordinates, commit[3] if commit else None)
239
+ batches.append(
240
+ SelectorBatchRow(
241
+ batch_id=batch_id,
242
+ status=status,
243
+ action_count=begin[0]["action_count"] if begin else None,
244
+ action_set_hash=begin[0]["actions_hash"] if begin else None,
245
+ begin_segment=begin_segment,
246
+ begin_offset=begin_offset,
247
+ earliest_commit_segment=commit_segment,
248
+ earliest_commit_offset=commit_offset,
249
+ )
250
+ )
251
+ for phase in sorted(markers):
252
+ _core, digest, identity_digest, sequence = markers[phase]
253
+ records.append(
254
+ SelectorBatchRecordRow(
255
+ batch_id=batch_id,
256
+ kind="marker",
257
+ key=phase,
258
+ record_digest=digest,
259
+ sequence=sequence,
260
+ identity_digest=identity_digest,
261
+ )
262
+ )
263
+ for seq in sorted(fold.actions.get(batch_id, {})):
264
+ normalized, digest, sequence = fold.actions[batch_id][seq]
265
+ records.append(
266
+ SelectorBatchRecordRow(
267
+ batch_id=batch_id,
268
+ kind="action",
269
+ key=str(seq),
270
+ record_digest=digest,
271
+ sequence=sequence,
272
+ action_core_json=(
273
+ None
274
+ if status == _COMPLETED or normalized is None
275
+ else _canonical_json(
276
+ _jl._correction_action_core(normalized))
277
+ ),
278
+ )
279
+ )
280
+ # Canonical order `(batch_id, kind, key)` — the same order a durable read
281
+ # returns, so a stored generation and an in-memory derivation compare equal
282
+ # without either side re-sorting. `key` is text on both sides, so SQLite's
283
+ # BINARY collation and Python's string ordering agree.
284
+ records.sort(key=lambda row: (row.batch_id, row.kind, row.key))
285
+ return tuple(batches), tuple(records)
286
+
287
+
288
+ def _effective_rows(selection):
289
+ conflicts = {
290
+ conflict.event_id: list(conflict.content_hashes)
291
+ for conflict in selection.conflicts
292
+ }
293
+ rows = []
294
+ for event_id, selected in selection.by_id.items():
295
+ event_json = None
296
+ if selected.record is not None:
297
+ event_json = (
298
+ _jl.encode_line(selected.record).decode("utf-8").rstrip("\n")
299
+ )
300
+ hashes = conflicts.get(event_id)
301
+ rows.append(
302
+ SelectorEffectiveRow(
303
+ event_id=event_id,
304
+ rev=selected.rev,
305
+ status=selected.status,
306
+ content_hash=selected.content_hash,
307
+ batch_id=selected.batch_id,
308
+ event_json=event_json,
309
+ winning_sequence=selected.sequence,
310
+ conflict_hashes_json=(
311
+ None if hashes is None else _canonical_json(hashes)
312
+ ),
313
+ )
314
+ )
315
+ rows.sort(key=lambda row: row.event_id)
316
+ return tuple(rows)
317
+
318
+
319
+ def violation_rows(selection, fold):
320
+ """Durable violation rows for ONE full selection.
321
+
322
+ Public because the live path's full-prefix fallback writes the same rows the
323
+ rebuild does. Deriving them twice, in two places, is how the two ended up
324
+ disagreeing on `available_after` and on `ensure_ascii`.
325
+ """
326
+ rows = []
327
+ for violation in (
328
+ *selection.protocol_violations,
329
+ *selection.acknowledged_protocol_violations,
330
+ ):
331
+ rows.append(
332
+ SelectorViolationRow(
333
+ fingerprint=violation.fingerprint,
334
+ batch_id=violation.batch_id,
335
+ kind=violation.kind,
336
+ violation_json=_canonical_json(violation.to_dict()),
337
+ available_after=fold.violation_available_after.get(
338
+ violation.fingerprint),
339
+ )
340
+ )
341
+ rows.sort(key=lambda row: (row.batch_id, row.kind, row.fingerprint))
342
+ return tuple(rows)
343
+
344
+
345
+ def rows_from_selection(
346
+ selection,
347
+ *,
348
+ accumulators,
349
+ next_sequence: int,
350
+ coordinates=None,
351
+ covered=None,
352
+ cutover_seen: bool = False,
353
+ cutover_account_key: "str | None" = None,
354
+ ) -> SelectorRows:
355
+ """Durable rows for ONE full selection.
356
+
357
+ ``accumulators`` is the out-dict `resolve_effective_events` populated, which
358
+ is where the six accumulators live; `EffectiveSelection` deliberately did
359
+ not grow a field for them, so every existing caller stays byte-unaffected.
360
+
361
+ ``next_sequence`` is the pass's TOTAL decoded-entry count — the sequence the
362
+ next stream must start at.
363
+
364
+ ``coordinates`` maps a sequence number to its ``(segment, end_offset)``.
365
+ Only correction-batch MARKER sequences are ever looked up, so a caller need
366
+ populate no more than those; a missing entry leaves the coordinate columns
367
+ NULL rather than guessing one.
368
+ """
369
+ fold = accumulators["fold"]
370
+ batches, batch_records = _batch_rows(fold, coordinates)
371
+ covered_segment, covered_offset = (
372
+ (None, None) if covered is None else (covered[0], int(covered[1]))
373
+ )
374
+ return SelectorRows(
375
+ state=SelectorStateRow(
376
+ next_sequence=next_sequence,
377
+ selector_version=SELECTOR_VERSION,
378
+ covered_segment=covered_segment,
379
+ covered_offset=covered_offset,
380
+ cutover_seen=bool(cutover_seen),
381
+ cutover_account_key=cutover_account_key,
382
+ ),
383
+ batches=batches,
384
+ batch_records=batch_records,
385
+ effective=_effective_rows(selection),
386
+ violations=violation_rows(selection, fold),
387
+ )
388
+
389
+
390
+ # --------------------------------------------------------------------------
391
+ # durable rows -> a seeded fold
392
+ # --------------------------------------------------------------------------
393
+
394
+ def comparable(rows: "SelectorRows | None"):
395
+ """``rows`` with the generation identity cleared, for validation.
396
+
397
+ The identity is written at PUBLICATION, so a durable generation carries it
398
+ and a fresh derivation from the journal cannot. Comparing it would make
399
+ every validation of a published index fail. Everything else — the covered
400
+ prefix, `next_sequence`, `selector_version`, the cutover pair, and all four
401
+ row groups — IS compared.
402
+ """
403
+ if rows is None:
404
+ return None
405
+ return replace(
406
+ rows,
407
+ state=replace(
408
+ rows.state,
409
+ generation_record_path=None,
410
+ generation_stamped_at_utc=None,
411
+ ),
412
+ )
413
+
414
+
415
+ def _seed_fold(rows: SelectorRows):
416
+ """Reconstruct the accumulator state the durable rows describe.
417
+
418
+ A marker's normalized core is rebuilt from the batch row's `action_count`
419
+ and `action_set_hash`, which is exactly the subset phase 2 reads, plus the
420
+ stored identity digest — and identity-digest equality implies core equality,
421
+ because the identity digest covers the whole record minus its phase.
422
+ """
423
+ fold = _jl.SelectorFold()
424
+ by_batch = {row.batch_id: row for row in rows.batches}
425
+ for row in rows.batch_records:
426
+ batch = by_batch.get(row.batch_id)
427
+ if row.kind == "marker":
428
+ core = {
429
+ "action_count": None if batch is None else batch.action_count,
430
+ "actions_hash": None if batch is None else batch.action_set_hash,
431
+ }
432
+ fold.markers.setdefault(row.batch_id, {})[row.key] = (
433
+ core, row.record_digest, row.identity_digest, row.sequence,
434
+ )
435
+ else:
436
+ normalized = None
437
+ if row.action_core_json is not None:
438
+ # `_correction_action_core` deliberately excludes `v`, `t`,
439
+ # `batch` and `seq`, which `_validate_correction_record` does
440
+ # produce and which `_candidate_from_correction` and phase 2
441
+ # read. They are restored around the stored core rather than
442
+ # widened into it, so the stored JSON stays the canonical core
443
+ # the manifest hash is computed over while the reconstructed
444
+ # record is field-for-field what a fresh validation returns.
445
+ normalized = {
446
+ "v": _jl.LINE_VERSION,
447
+ "t": "correction",
448
+ **json.loads(row.action_core_json),
449
+ "batch": row.batch_id,
450
+ "seq": int(row.key),
451
+ }
452
+ fold.actions.setdefault(row.batch_id, {})[int(row.key)] = (
453
+ normalized, row.record_digest, row.sequence,
454
+ )
455
+ for row in rows.batches:
456
+ if row.status == _TAINTED:
457
+ fold.tainted_batches.add(row.batch_id)
458
+ elif row.status == _COMPLETED:
459
+ fold.completed.add(row.batch_id)
460
+ # `fold.violations` is deliberately NOT seeded. Phase 2 never reads it, and
461
+ # `_merged_violation_rows` decides each stored row against the re-resolved
462
+ # verdict instead: it keeps a phase-1 row (not re-derivable from the first
463
+ # record this seeds) and an acknowledged one (whose richer JSON a
464
+ # re-derivation would not reproduce), and drops a phase-2 row the
465
+ # re-resolution withdrew.
466
+ return fold
467
+
468
+
469
+ def _event_from_row(row) -> "object":
470
+ return _jl.EffectiveEvent(
471
+ event_id=row.event_id,
472
+ rev=row.rev,
473
+ status=row.status,
474
+ content_hash=row.content_hash,
475
+ batch_id=row.batch_id,
476
+ record=(
477
+ None if row.event_json is None else json.loads(row.event_json)
478
+ ),
479
+ sequence=row.winning_sequence,
480
+ )
481
+
482
+
483
+ def _merge_candidates(rows: SelectorRows, candidates):
484
+ """Fold new candidates into the durable winners, reproducing #374 exactly.
485
+
486
+ Only the WINNING revision is seeded, and that is sufficient: a candidate
487
+ below the winning revision can never take the winner, and its same-revision
488
+ group is filtered out by the revision-scoping rule anyway.
489
+
490
+ Returns winners for the event ids the DELTA NAMES, not for every durable
491
+ row. That scoping is what keeps the live path affordable: this runs inside
492
+ an ingest tick, and materializing every stored winner would parse each
493
+ row's retained record — at most 34,644 JSON documents on the maintainer's
494
+ journal, an upper bound rather than a count, because a row whose
495
+ `event_json` is NULL parses nothing — on a tick that names two or three of
496
+ them. An untouched winner
497
+ cannot change, so its row passes through verbatim (see
498
+ `_merged_effective_rows`).
499
+ """
500
+ prior_rows = {row.event_id: row for row in rows.effective}
501
+ winners: dict = {}
502
+ conflicts: dict = {}
503
+
504
+ def seeded(event_id):
505
+ """The durable winner for ``event_id``, materialized on first use."""
506
+ if event_id in winners:
507
+ return winners[event_id]
508
+ row = prior_rows.get(event_id)
509
+ if row is None:
510
+ return None
511
+ if row.conflict_hashes_json is not None:
512
+ conflicts.setdefault(
513
+ event_id, set(json.loads(row.conflict_hashes_json)))
514
+ return _event_from_row(row)
515
+
516
+ for candidate in sorted(candidates, key=lambda item: item.sequence):
517
+ prior = seeded(candidate.event_id)
518
+ if prior is None or candidate.rev > prior.rev:
519
+ winners[candidate.event_id] = candidate
520
+ conflicts.pop(candidate.event_id, None)
521
+ continue
522
+ if candidate.rev < prior.rev:
523
+ winners.setdefault(candidate.event_id, prior)
524
+ continue
525
+ if (
526
+ prior.content_hash == candidate.content_hash
527
+ and prior.status == candidate.status
528
+ ):
529
+ if prior.sequence is None:
530
+ # A durable row with no winning sequence came from the LIVE emit
531
+ # path, which writes the six legacy columns for an evt it
532
+ # journals past the cycle's own high-water. The next cycle reads
533
+ # that line and folds it here at a known sequence, so adopting
534
+ # the candidate replaces an unknown with the number a full
535
+ # derivation would compute. Without it the row stays sequenceless
536
+ # forever and `stats_index_matches_journal_prefix` can never
537
+ # agree again, which is the shape of the defect this session was
538
+ # asked to close rather than relocate.
539
+ winners[candidate.event_id] = candidate
540
+ else:
541
+ winners.setdefault(candidate.event_id, prior)
542
+ continue
543
+ if (
544
+ _jl._is_legacy_quota_arming_state(prior)
545
+ and _jl._is_legacy_quota_arming_state(candidate)
546
+ ):
547
+ # Legacy qaa carve-out: last-wins AND silent. A pre-#372 arming
548
+ # record deliberately reuses its natural id as a state stream, so
549
+ # successive lines are not a conflict.
550
+ winners[candidate.event_id] = candidate
551
+ continue
552
+ winners.setdefault(candidate.event_id, prior)
553
+ conflicts.setdefault(candidate.event_id, set()).update(
554
+ {prior.content_hash, candidate.content_hash}
555
+ )
556
+ return winners, conflicts
557
+
558
+
559
+ def _merged_effective_rows(rows: SelectorRows, winners, conflicts):
560
+ """Durable winners advanced by the delta.
561
+
562
+ A row the delta did not name passes through VERBATIM. It cannot have
563
+ changed — nothing else in the fold reaches it — and re-encoding it would
564
+ re-serialize every retained record on every tick.
565
+ """
566
+ merged = [row for row in rows.effective if row.event_id not in winners]
567
+ for event_id, selected in winners.items():
568
+ event_json = None
569
+ if selected.record is not None:
570
+ event_json = (
571
+ _jl.encode_line(selected.record).decode("utf-8").rstrip("\n")
572
+ )
573
+ hashes = conflicts.get(event_id)
574
+ merged.append(
575
+ SelectorEffectiveRow(
576
+ event_id=event_id,
577
+ rev=selected.rev,
578
+ status=selected.status,
579
+ content_hash=selected.content_hash,
580
+ batch_id=selected.batch_id,
581
+ event_json=event_json,
582
+ winning_sequence=selected.sequence,
583
+ conflict_hashes_json=(
584
+ None if not hashes else _canonical_json(sorted(hashes))
585
+ ),
586
+ )
587
+ )
588
+ merged.sort(key=lambda row: row.event_id)
589
+ return tuple(merged)
590
+
591
+
592
+ #: The two violation kinds phase 1 establishes, from a DUPLICATE record whose
593
+ #: digest differs from the one already accumulated.
594
+ #:
595
+ #: They are separated from the other five because they behave differently under
596
+ #: re-resolution. A phase-1 violation is MONOTONE: the duplicate is durably in
597
+ #: the journal, so every later full derivation reproduces it — but an
598
+ #: incremental pass cannot, because `_seed_fold` restores only the FIRST record
599
+ #: at each phase and action sequence, which is what the durable rows store. The
600
+ #: five phase-2 kinds are the opposite: `resolve_batches` re-derives all of them
601
+ #: from the accumulated batch state on every pass, and a later record can
602
+ #: WITHDRAW one — an incomplete action set completed by a late action stops
603
+ #: producing `manifest_action_sequence_mismatch`.
604
+ PHASE_ONE_VIOLATION_KINDS = frozenset(
605
+ {"marker_conflict", "action_sequence_conflict"}
606
+ )
607
+
608
+
609
+ def _is_acknowledged(row: SelectorViolationRow) -> bool:
610
+ """Whether ``row``'s stored JSON is an acknowledged violation's richer shape.
611
+
612
+ `AcknowledgedProtocolViolation.to_dict` adds `auditId`, `journalHighWater`
613
+ and `journalPrefixHash` to the plain violation dict, and an incremental fold
614
+ never carries a resolution — `merge_delta` refuses a delta that contains one
615
+ — so a re-derivation here can never reproduce that shape. Such a row is
616
+ therefore never withdrawn.
617
+
618
+ Retaining it is conservative only about THIS path's own state. It is not
619
+ harmless in general: when a later record genuinely withdraws an acknowledged
620
+ phase-2 violation, a full derivation raises `JournalProtocolError` at
621
+ `bin/_lib_journal.py:1105` for an acknowledgement that resolves nothing, so
622
+ every later rebuild is wedged while the incremental path keeps serving the
623
+ stale row. That is pre-existing — a full derivation reaches the same state
624
+ without this function — and production holds zero
625
+ `journal_protocol_resolution` ops, so nothing here can reach it today.
626
+ """
627
+ try:
628
+ return "auditId" in json.loads(row.violation_json)
629
+ except (TypeError, ValueError):
630
+ return True
631
+
632
+
633
+ def _with_earliest_available_after(stored, derived):
634
+ """``stored`` carrying the earlier of the two rows' ``available_after``."""
635
+ if derived is None or derived.available_after is None:
636
+ return stored
637
+ if stored.available_after is None:
638
+ return replace(stored, available_after=derived.available_after)
639
+ if derived.available_after >= stored.available_after:
640
+ return stored
641
+ return replace(stored, available_after=derived.available_after)
642
+
643
+
644
+ def _merged_violation_rows(rows: SelectorRows, fold, resolved_batches):
645
+ """Durable violation rows advanced by the delta, WITHDRAWALS included.
646
+
647
+ ``resolved_batches`` names the batches phase 2 actually re-resolved in this
648
+ delta. For exactly those, `fold.violations` is the complete phase-2 verdict a
649
+ full derivation would produce, so a stored phase-2 row the re-resolution did
650
+ not reproduce has been withdrawn and must be dropped rather than unioned
651
+ forward. Unioning left a withdrawn `manifest_action_sequence_mismatch`
652
+ durable after the missing action arrived, which made `doctor`'s
653
+ `journal.protocol` leg FAIL and print a `db journal-repair --violation
654
+ <fingerprint>` command naming a fingerprint no fresh derivation reproduces.
655
+
656
+ Two classes of stored row are never withdrawn. A **phase-1** row is monotone
657
+ and not re-derivable from the seeded fold (`PHASE_ONE_VIOLATION_KINDS`), and
658
+ an **acknowledged** row carries operator audit an incremental pass cannot
659
+ reconstruct.
660
+
661
+ A batch outside ``resolved_batches`` is untouched for the same reason its
662
+ other rows are: it received no record this delta could fold.
663
+
664
+ **The stored row wins for a fingerprint both produce, except on
665
+ ``available_after``.** Stored-wins is required, because an acknowledged row's
666
+ richer JSON is exactly what a re-derivation cannot reproduce. But
667
+ `available_after` is not one of the fingerprint's inputs, so the two rows can
668
+ legitimately disagree on it, and it is a MINIMUM over sightings
669
+ (`SelectorFold.taint`) rather than a last-write value. Taking the pointwise
670
+ minimum is therefore the correct merge in both directions: it can never
671
+ regress an earlier boundary a longer derivation established, and it adopts a
672
+ value for a stored row that a four-column fallback left NULL.
673
+ """
674
+ derived = {}
675
+ for (batch_id, kind, fingerprint), violation in fold.violations.items():
676
+ derived[fingerprint] = SelectorViolationRow(
677
+ fingerprint=fingerprint,
678
+ batch_id=batch_id,
679
+ kind=kind,
680
+ violation_json=_canonical_json(violation.to_dict()),
681
+ available_after=fold.violation_available_after.get(fingerprint),
682
+ )
683
+ merged: dict = {}
684
+ for row in rows.violations:
685
+ if (
686
+ row.batch_id in resolved_batches
687
+ and row.fingerprint not in derived
688
+ and row.kind not in PHASE_ONE_VIOLATION_KINDS
689
+ and not _is_acknowledged(row)
690
+ ):
691
+ continue
692
+ merged[row.fingerprint] = _with_earliest_available_after(
693
+ row, derived.get(row.fingerprint))
694
+ for fingerprint, row in derived.items():
695
+ merged.setdefault(fingerprint, row)
696
+ ordered = sorted(
697
+ merged.values(), key=lambda row: (row.batch_id, row.kind, row.fingerprint)
698
+ )
699
+ return tuple(ordered)
700
+
701
+
702
+ #: Record types `_lib_journal._fold_one` actually consumes. Everything else
703
+ #: leaves the fold untouched and only consumes a sequence number.
704
+ FOLD_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch"})
705
+
706
+
707
+ def delta_batch_scope(records) -> set:
708
+ """Every correction batch a delta of ``records`` can reach.
709
+
710
+ A `correction_batch` names its batch through `id` and a `correction` through
711
+ `batch`. A batch the delta does not name receives no new record, so phase 1
712
+ cannot taint it and phase 2 cannot change its verdict, and its durable rows
713
+ stand untouched — which is what lets the caller read `journal_selector_
714
+ batches` and `journal_selector_batch_records` scoped to this set instead of
715
+ materializing all 64,248 batch-record rows a production journal holds on
716
+ every tick.
717
+ """
718
+ scope = set()
719
+ for record in records:
720
+ if not isinstance(record, dict):
721
+ continue
722
+ kind = record.get("t")
723
+ if kind == "correction_batch":
724
+ batch_id = record.get("id")
725
+ elif kind == "correction":
726
+ batch_id = record.get("batch")
727
+ else:
728
+ continue
729
+ if isinstance(batch_id, str):
730
+ scope.add(batch_id)
731
+ return scope
732
+
733
+
734
+ def delta_event_scope(records, batch_records=()) -> set:
735
+ """Every event id `_merge_candidates` can look a durable winner up for.
736
+
737
+ Three sources, and all three are necessary:
738
+
739
+ - an `evt` record is a candidate for its own `id`;
740
+ - a `correction` action replaces the event named by its `id`;
741
+ - an action of a `begin_only` or `tainted` batch that arrived in an EARLIER
742
+ generation names an id no delta record mentions. That is the split-cycle
743
+ case, and it is exactly why those batches retain their action cores.
744
+
745
+ A `completed` batch is skipped by phase 2 and produces no candidate, so its
746
+ dropped cores cannot widen the scope and their absence is not a gap.
747
+ """
748
+ scope = set()
749
+ for record in records:
750
+ if not isinstance(record, dict):
751
+ continue
752
+ if record.get("t") in ("evt", "correction"):
753
+ event_id = record.get("id")
754
+ if isinstance(event_id, str):
755
+ scope.add(event_id)
756
+ for row in batch_records:
757
+ if row.kind != "action" or row.action_core_json is None:
758
+ continue
759
+ event_id = json.loads(row.action_core_json).get("id")
760
+ if isinstance(event_id, str):
761
+ scope.add(event_id)
762
+ return scope
763
+
764
+
765
+ def advance_counter(
766
+ rows: SelectorRows,
767
+ *,
768
+ consumed: int,
769
+ covered=None,
770
+ ) -> SelectorRows:
771
+ """Advance ONLY the prefix counters, reusing every row object.
772
+
773
+ For a delta the fold does not consume — observations and ordinary ops — the
774
+ merge is the identity on all four row groups, and running it anyway would
775
+ make an ordinary status-line tick pay for the whole durable generation. The
776
+ row tuples are returned by reference, which is also what lets the glue's
777
+ delta writer skip those groups outright rather than diff them.
778
+
779
+ The cutover pair carries forward and cannot be set here. An incremental pass
780
+ may never adopt a cutover operation the durable prefix has not folded: a full
781
+ derivation applies the legacy account stamp to every legacy Claude line in
782
+ that prefix, changing those events' `content_hash` and `event_json`, and this
783
+ path normalizes only the delta. The caller falls back instead.
784
+ """
785
+ return SelectorRows(
786
+ state=replace(
787
+ rows.state,
788
+ next_sequence=consumed,
789
+ selector_version=SELECTOR_VERSION,
790
+ covered_segment=(
791
+ rows.state.covered_segment if covered is None else covered[0]),
792
+ covered_offset=(
793
+ rows.state.covered_offset if covered is None
794
+ else int(covered[1])),
795
+ ),
796
+ batches=rows.batches,
797
+ batch_records=rows.batch_records,
798
+ effective=rows.effective,
799
+ violations=rows.violations,
800
+ )
801
+
802
+
803
+ def merge_delta(
804
+ rows: SelectorRows,
805
+ new_records,
806
+ *,
807
+ next_sequence: int,
808
+ coordinates=None,
809
+ covered=None,
810
+ ):
811
+ """Continue the fold over ``new_records`` from durable state.
812
+
813
+ ``next_sequence`` is the sequence the FIRST new entry takes, which is the
814
+ durable state's own `next_sequence`.
815
+
816
+ Returns ``(rows, transitions)``. ``transitions`` names every batch whose
817
+ durable status was `completed` and which this delta tainted; the caller
818
+ turns each into a `CorrectionRebuildRequired` bounded at the causal record,
819
+ because carrying a stale `completed` status forward is the one way an
820
+ incremental path can make the pre-existing #510 staleness worse.
821
+
822
+ The cutover pair carries forward and cannot be set here, for the reason
823
+ `advance_counter` gives.
824
+
825
+ Raises :class:`IncrementalSelectionUnavailable` when the delta contains a
826
+ `journal_protocol_resolution` op: acknowledging a violation authenticates an
827
+ exact length-framed raw-prefix SHA-256, a claimed hash is never accepted
828
+ without recomputation, and no durable summary can reconstruct it.
829
+ """
830
+ fold = _seed_fold(rows)
831
+ seeded_completed = {
832
+ row.batch_id for row in rows.batches if row.status == _COMPLETED
833
+ }
834
+ # The shape a completed batch had when its cores were dropped. Phase 2 is
835
+ # skipped for these batches, so anything the delta ADDS to one of them is
836
+ # never folded into a verdict — see the refusal below.
837
+ seeded_shape = {
838
+ batch_id: (
839
+ frozenset(fold.markers.get(batch_id, {})),
840
+ frozenset(fold.actions.get(batch_id, {})),
841
+ )
842
+ for batch_id in seeded_completed
843
+ }
844
+ consumed = _jl.fold_records(fold, new_records, start_sequence=next_sequence)
845
+ if fold.resolutions:
846
+ raise IncrementalSelectionUnavailable(
847
+ "a journal_protocol_resolution op requires a verified raw-prefix "
848
+ "read; fall back to full selection"
849
+ )
850
+ for batch_id, (markers, actions) in sorted(seeded_shape.items()):
851
+ # A DUPLICATE marker or action is safe to carry forward: phase 1 keeps
852
+ # the first record and taints from the retained whole-record digest when
853
+ # the duplicate differs, which the transition loop below then reports. A
854
+ # record at a phase or action sequence the durable rows do not hold is
855
+ # different in kind — a full pass would re-run phase 2 over the widened
856
+ # set and could taint the batch (`manifest_action_sequence_mismatch`,
857
+ # `record_order_violation`), while this path would keep it completed and
858
+ # raise nothing. The cores were dropped at completion, so re-deriving the
859
+ # verdict here is not an option; refusing is.
860
+ if (
861
+ frozenset(fold.markers.get(batch_id, {})) != markers
862
+ or frozenset(fold.actions.get(batch_id, {})) != actions
863
+ ):
864
+ raise IncrementalSelectionUnavailable(
865
+ f"batch {batch_id} is durably completed and the delta adds a "
866
+ "marker phase or action sequence its stored rows do not hold; "
867
+ "fall back to full selection"
868
+ )
869
+ resolved_batches = (
870
+ set(fold.markers) | set(fold.actions)
871
+ ) - seeded_completed
872
+ _jl.resolve_batches(fold, batch_ids=resolved_batches)
873
+ fold.completed |= seeded_completed - fold.tainted_batches
874
+
875
+ transitions = []
876
+ for batch_id in sorted(seeded_completed & fold.tainted_batches):
877
+ # A completed batch carries no violations — `completed` and `tainted`
878
+ # are disjoint — so every violation now standing against it was
879
+ # established by THIS delta, and the earliest of them is the record that
880
+ # caused the transition.
881
+ causal = [
882
+ fold.violation_available_after[fingerprint]
883
+ for (candidate_batch, _kind, fingerprint) in fold.violations
884
+ if candidate_batch == batch_id
885
+ and fingerprint in fold.violation_available_after
886
+ ]
887
+ if not causal:
888
+ # The causal offset is MANDATORY and this path fails closed without
889
+ # it. Substituting the pinned high-water is unsafe: it is `st_size`,
890
+ # `_iter_segment_lines` omits an incomplete trailing line, and
891
+ # torn-tail repair can truncate below it, so a cursor written there
892
+ # can sit beyond unread data (spec §3.7).
893
+ raise IncrementalSelectionUnavailable(
894
+ f"batch {batch_id} moved completed -> tainted with no causal "
895
+ "offset; fall back to full selection"
896
+ )
897
+ transitions.append(
898
+ TaintTransition(batch_id=batch_id, causal_sequence=min(causal))
899
+ )
900
+ # CAUSAL order, not batch-id order. The caller raises on the FIRST
901
+ # transition, so raising the batch whose causal record sits later would
902
+ # rebuild through a longer prefix than necessary. Both orders converge;
903
+ # this one converges through the narrowest prefix.
904
+ transitions.sort(key=lambda item: (item.causal_sequence, item.batch_id))
905
+
906
+ winners, conflicts = _merge_candidates(rows, fold.candidates)
907
+ batches, batch_records = _batch_rows(fold, coordinates)
908
+ merged_batches = _carry_coordinates(rows, batches)
909
+ state = replace(
910
+ rows.state,
911
+ next_sequence=consumed,
912
+ selector_version=SELECTOR_VERSION,
913
+ covered_segment=(
914
+ rows.state.covered_segment if covered is None else covered[0]),
915
+ covered_offset=(
916
+ rows.state.covered_offset if covered is None else int(covered[1])),
917
+ )
918
+ return (
919
+ SelectorRows(
920
+ state=state,
921
+ batches=merged_batches,
922
+ batch_records=batch_records,
923
+ effective=_merged_effective_rows(rows, winners, conflicts),
924
+ violations=_merged_violation_rows(rows, fold, resolved_batches),
925
+ ),
926
+ transitions,
927
+ )
928
+
929
+
930
+ def _carry_coordinates(rows: SelectorRows, batches):
931
+ """Keep a coordinate the durable row already holds.
932
+
933
+ A delta's `coordinates` map covers only the delta's own records, so a batch
934
+ whose begin marker arrived in an earlier generation would otherwise lose the
935
+ coordinate that generation resolved.
936
+ """
937
+ prior = {row.batch_id: row for row in rows.batches}
938
+ carried = []
939
+ for row in batches:
940
+ old = prior.get(row.batch_id)
941
+ if old is None:
942
+ carried.append(row)
943
+ continue
944
+ carried.append(
945
+ replace(
946
+ row,
947
+ begin_segment=row.begin_segment or old.begin_segment,
948
+ begin_offset=(
949
+ row.begin_offset if row.begin_offset is not None
950
+ else old.begin_offset),
951
+ earliest_commit_segment=(
952
+ row.earliest_commit_segment or old.earliest_commit_segment),
953
+ earliest_commit_offset=(
954
+ row.earliest_commit_offset
955
+ if row.earliest_commit_offset is not None
956
+ else old.earliest_commit_offset),
957
+ )
958
+ )
959
+ return tuple(carried)