cctally 1.114.0 → 1.115.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 (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -1
@@ -32,6 +32,8 @@ import hashlib
32
32
  import json
33
33
  from dataclasses import dataclass
34
34
 
35
+ import _lib_account_protocol
36
+
35
37
  LINE_VERSION = 1
36
38
 
37
39
  SEGMENT_PREFIX = "observations-"
@@ -379,8 +381,13 @@ def effects_payload_digest(payload: dict, refs: dict | None = None) -> str:
379
381
  # fully-formed line records
380
382
  # --------------------------------------------------------------------------
381
383
 
384
+ _WRITER_GENERATION = _lib_account_protocol.PROTOCOL_GENERATION
385
+ _WPG = _lib_account_protocol.WRITER_GENERATION_KEY
386
+
387
+
382
388
  def make_obs(at: str, src: str, provider: str, payload: dict,
383
- account: str | None = None) -> dict:
389
+ account: str | None = None, *,
390
+ writer_generation: str | None = _WRITER_GENERATION) -> dict:
384
391
  """Build a complete ``obs`` line record (raw capture; canonicalization is
385
392
  derivation-time, never baked into the stored line — spec §4.2).
386
393
 
@@ -388,20 +395,126 @@ def make_obs(at: str, src: str, provider: str, payload: dict,
388
395
  ``provider``. It is emitted ONLY when supplied, so a pre-epic / single-account
389
396
  writer that passes nothing produces a byte-identical line (and a byte-stable
390
397
  content id). When supplied it participates in the content id, so two obs that
391
- differ only by account are distinct records."""
398
+ differ only by account are distinct records.
399
+
400
+ ``writer_generation`` (#825/#832) is the top-level ``wpg`` stamp naming the
401
+ writing protocol. It is part of the content-id core, so a current record and
402
+ a previous-release record of the same capture are distinct lines. ``None``
403
+ builds the previous release's exact shape (a validator re-deriving a
404
+ retained pre-stamp record, or a test simulating an old writer)."""
392
405
  core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
393
406
  if account is not None:
394
407
  core["account"] = account
408
+ if writer_generation is not None:
409
+ core[_WPG] = writer_generation
395
410
  return {"v": LINE_VERSION, **core, "id": content_id(core)}
396
411
 
397
412
 
398
- def make_op(at: str, src: str, payload: dict) -> dict:
399
- """Build a complete ``op`` (operator record) line — no ``provider``."""
413
+ def make_op(at: str, src: str, payload: dict, *,
414
+ writer_generation: str | None = _WRITER_GENERATION) -> dict:
415
+ """Build a complete ``op`` (operator record) line — no ``provider``.
416
+ ``writer_generation`` as in ``make_obs``."""
400
417
  core = {"t": "op", "at": at, "src": src, "payload": payload}
418
+ if writer_generation is not None:
419
+ core[_WPG] = writer_generation
401
420
  return {"v": LINE_VERSION, **core, "id": content_id(core)}
402
421
 
403
422
 
423
+ def prepare_record(record: dict) -> dict:
424
+ """The single shared preparation step both append APIs apply (#825/#832
425
+ spec 5.1): return ``record`` carrying the current writer generation.
426
+
427
+ A content-id record (``id`` starting ``o:``) that is not yet stamped and
428
+ whose id verifies over its unstamped core is re-identified over the stamped
429
+ core, exactly as the builders compute it. Every other id — semantic event
430
+ ids, bootstrap ids, correction batch ids, a content id that does not verify
431
+ — is preserved. Idempotent: a stamped record is returned unchanged."""
432
+ if _lib_account_protocol.is_stamped(record):
433
+ return record
434
+ stamped = _lib_account_protocol.stamp(record)
435
+ rid = record.get("id")
436
+ if (record.get("t") in ("obs", "op") and isinstance(rid, str)
437
+ and rid.startswith("o:")):
438
+ core = {k: v for k, v in record.items() if k not in ("v", "id")}
439
+ if content_id(core) == rid:
440
+ stamped_core = {k: v for k, v in stamped.items()
441
+ if k not in ("v", "id")}
442
+ stamped["id"] = content_id(stamped_core)
443
+ return stamped
444
+
445
+
446
+ def unstamped_record(record: dict) -> dict:
447
+ """The previous release's shape of ``record``: the inverse of
448
+ ``prepare_record``. Drops the writer-generation stamp and, for a content-id
449
+ record whose id verifies over its stamped core, re-identifies it over the
450
+ unstamped core. Any other id is preserved. For fixtures and tests that
451
+ simulate a pre-stamp writer; never used on a production write path."""
452
+ if not isinstance(record, dict) or _WPG not in record:
453
+ return record
454
+ out = {k: v for k, v in record.items() if k != _WPG}
455
+ rid = record.get("id")
456
+ if (record.get("t") in ("obs", "op") and isinstance(rid, str)
457
+ and rid.startswith("o:")):
458
+ core = {k: v for k, v in record.items() if k not in ("v", "id")}
459
+ if content_id(core) == rid:
460
+ out["id"] = content_id(
461
+ {k: v for k, v in out.items() if k not in ("v", "id")})
462
+ return out
463
+
464
+
465
+ def legacy_writer_view():
466
+ """A namespace exposing this module with every ``make_*`` builder wrapped by
467
+ ``unstamped_record``, so a fixture builder written against the module
468
+ produces the previous release's exact bytes (cross-references between
469
+ records stay consistent because every id is the unstamped one)."""
470
+ import types
471
+
472
+ def _wrap(fn):
473
+ def build(*args, **kwargs):
474
+ out = fn(*args, **kwargs)
475
+ if isinstance(out, list):
476
+ return [unstamped_record(item) for item in out]
477
+ return unstamped_record(out)
478
+ build.__name__ = fn.__name__
479
+ build.__doc__ = fn.__doc__
480
+ return build
481
+
482
+ def _legacy_protocol_resolution(**kwargs):
483
+ # The previous release's acknowledgement kind, which the current
484
+ # binary never emits (#825/#832 spec 5.1).
485
+ current = make_protocol_resolution(**kwargs)
486
+ return make_op(
487
+ at=current["at"], src=current["src"],
488
+ payload={**current["payload"], "kind": _PROTOCOL_RESOLUTION_KIND},
489
+ writer_generation=None)
490
+
491
+ attrs = {}
492
+ for name, value in globals().items():
493
+ if name.startswith("__"):
494
+ continue
495
+ if name.startswith("make_") and callable(value):
496
+ value = _wrap(value)
497
+ attrs[name] = value
498
+ attrs["make_protocol_resolution"] = _legacy_protocol_resolution
499
+ return types.SimpleNamespace(**attrs)
500
+
501
+
502
+ def event_content_hash(record: dict) -> str:
503
+ """The semantic content hash of an evt line. The writer-generation stamp is
504
+ transport metadata, so it is stripped before hashing: a current emission
505
+ that differs from a retained pre-stamp event only by ``wpg`` is the same
506
+ event (a duplicate), never a same-revision conflict."""
507
+ return _sha256_canonical(_lib_account_protocol.strip_stamp(record))
508
+
509
+
510
+ #: The previous release's exact-key repair acknowledgement. The current binary
511
+ #: never emits it: its validator would reject the writer-generation stamp, so
512
+ #: current acknowledgements use the versioned kind the previous release ignores
513
+ #: as an unknown op (#825/#832 spec 5.1). Current readers accept both.
404
514
  _PROTOCOL_RESOLUTION_KIND = "journal_protocol_resolution"
515
+ _PROTOCOL_RESOLUTION_V2_KIND = _lib_account_protocol.PROTOCOL_RESOLUTION_V2_KIND
516
+ PROTOCOL_RESOLUTION_KINDS = frozenset((
517
+ _PROTOCOL_RESOLUTION_KIND, _PROTOCOL_RESOLUTION_V2_KIND))
405
518
  _PROTOCOL_RESOLUTION_SRC = "journal-repair"
406
519
 
407
520
 
@@ -441,7 +554,7 @@ def make_protocol_resolution(
441
554
  at=at,
442
555
  src=_PROTOCOL_RESOLUTION_SRC,
443
556
  payload={
444
- "kind": _PROTOCOL_RESOLUTION_KIND,
557
+ "kind": _PROTOCOL_RESOLUTION_V2_KIND,
445
558
  "violations": refs,
446
559
  "journal_high_water": {
447
560
  "segment": segment,
@@ -452,6 +565,17 @@ def make_protocol_resolution(
452
565
  )
453
566
 
454
567
 
568
+ def is_protocol_resolution(record) -> bool:
569
+ """Whether ``record`` is a journal-repair acknowledgement of either
570
+ version."""
571
+ return (
572
+ isinstance(record, dict)
573
+ and record.get("t") == "op"
574
+ and isinstance(record.get("payload"), dict)
575
+ and record["payload"].get("kind") in PROTOCOL_RESOLUTION_KINDS
576
+ )
577
+
578
+
455
579
  def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
456
580
  """Build a complete ``evt`` (derived) line record.
457
581
 
@@ -463,7 +587,8 @@ def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
463
587
  body = dict(payload)
464
588
  body["kind"] = kind
465
589
  return {"v": LINE_VERSION, "t": "evt", "id": id, "rev": rev,
466
- "at": at, "src": "ingest", "payload": body}
590
+ "at": at, "src": "ingest", "payload": body,
591
+ _WPG: _WRITER_GENERATION}
467
592
 
468
593
 
469
594
  # --------------------------------------------------------------------------
@@ -501,7 +626,9 @@ def _protocol_violation(
501
626
 
502
627
  def _validate_protocol_resolution(record: dict) -> dict:
503
628
  required = {"v", "t", "id", "at", "src", "payload"}
504
- if set(record) != required:
629
+ # #825/#832: a current record also carries the writer-generation stamp,
630
+ # which is part of its content-id core.
631
+ if set(record) not in (required, required | {_WPG}):
505
632
  raise JournalProtocolError(
506
633
  "journal protocol resolution record shape is invalid"
507
634
  )
@@ -521,6 +648,8 @@ def _validate_protocol_resolution(record: dict) -> dict:
521
648
  "src": record["src"],
522
649
  "payload": record["payload"],
523
650
  }
651
+ if _WPG in record:
652
+ core[_WPG] = record[_WPG]
524
653
  if record.get("id") != content_id(core):
525
654
  raise JournalProtocolError(
526
655
  "journal protocol resolution record id does not match its content"
@@ -535,7 +664,7 @@ def _validate_protocol_resolution(record: dict) -> dict:
535
664
  raise JournalProtocolError(
536
665
  "journal protocol resolution payload shape is invalid"
537
666
  )
538
- if payload.get("kind") != _PROTOCOL_RESOLUTION_KIND:
667
+ if payload.get("kind") not in PROTOCOL_RESOLUTION_KINDS:
539
668
  raise JournalProtocolError(
540
669
  "journal protocol resolution kind is invalid"
541
670
  )
@@ -672,6 +801,7 @@ def make_correction_batch(
672
801
  "family": family,
673
802
  "action_count": len(cores),
674
803
  "actions_hash": actions_hash,
804
+ _WPG: _WRITER_GENERATION,
675
805
  }
676
806
  records = [{**marker, "phase": "begin"}]
677
807
  for seq, core in enumerate(cores):
@@ -682,6 +812,7 @@ def make_correction_batch(
682
812
  **core,
683
813
  "batch": batch_id,
684
814
  "seq": seq,
815
+ _WPG: _WRITER_GENERATION,
685
816
  }
686
817
  )
687
818
  records.append({**marker, "phase": "commit"})
@@ -769,7 +900,7 @@ def _candidate_from_evt(record: dict, sequence: int) -> EffectiveEvent:
769
900
  )
770
901
  if not isinstance(record.get("payload"), dict):
771
902
  raise JournalProtocolError("event payload must be an object")
772
- digest = _sha256_canonical(record)
903
+ digest = event_content_hash(record)
773
904
  return EffectiveEvent(
774
905
  event_id=event_id,
775
906
  rev=rev,
@@ -919,7 +1050,7 @@ def _fold_one(fold: SelectorFold, record, sequence: int) -> None:
919
1050
  if (
920
1051
  record_type == "op"
921
1052
  and isinstance(record.get("payload"), dict)
922
- and record["payload"].get("kind") == _PROTOCOL_RESOLUTION_KIND
1053
+ and record["payload"].get("kind") in PROTOCOL_RESOLUTION_KINDS
923
1054
  ):
924
1055
  resolutions.append(
925
1056
  (sequence, _validate_protocol_resolution(record))
package/bin/_lib_jsonl.py CHANGED
@@ -359,7 +359,7 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
359
359
 
360
360
 
361
361
  _CODEX_FILENAME_UUID_RE = re.compile(
362
- r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F-]{36})\.jsonl$"
362
+ r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F-]{36})\.jsonl(?:\.zst)?$"
363
363
  )
364
364
 
365
365
 
@@ -883,7 +883,7 @@ def _reject_nonfinite_json_constant(value: str) -> None:
883
883
 
884
884
 
885
885
  def _codex_generation_transition_between(
886
- path_str: str, after_offset: int, before_offset: int,
886
+ path_str: str, after_offset: int, before_offset: int, *, open_stream=None,
887
887
  ) -> bool:
888
888
  """Return whether a producer lifecycle boundary exists in this byte gap.
889
889
 
@@ -892,12 +892,13 @@ def _codex_generation_transition_between(
892
892
  and is never consumed. This reconstructs the one bit of parser state a
893
893
  metadata-only or budgeted pass can leave between the last accounting row
894
894
  and the next token event without adding another cache schema column.
895
- Malformed or unreadable evidence fails closed.
895
+ Malformed or unreadable evidence fails closed. A container walk supplies
896
+ ``open_stream`` so all gap offsets address decoded bytes.
896
897
  """
897
898
  if after_offset < 0 or before_offset <= after_offset:
898
899
  return False
899
900
  try:
900
- with open(path_str, "rb") as fh:
901
+ with (open_stream() if open_stream is not None else open(path_str, "rb")) as fh:
901
902
  fh.seek(after_offset)
902
903
  fh.readline() # skip the retained accounting record itself
903
904
  while fh.tell() < before_offset:
@@ -0,0 +1,126 @@
1
+ """Pure legacy statusline spool baseline (#825/#832, spec sections 5.5 and 7).
2
+
3
+ After the account-bound cutover the previous release's statusline spool
4
+ (``statusline-candidates/``, ``statusline-selected.json``, the authoritative
5
+ tombstones and the freshness markers) is never read as authority. It is only
6
+ compared with a baseline captured inside the locked cutover establishment and
7
+ anchored in the journal: the cutover op records the baseline digest, and every
8
+ later ``legacy_spool_activity`` op records the digest the baseline advances to.
9
+ A change against the baseline is evidence that a legacy process wrote after the
10
+ cutover, which quarantines automatic outcomes (spec 5.6 item 1(b)).
11
+
12
+ This module owns the baseline's value shape, its digest, its on-disk document
13
+ codec and the comparison. It is pure: the filesystem capture lives in
14
+ ``_cctally_journal.capture_legacy_spool_state``. File content is digested,
15
+ never interpreted.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import hashlib
21
+ import json
22
+ import re
23
+
24
+ DOCUMENT_SCHEMA_VERSION = 1
25
+
26
+ _SHA_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
27
+
28
+
29
+ def _canonical(value) -> bytes:
30
+ return json.dumps(value, separators=(",", ":"), sort_keys=True,
31
+ ensure_ascii=False).encode("utf-8")
32
+
33
+
34
+ def content_digest(data: bytes) -> str:
35
+ return "sha256:" + hashlib.sha256(data).hexdigest()
36
+
37
+
38
+ @dataclasses.dataclass(frozen=True)
39
+ class LegacySpoolState:
40
+ """The observed legacy spool: one ``(relative path, size, mtime_ns,
41
+ sha256 of content)`` entry per regular file, sorted by path."""
42
+
43
+ entries: tuple[tuple[str, int, int, str], ...]
44
+
45
+ def digest(self) -> str:
46
+ return content_digest(_canonical([list(e) for e in self.entries]))
47
+
48
+
49
+ def make_state(entries) -> LegacySpoolState:
50
+ """Normalize ``entries`` into a sorted, validated state."""
51
+ normalized = []
52
+ seen = set()
53
+ for entry in entries:
54
+ path, size, mtime_ns, sha = entry
55
+ if not isinstance(path, str) or not path or path in seen:
56
+ raise ValueError("legacy spool entry path is invalid")
57
+ if type(size) is not int or size < 0:
58
+ raise ValueError("legacy spool entry size is invalid")
59
+ if type(mtime_ns) is not int:
60
+ raise ValueError("legacy spool entry mtime is invalid")
61
+ if not isinstance(sha, str) or not _SHA_RE.match(sha):
62
+ raise ValueError("legacy spool entry digest is invalid")
63
+ seen.add(path)
64
+ normalized.append((path, size, mtime_ns, sha))
65
+ return LegacySpoolState(entries=tuple(sorted(normalized)))
66
+
67
+
68
+ def diff(baseline: LegacySpoolState,
69
+ current: LegacySpoolState) -> tuple[str, ...]:
70
+ """The relative paths that were added, removed or changed (any of size,
71
+ modification time or content), sorted."""
72
+ before = {e[0]: e[1:] for e in baseline.entries}
73
+ after = {e[0]: e[1:] for e in current.entries}
74
+ return tuple(sorted(
75
+ path for path in set(before) | set(after)
76
+ if before.get(path) != after.get(path)
77
+ ))
78
+
79
+
80
+ def change_evidence(changed, current: LegacySpoolState) -> list[dict]:
81
+ """Per changed path, the observed after-state (or its absence). Digests
82
+ and metadata only, never a legacy value."""
83
+ after = {e[0]: e for e in current.entries}
84
+ out = []
85
+ for path in sorted(changed):
86
+ entry = after.get(path)
87
+ if entry is None:
88
+ out.append({"path": path, "absent": True})
89
+ else:
90
+ out.append({"path": path, "size": entry[1], "mtimeNs": entry[2],
91
+ "sha256": entry[3]})
92
+ return out
93
+
94
+
95
+ def to_document(state: LegacySpoolState) -> dict:
96
+ return {
97
+ "schemaVersion": DOCUMENT_SCHEMA_VERSION,
98
+ "digest": state.digest(),
99
+ "entries": [
100
+ {"path": p, "size": s, "mtimeNs": m, "sha256": h}
101
+ for (p, s, m, h) in state.entries
102
+ ],
103
+ }
104
+
105
+
106
+ def from_document(doc) -> LegacySpoolState:
107
+ """Decode a baseline document. Raises ``ValueError`` on any shape failure
108
+ or when the recorded digest does not match the entries."""
109
+ if not isinstance(doc, dict) or doc.get("schemaVersion") != (
110
+ DOCUMENT_SCHEMA_VERSION):
111
+ raise ValueError("legacy spool baseline document shape is invalid")
112
+ entries = doc.get("entries")
113
+ if not isinstance(entries, list):
114
+ raise ValueError("legacy spool baseline entries are invalid")
115
+ try:
116
+ state = make_state(
117
+ (e["path"], e["size"], e["mtimeNs"], e["sha256"])
118
+ for e in entries if isinstance(e, dict)
119
+ )
120
+ except (KeyError, TypeError) as exc:
121
+ raise ValueError("legacy spool baseline entry is invalid") from exc
122
+ if len(state.entries) != len(entries):
123
+ raise ValueError("legacy spool baseline entry is invalid")
124
+ if doc.get("digest") != state.digest():
125
+ raise ValueError("legacy spool baseline digest does not match")
126
+ return state
@@ -0,0 +1,136 @@
1
+ """Pure legacy-writer evidence (#825/#832, spec section 8, case F9).
2
+
3
+ After the account-bound cutover, records an older cctally process appends are
4
+ excluded from current accounting. `doctor`'s `journal.legacy_writer` leg warns
5
+ while that is still happening, and clears after seven quiet days (operator
6
+ Q4). The seven-day clock needs evidence of WHEN the excluded population was
7
+ first observed, and neither the disposable index (rebuilt) nor a record's `at`
8
+ (event time, which a backdated legacy event fakes) provides it. So one small
9
+ durable sidecar records:
10
+
11
+ * the cutover identity;
12
+ * the excluded population: its count and an ORDER-INDEPENDENT digest over the
13
+ physical coordinates of every excluded complete record;
14
+ * `firstObservedAt`: the wall-clock time a current scan first observed THAT
15
+ population.
16
+
17
+ The trigger is the population of physical coordinates, never a record's `at`
18
+ or the maximum coordinate alone, so a record added to an earlier segment, a
19
+ new bootstrap segment, or a backdated event all count as new evidence. A scan
20
+ that finds the same population leaves the evidence untouched, so a rebuild
21
+ over unchanged evidence does not restart the age. A missing sidecar is
22
+ recreated with the current time, which can only lengthen a warning. The
23
+ sidecar is diagnostic only: it never decides admission.
24
+
25
+ Pure: no I/O, no clock (callers pass `now`).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import datetime as dt
31
+ import re
32
+
33
+ SCHEMA_VERSION = 1
34
+ QUIET_DAYS = 7
35
+ REMEDY = ("an older cctally process (for example a dashboard started before "
36
+ "the upgrade) is still writing; restart it")
37
+
38
+ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class Evidence:
43
+ cutover_id: str
44
+ excluded_count: int
45
+ coordinate_digest: str
46
+ first_observed_at: str # ISO-8601 UTC
47
+
48
+
49
+ def _parse_instant(value):
50
+ if not isinstance(value, str) or not value:
51
+ return None
52
+ try:
53
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
54
+ except ValueError:
55
+ return None
56
+ if parsed.tzinfo is None:
57
+ return None
58
+ return parsed.astimezone(dt.timezone.utc)
59
+
60
+
61
+ def _iso(now: dt.datetime) -> str:
62
+ return now.astimezone(dt.timezone.utc).isoformat(timespec="seconds")
63
+
64
+
65
+ def to_document(evidence: Evidence) -> dict:
66
+ return {
67
+ "schemaVersion": SCHEMA_VERSION,
68
+ "cutoverId": evidence.cutover_id,
69
+ "excludedCount": evidence.excluded_count,
70
+ "coordinateDigest": evidence.coordinate_digest,
71
+ "firstObservedAt": evidence.first_observed_at,
72
+ }
73
+
74
+
75
+ def from_document(doc) -> Evidence | None:
76
+ """The evidence a sidecar document holds, or None when it is not a valid
77
+ document (treated exactly like a missing sidecar)."""
78
+ if not isinstance(doc, dict) or doc.get("schemaVersion") != SCHEMA_VERSION:
79
+ return None
80
+ cutover_id = doc.get("cutoverId")
81
+ count = doc.get("excludedCount")
82
+ digest = doc.get("coordinateDigest")
83
+ first = doc.get("firstObservedAt")
84
+ if (not isinstance(cutover_id, str) or not cutover_id
85
+ or type(count) is not int or count < 0
86
+ or not isinstance(digest, str) or not _DIGEST_RE.match(digest)
87
+ or _parse_instant(first) is None):
88
+ return None
89
+ return Evidence(cutover_id, count, digest, first)
90
+
91
+
92
+ def observe(existing: Evidence | None, *, cutover_id: str | None,
93
+ excluded_count: int, coordinate_digest: str | None,
94
+ now: dt.datetime) -> Evidence | None:
95
+ """The update rule. Returns the evidence a scan should leave behind:
96
+
97
+ * no cutover, or no excluded record at all: ``existing`` unchanged (None
98
+ when there was none) — nothing to warn about;
99
+ * the same cutover, count and digest: ``existing`` unchanged, so the age
100
+ keeps running;
101
+ * anything else (a new population, or no valid sidecar): new evidence
102
+ first observed ``now``.
103
+ """
104
+ if cutover_id is None or excluded_count <= 0 or coordinate_digest is None:
105
+ return existing
106
+ if (existing is not None
107
+ and existing.cutover_id == cutover_id
108
+ and existing.excluded_count == excluded_count
109
+ and existing.coordinate_digest == coordinate_digest):
110
+ return existing
111
+ return Evidence(cutover_id, int(excluded_count), coordinate_digest,
112
+ _iso(now))
113
+
114
+
115
+ @dataclasses.dataclass(frozen=True)
116
+ class Verdict:
117
+ severity: str # "ok" | "warn"
118
+ state: str # "none" | "recent" | "quiet"
119
+ excluded_count: int
120
+ last_seen_at: str | None
121
+ age_seconds: int | None
122
+
123
+
124
+ def classify(evidence: Evidence | None, *, excluded_count: int,
125
+ now: dt.datetime) -> Verdict:
126
+ """The leg's verdict: WARN while the latest excluded population was first
127
+ observed under `QUIET_DAYS` ago, OK with a historical note afterwards,
128
+ OK with nothing to report when nothing was ever excluded."""
129
+ if excluded_count <= 0 or evidence is None:
130
+ return Verdict("ok", "none", max(0, excluded_count), None, None)
131
+ first = _parse_instant(evidence.first_observed_at)
132
+ age = max(0, int((now.astimezone(dt.timezone.utc) - first).total_seconds()))
133
+ recent = age < QUIET_DAYS * 86400
134
+ return Verdict("warn" if recent else "ok",
135
+ "recent" if recent else "quiet",
136
+ excluded_count, evidence.first_observed_at, age)