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
@@ -529,6 +529,43 @@ def resolve_retention_days(config: dict) -> int:
529
529
  return default
530
530
 
531
531
 
532
+ #: The test-only switch (#965). Honored only at exactly "1", only when no
533
+ #: retention setting exists, and only for code running from a git checkout.
534
+ CONVERSATION_RETENTION_TEST_SWITCH = "CCTALLY_TEST_CONVERSATION_RETENTION_DEFAULT_OFF"
535
+
536
+
537
+ def _conversation_retention_setting_absent(config) -> bool:
538
+ """True when no ``conversation.retention_days`` setting exists (#965 5.2).
539
+
540
+ Absent: no ``conversation`` key, or a ``conversation`` dictionary without
541
+ ``retention_days``. A present key of any value (``null`` and malformed
542
+ values included), a non-dictionary ``conversation`` and a non-dictionary
543
+ config are not absent: they keep ``resolve_retention_days``'s meaning."""
544
+ if not isinstance(config, dict):
545
+ return False
546
+ if "conversation" not in config:
547
+ return True
548
+ block = config["conversation"]
549
+ return isinstance(block, dict) and "retention_days" not in block
550
+
551
+
552
+ def resolve_prune_retention_days(config: dict) -> int:
553
+ """Operational transcript-retention window of the three prune paths (#965).
554
+
555
+ ``resolve_retention_days`` stays the reporting resolver (``config get``,
556
+ the settings manifest, docs). This one differs in exactly one case: with
557
+ no retention setting, the test switch at exactly ``"1"`` and a ``.git``
558
+ entry at the running code's root, it returns ``0`` (keep forever). The
559
+ switch and the marker are read on every call; nothing is cached, written
560
+ or logged. docs/fixtures-gotchas.md, "Conversation retention is off in
561
+ test environments (#965)"."""
562
+ if (_conversation_retention_setting_absent(config)
563
+ and os.environ.get(CONVERSATION_RETENTION_TEST_SWITCH) == "1"
564
+ and _cctally_core._has_git_checkout_marker()):
565
+ return 0
566
+ return resolve_retention_days(config)
567
+
568
+
532
569
  def _config_codex_leaf_value(config: dict, key: str) -> object:
533
570
  """Read one nested Codex budget leaf with its public default.
534
571
 
@@ -76,6 +76,8 @@ def _init_paths_from_env() -> None:
76
76
  global STATUSLINE_CANDIDATE_DIR, STATUSLINE_SELECTED_PATH
77
77
  global STATUSLINE_TRANSPORT_MARKER_PATH
78
78
  global STATUSLINE_AUTHORITATIVE_7D_PATH, STATUSLINE_AUTHORITATIVE_5H_PATH
79
+ global STATUSLINE_ACCOUNT_BOUND_DIR, LEGACY_SPOOL_BASELINE_PATH
80
+ global ACCOUNT_BOUND_CUTOVER_CACHE_PATH, LEGACY_WRITER_EVIDENCE_PATH
79
81
  global OAUTH_BACKOFF_MARKER_PATH, OAUTH_BACKOFF_COUNT_PATH
80
82
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
81
83
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
@@ -184,6 +186,19 @@ def _init_paths_from_env() -> None:
184
186
  STATUSLINE_TRANSPORT_MARKER_PATH = APP_DIR / "statusline-transport.last"
185
187
  STATUSLINE_AUTHORITATIVE_7D_PATH = APP_DIR / "statusline-authoritative-7d.json"
186
188
  STATUSLINE_AUTHORITATIVE_5H_PATH = APP_DIR / "statusline-authoritative-5h.json"
189
+ # #825/#832 account-bound protocol (spec section 7). Every current
190
+ # coordination file lives under its own sibling root; the legacy paths
191
+ # above are never read as authority after the cutover, only compared with
192
+ # the journal-anchored legacy spool baseline. The coordinate cache only
193
+ # accelerates locating the cutover op and never confers authority; the
194
+ # legacy-writer evidence sidecar is diagnostic only (spec section 8).
195
+ STATUSLINE_ACCOUNT_BOUND_DIR = APP_DIR / "statusline-account-bound-v1"
196
+ LEGACY_SPOOL_BASELINE_PATH = (
197
+ STATUSLINE_ACCOUNT_BOUND_DIR / "legacy-spool-baseline.json")
198
+ ACCOUNT_BOUND_CUTOVER_CACHE_PATH = (
199
+ STATUSLINE_ACCOUNT_BOUND_DIR / "cutover-coordinate.json")
200
+ LEGACY_WRITER_EVIDENCE_PATH = (
201
+ STATUSLINE_ACCOUNT_BOUND_DIR / "legacy-writer-evidence.json")
187
202
  OAUTH_BACKOFF_MARKER_PATH = APP_DIR / "oauth-backoff.until"
188
203
  # Consecutive-429 counter (text int) driving the headerless exponential
189
204
  # backoff (base * 2**count). Separate from the deadline marker so the
@@ -261,6 +276,18 @@ def _repo_root() -> pathlib.Path:
261
276
  return pathlib.Path(__file__).resolve().parent.parent
262
277
 
263
278
 
279
+ def _has_git_checkout_marker() -> bool:
280
+ """True iff the running code has a ``.git`` entry at its root (#965 5.1).
281
+
282
+ A main checkout has a ``.git`` directory and a linked worktree a ``.git``
283
+ file; both count. Eligibility follows the code's resolved location
284
+ (``_repo_root()``), never the current directory, and is evaluated on every
285
+ call. No suppressor (``CCTALLY_DISABLE_DEV_AUTODETECT``,
286
+ ``CCTALLY_ALLOW_PROD_MIGRATION``) affects it. npm and Homebrew installs
287
+ ship without ``.git``, so they never read True."""
288
+ return (_repo_root() / ".git").exists()
289
+
290
+
264
291
  def _is_dev_checkout() -> bool:
265
292
  """True iff running from a git checkout (a ``.git`` entry at the repo
266
293
  root — a directory for a main checkout, a file for a worktree) AND the
@@ -272,7 +299,7 @@ def _is_dev_checkout() -> bool:
272
299
  install copies ship without ``.git`` so they never read True."""
273
300
  if _truthy_env("CCTALLY_DISABLE_DEV_AUTODETECT"):
274
301
  return False
275
- return (_repo_root() / ".git").exists()
302
+ return _has_git_checkout_marker()
276
303
 
277
304
 
278
305
  def is_preview_channel() -> bool:
@@ -487,9 +514,19 @@ _init_paths_from_env()
487
514
  # reason this is a bump and not a migration is unchanged: the registry is
488
515
  # frozen at 13, and an epoch-current open returns before any schema work, so a
489
516
  # schema helper alone would never run on an upgraded install.
490
- STATS_INDEX_EPOCH = 1017
517
+ # 1017 -> 1018 (#825/#832 spec section 6): the same journal now has a different
518
+ # authoritative interpretation — the account-bound admission predicate excludes
519
+ # unstamped post-cutover records before any derivation — so an index
520
+ # materialized under the old interpretation is not current even though its
521
+ # schema is usable. The epoch also carries the disposable
522
+ # `journal_admission_summary` table, whose presence is the protocol marker
523
+ # `_cctally_store.validated_read_snapshot` checks inside each decision
524
+ # scope's own snapshot. An epoch transition, not a numbered migration: the
525
+ # registry stays frozen at 13.
526
+ STATS_INDEX_EPOCH = 1018
491
527
  LEGACY_STATS_HEAD = 13
492
528
 
529
+
493
530
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
494
531
  #: publication transaction exists: a replacement index was published and then
495
532
  #: failed validation on a fresh connection. The existing corrupt-stats text
@@ -3310,6 +3347,106 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
3310
3347
  "cutover_seen INTEGER NOT NULL DEFAULT 0, "
3311
3348
  "cutover_account_key TEXT)"
3312
3349
  )
3350
+ # #825/#832 epoch 1018 (spec section 8): the bounded, disposable
3351
+ # summary of the complete journal records the admission predicate
3352
+ # EXCLUDED (post-cutover records without the current writer
3353
+ # generation). Ingest extends it inside its cycle transaction and a
3354
+ # rebuild reproduces it from the pinned prefix; `complete_through` is
3355
+ # NULL when the row was started mid-journal (it then describes only
3356
+ # what it saw). Coordinates are JSON `{"segment", "offset"}` text, and
3357
+ # `coordinate_digest` is the order-independent population digest of
3358
+ # every excluded coordinate (`_lib_account_protocol`).
3359
+ conn.execute(
3360
+ "CREATE TABLE IF NOT EXISTS journal_admission_summary ("
3361
+ "id INTEGER PRIMARY KEY CHECK (id = 1), "
3362
+ "cutover_id TEXT, "
3363
+ "scanned_high_water TEXT, "
3364
+ "excluded_total INTEGER NOT NULL DEFAULT 0, "
3365
+ "family_counts_json TEXT NOT NULL DEFAULT '{}', "
3366
+ "first_excluded_coordinate TEXT, "
3367
+ "latest_excluded_coordinate TEXT, "
3368
+ "coordinate_digest TEXT, "
3369
+ "complete_through TEXT, "
3370
+ # The boundary payload this summary classified against, so the
3371
+ # live ingest path resolves admission without scanning the journal.
3372
+ "boundary_json TEXT)"
3373
+ )
3374
+ # #825/#832 fix round A (Codex fix-round-1 Q1): the derivation
3375
+ # frontier. Every admitted derivable input at or before this journal
3376
+ # coordinate has had its decisions journaled by a current binary. Live
3377
+ # ingest advances it with the cursor only while the two are equal; a
3378
+ # rebuild carries it from a trusted predecessor index or sets it after
3379
+ # the shared completion step proved the window. Absent means unknown,
3380
+ # which the completion step reads as "replay from the cutover".
3381
+ # Fix round C (P1-1): the frontier counts CONSUMED INPUTS, and the
3382
+ # decisions a cycle derives from them land beyond it, so the row also
3383
+ # names the output end — the coordinate through which every decision
3384
+ # derived from an input at or before the frontier is journaled, with
3385
+ # nothing between the two derived from a later input. NULL means
3386
+ # nothing is owed beyond the frontier.
3387
+ conn.execute(
3388
+ "CREATE TABLE IF NOT EXISTS journal_derivation_frontier ("
3389
+ "id INTEGER PRIMARY KEY CHECK (id = 1), "
3390
+ "segment TEXT NOT NULL, "
3391
+ "offset INTEGER NOT NULL, "
3392
+ "output_segment TEXT, "
3393
+ "output_offset INTEGER)"
3394
+ )
3395
+ # An index an earlier build of this epoch created lacks the two
3396
+ # columns; additive, so it keeps its frontier row.
3397
+ add_column_if_missing(
3398
+ conn, "journal_derivation_frontier", "output_segment", "TEXT")
3399
+ add_column_if_missing(
3400
+ conn, "journal_derivation_frontier", "output_offset", "INTEGER")
3401
+ # Revision 2 (#946, Codex design-fix-round-3 Q5): the proof
3402
+ # generation. A row written under the old traversal rule (NULL here)
3403
+ # may name a valid endpoint past an input nothing derived, so only
3404
+ # `_cctally_journal.DERIVATION_PROOF_GENERATION` is trusted.
3405
+ add_column_if_missing(
3406
+ conn, "journal_derivation_frontier", "proof_generation", "INTEGER")
3407
+ # #825/#832 fix round A (Codex fix-round-1 Q2): the physical revision
3408
+ # inventory of every EXCLUDED event or correction action the index has
3409
+ # consumed, by logical id. Admission hides these from effective
3410
+ # selection, so without it a current writer would classify the id NEW
3411
+ # and append a revision-0 twin the previous release reads as a
3412
+ # same-revision conflict.
3413
+ conn.execute(
3414
+ "CREATE TABLE IF NOT EXISTS journal_excluded_event_revisions ("
3415
+ "event_id TEXT PRIMARY KEY, "
3416
+ "max_rev INTEGER NOT NULL)"
3417
+ )
3418
+ # #825/#832 epoch 1018 (spec 5.6 item 9): the outcome quarantine. A
3419
+ # deterministic function of journal order that ingest extends inside
3420
+ # its cycle transaction and a rebuild reproduces from the pinned
3421
+ # prefix: one row per uncertainty trigger (an excluded record or a
3422
+ # current `legacy_spool_activity` op, at its physical coordinate), the
3423
+ # whole-prefix envelope per scope ("excluded" applies to every
3424
+ # account), and each account's accounting and control certificates
3425
+ # with the trigger sequence they were granted against. A certificate
3426
+ # granted against an older sequence is void; a missing or
3427
+ # unverifiable row reads as quarantined.
3428
+ conn.execute(
3429
+ "CREATE TABLE IF NOT EXISTS outcome_quarantine_triggers ("
3430
+ "seq INTEGER PRIMARY KEY, "
3431
+ "segment TEXT, "
3432
+ "byte_offset INTEGER, "
3433
+ "kind TEXT NOT NULL, "
3434
+ "reason TEXT NOT NULL, "
3435
+ "at TEXT)"
3436
+ )
3437
+ conn.execute(
3438
+ "CREATE TABLE IF NOT EXISTS outcome_quarantine_envelope ("
3439
+ "scope TEXT PRIMARY KEY, "
3440
+ "envelope_json TEXT NOT NULL)"
3441
+ )
3442
+ conn.execute(
3443
+ "CREATE TABLE IF NOT EXISTS outcome_quarantine_certificates ("
3444
+ "account_key TEXT PRIMARY KEY, "
3445
+ "accounting_certificate_json TEXT, "
3446
+ "accounting_trigger_seq INTEGER, "
3447
+ "control_certificate_json TEXT, "
3448
+ "control_trigger_seq INTEGER)"
3449
+ )
3313
3450
  conn.execute(
3314
3451
  "CREATE TABLE IF NOT EXISTS journal_selector_batches ("
3315
3452
  "batch_id TEXT PRIMARY KEY, "
@@ -3369,6 +3506,45 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
3369
3506
  "started_at_utc TEXT NOT NULL, "
3370
3507
  "stamped_at_utc TEXT NOT NULL)"
3371
3508
  )
3509
+ # #825/#832 Task 4.0 (epoch 1018): a milestone whose snapshot a
3510
+ # credit's stale-replica removal deleted. The removal's band starts at
3511
+ # floor10 of the arming tick, so it also reaches the GENUINE pre-credit
3512
+ # peak tick when that tick shares the arming tick's ten-minute slot —
3513
+ # and that tick is the one that crossed the last weekly and five-hour
3514
+ # milestones. A rebuild folds the suppression (order 30) before the
3515
+ # milestones (order 60), so their journaled `usage_snapshot_ref` named
3516
+ # a row that no longer existed and the NOT NULL row was silently
3517
+ # dropped; the next tick then re-recorded the highest weekly milestone
3518
+ # and its conflict convergence raised on every cycle.
3519
+ #
3520
+ # The tombstone records every journaled snapshot the index deleted —
3521
+ # the live stale-replica removals and the suppression appliers alike,
3522
+ # because a trigger sees every DELETE — so the fold's ONE resolver
3523
+ # (`_cctally_journal._resolve_fold_ref`) can tell "a suppression
3524
+ # removed it" from a genuinely unresolvable reference. The trigger's
3525
+ # second half points every milestone that referenced the deleted row at
3526
+ # the 0 "no snapshot row" sentinel, which is what the rebuild resolves
3527
+ # such a reference to, so the live index and a rebuild agree. Both
3528
+ # milestone readers already treat 0 as "no row": the five-hour
3529
+ # milestone read LEFT JOINs (and so publishes the effective weekly
3530
+ # value as unavailable, as it already did for the dangling rowid), and
3531
+ # the observation-gap classifier never continues a run through 0.
3532
+ conn.execute(
3533
+ "CREATE TABLE IF NOT EXISTS usage_snapshot_suppressions ("
3534
+ "journal_id TEXT PRIMARY KEY)"
3535
+ )
3536
+ conn.execute(
3537
+ "CREATE TRIGGER IF NOT EXISTS trg_usage_snapshot_suppression "
3538
+ "AFTER DELETE ON weekly_usage_snapshots "
3539
+ "BEGIN "
3540
+ "INSERT OR IGNORE INTO usage_snapshot_suppressions (journal_id) "
3541
+ "SELECT OLD.journal_id WHERE OLD.journal_id IS NOT NULL; "
3542
+ "UPDATE percent_milestones SET usage_snapshot_id = 0 "
3543
+ "WHERE usage_snapshot_id = OLD.id; "
3544
+ "UPDATE five_hour_milestones SET usage_snapshot_id = 0 "
3545
+ "WHERE usage_snapshot_id = OLD.id; "
3546
+ "END"
3547
+ )
3372
3548
 
3373
3549
  # §6.2 backfill gate (Task 8): stamp the one-shot marker AFTER the three
3374
3550
  # open-time backfills ran, so the next open skips them (and their probes)
@@ -2586,9 +2586,9 @@ def _dashboard_maybe_prune_retention() -> None:
2586
2586
  crash the sync thread)."""
2587
2587
  try:
2588
2588
  c = sys.modules["cctally"]
2589
- from _cctally_config import resolve_retention_days
2589
+ from _cctally_config import resolve_prune_retention_days
2590
2590
  import _lib_conversation_retention as retention
2591
- retention_days = resolve_retention_days(c.load_config())
2591
+ retention_days = resolve_prune_retention_days(c.load_config())
2592
2592
  if retention_days <= 0:
2593
2593
  return
2594
2594
  conn = c.open_conversations_db(attach_cache=False)