cctally 1.113.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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
package/bin/_lib_journal.py
CHANGED
|
@@ -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
|
|
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
|
|
399
|
-
|
|
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":
|
|
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
|
-
|
|
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")
|
|
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 =
|
|
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")
|
|
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)
|