cctally 1.115.0 → 1.116.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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -3428,8 +3428,13 @@ def _read_five_hour_source_state(conn, account_key, five_hour_window_key,
3428
3428
  """Return one contributor's five-hour state, or None when it has none.
3429
3429
 
3430
3430
  The tuple is ``(baseline_pct, pending_low_pct, pending_at_utc,
3431
- pending_observation_id)``; ``pending_low_pct is not None`` is the armed
3432
- predicate. A missing table is reported as "no state" rather than raised,
3431
+ pending_observation_id, baseline_observation_id, baseline_generation,
3432
+ pending_generation, pending_position)``; ``pending_low_pct is not None``
3433
+ is the armed predicate. The last four are the v2 detector's causal facts
3434
+ (#797/#823/#840 spec 4.9): the observation that established the baseline,
3435
+ the credit generation the baseline and the pending descent were
3436
+ established under, and the arming observation's capture instant. Each is
3437
+ None on a row the historical detector or an earlier binary wrote. A missing table is reported as "no state" rather than raised,
3433
3438
  for the same reason the weekly debounce reader does it: the detector must
3434
3439
  never crash a recording tick over its own bookkeeping.
3435
3440
 
@@ -3467,7 +3472,9 @@ def _read_five_hour_source_state(conn, account_key, five_hour_window_key,
3467
3472
  try:
3468
3473
  row = conn.execute(
3469
3474
  "SELECT baseline_pct, pending_low_pct, pending_at_utc, "
3470
- " pending_observation_id "
3475
+ " pending_observation_id, baseline_observation_id, "
3476
+ " baseline_generation, pending_generation, "
3477
+ " pending_position "
3471
3478
  " FROM five_hour_credit_confirmation_state "
3472
3479
  " WHERE account_key = ? AND five_hour_window_key = ? "
3473
3480
  " AND source = ?",
@@ -3482,19 +3489,31 @@ def _read_five_hour_source_state(conn, account_key, five_hour_window_key,
3482
3489
  None if row[1] is None else float(row[1]),
3483
3490
  row[2],
3484
3491
  row[3],
3492
+ row[4],
3493
+ row[5],
3494
+ row[6],
3495
+ row[7],
3485
3496
  )
3486
3497
 
3487
3498
 
3488
3499
  def _write_five_hour_source_state(conn, account_key, five_hour_window_key,
3489
3500
  source, *, baseline_pct,
3490
3501
  pending_low_pct=None, pending_at_utc=None,
3491
- pending_observation_id=None):
3502
+ pending_observation_id=None,
3503
+ baseline_observation_id=None,
3504
+ baseline_generation=None,
3505
+ pending_generation=None,
3506
+ pending_position=None):
3492
3507
  """Upsert one contributor's state for one window, then retire that
3493
3508
  contributor's rows for earlier windows.
3494
3509
 
3495
3510
  The three ``pending_*`` values are written together on every call, so
3496
3511
  cancelling or confirming a descent is the same statement as arming one and
3497
- a stale pending value can never survive a state change.
3512
+ a stale pending value can never survive a state change. The same holds
3513
+ for the v2 causal facts (``baseline_observation_id``,
3514
+ ``baseline_generation``, ``pending_generation``, ``pending_position``):
3515
+ every write states all of them, so the historical detector's write
3516
+ leaves none behind.
3498
3517
 
3499
3518
  ``pending_at_utc`` is the arming tick's DETECTION clock, which is what the
3500
3519
  five-hour path has always stamped into `effective_reset_at_utc`
@@ -3512,16 +3531,23 @@ def _write_five_hour_source_state(conn, account_key, five_hour_window_key,
3512
3531
  conn.execute(
3513
3532
  "INSERT INTO five_hour_credit_confirmation_state "
3514
3533
  "(account_key, five_hour_window_key, source, baseline_pct, "
3515
- " pending_low_pct, pending_at_utc, pending_observation_id) "
3516
- "VALUES (?, ?, ?, ?, ?, ?, ?) "
3534
+ " pending_low_pct, pending_at_utc, pending_observation_id, "
3535
+ " baseline_observation_id, baseline_generation, pending_generation, "
3536
+ " pending_position) "
3537
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
3517
3538
  "ON CONFLICT(account_key, five_hour_window_key, source) DO UPDATE SET "
3518
3539
  " baseline_pct = excluded.baseline_pct, "
3519
3540
  " pending_low_pct = excluded.pending_low_pct, "
3520
3541
  " pending_at_utc = excluded.pending_at_utc, "
3521
- " pending_observation_id = excluded.pending_observation_id",
3542
+ " pending_observation_id = excluded.pending_observation_id, "
3543
+ " baseline_observation_id = excluded.baseline_observation_id, "
3544
+ " baseline_generation = excluded.baseline_generation, "
3545
+ " pending_generation = excluded.pending_generation, "
3546
+ " pending_position = excluded.pending_position",
3522
3547
  (account_key, int(five_hour_window_key), source, float(baseline_pct),
3523
3548
  None if pending_low_pct is None else float(pending_low_pct),
3524
- pending_at_utc, pending_observation_id),
3549
+ pending_at_utc, pending_observation_id, baseline_observation_id,
3550
+ baseline_generation, pending_generation, pending_position),
3525
3551
  )
3526
3552
  conn.execute(
3527
3553
  "DELETE FROM five_hour_credit_confirmation_state "
@@ -3532,7 +3558,8 @@ def _write_five_hour_source_state(conn, account_key, five_hour_window_key,
3532
3558
 
3533
3559
  def _step_five_hour_source_state(conn, account_key, five_hour_window_key,
3534
3560
  source, *, new_pct, observation_id,
3535
- drop_threshold, window_live, now_iso):
3561
+ drop_threshold, window_live, now_iso,
3562
+ generation=None, capture_at=None):
3536
3563
  """Advance one contributor's five-hour state by one observation.
3537
3564
 
3538
3565
  Returns ``(decision, prior_state)``, where ``prior_state`` is the row as it
@@ -3545,6 +3572,14 @@ def _step_five_hour_source_state(conn, account_key, five_hour_window_key,
3545
3572
  must leave the state armed so the next same-source reading below the
3546
3573
  baseline re-confirms and re-runs the idempotent pivots, rather than losing
3547
3574
  the credit outright.
3575
+
3576
+ ``generation`` (#797/#823/#840 spec 4.9) is the account and window's
3577
+ current five-hour credit generation, passed only by the v2 detector. It
3578
+ makes a state established under another generation obsolete (the
3579
+ kernel), and it is recorded with what this observation establishes: the
3580
+ baseline's observation and generation, and an arming observation's
3581
+ generation and causal position (its capture instant, ``capture_at``). The
3582
+ historical detector passes None and records none of these.
3548
3583
  """
3549
3584
  prior_state = _read_five_hour_source_state(
3550
3585
  conn, account_key, five_hour_window_key, source)
@@ -3556,7 +3591,11 @@ def _step_five_hour_source_state(conn, account_key, five_hour_window_key,
3556
3591
  observation_id=observation_id,
3557
3592
  drop_threshold=drop_threshold,
3558
3593
  window_live=window_live,
3594
+ generation=generation,
3595
+ baseline_generation=None if prior_state is None else prior_state[5],
3596
+ pending_generation=None if prior_state is None else prior_state[6],
3559
3597
  )
3598
+ v2 = generation is not None
3560
3599
  if decision.action == FIVE_HOUR_ARM:
3561
3600
  # The descent is recorded and nothing is emitted. The write clamp
3562
3601
  # raises this reading back to the baseline in
@@ -3569,13 +3608,26 @@ def _step_five_hour_source_state(conn, account_key, five_hour_window_key,
3569
3608
  pending_low_pct=new_pct,
3570
3609
  pending_at_utc=now_iso,
3571
3610
  pending_observation_id=observation_id,
3611
+ baseline_observation_id=(
3612
+ prior_state[4] if v2 and prior_state is not None else None),
3613
+ baseline_generation=generation,
3614
+ pending_generation=generation,
3615
+ pending_position=capture_at if v2 else None,
3572
3616
  )
3573
3617
  elif decision.action in (FIVE_HOUR_SET_BASELINE, FIVE_HOUR_CANCEL):
3574
3618
  # Establish, raise, or cancel. All three write the baseline with the
3575
3619
  # three pending columns NULL, so a cancelled candidate cannot linger.
3620
+ # The baseline's observation is this one unless a cancellation kept
3621
+ # the higher prior baseline.
3622
+ kept_prior = (prior_state is not None
3623
+ and float(decision.baseline_pct) != float(new_pct))
3576
3624
  _write_five_hour_source_state(
3577
3625
  conn, account_key, five_hour_window_key, source,
3578
3626
  baseline_pct=decision.baseline_pct,
3627
+ baseline_observation_id=(
3628
+ None if not v2 else
3629
+ prior_state[4] if kept_prior else observation_id),
3630
+ baseline_generation=generation,
3579
3631
  )
3580
3632
  # FIVE_HOUR_HOLD writes nothing: either the arming observation replayed
3581
3633
  # and must leave the state armed, or the reading neither raises the
@@ -3583,6 +3635,164 @@ def _step_five_hour_source_state(conn, account_key, five_hour_window_key,
3583
3635
  return decision, prior_state
3584
3636
 
3585
3637
 
3638
+ #: The no-credit sentinel of the five-hour credit generation (spec 4.9).
3639
+ FIVE_HOUR_NO_CREDIT_GENERATION = "none"
3640
+ #: The source-key prefix of a credit's causal facts in the disposable
3641
+ #: confirmation state (see `_record_five_hour_credit_facts`). No real
3642
+ #: contributor source carries it: the pipeline files such a source as
3643
+ #: `FIVE_HOUR_UNKNOWN_SOURCE`.
3644
+ FIVE_HOUR_CREDIT_FACTS_PREFIX = "#credit|"
3645
+
3646
+
3647
+ def five_hour_credit_generation(conn, five_hour_window_key, *,
3648
+ account_key) -> str:
3649
+ """The account and window's current five-hour credit generation
3650
+ (#797/#823/#840 spec 4.9, D3/D11): ``"<event_id>@<revision>"`` for the
3651
+ selected effective credit, else ``"none"``.
3652
+
3653
+ The credit is the one `resolve_five_hour_reset_event` selects by logical
3654
+ order (effective instant, detection instant, stable journal identity),
3655
+ never by rowid, so a rebuild that renumbers rows cannot change it. The
3656
+ event id is the harvest's natural key ``fhc:<account>:<window>:<effective>``
3657
+ and the revision is a digest of the row's accounting content, so a
3658
+ correction that rewrites the credit, or a tombstone that removes it,
3659
+ gives a different generation and makes every state that depends on the
3660
+ old one obsolete. A database error propagates."""
3661
+ import _lib_journal
3662
+
3663
+ event = resolve_five_hour_reset_event(
3664
+ conn, five_hour_window_key, account_key=account_key)
3665
+ if isinstance(event, NoFiveHourResetEvent):
3666
+ return FIVE_HOUR_NO_CREDIT_GENERATION
3667
+ row = conn.execute(
3668
+ "SELECT detected_at_utc, prior_percent, post_percent, "
3669
+ " effective_reset_at_utc "
3670
+ " FROM five_hour_reset_events WHERE id = ?",
3671
+ (event.row_id,),
3672
+ ).fetchone()
3673
+ identity = _lib_journal.evt_id(
3674
+ "fhc", account_key, int(five_hour_window_key),
3675
+ event.effective_reset_at_utc)
3676
+ return f"{identity}@{_five_hour_credit_revision(row)}"
3677
+
3678
+
3679
+ def _five_hour_credit_revision(row) -> str:
3680
+ """A five-hour credit row's content revision: its accounting payload
3681
+ (detection instant, both ends, effective instant), digested."""
3682
+ import hashlib
3683
+
3684
+ return hashlib.sha256(json.dumps(
3685
+ [str(row[0]), round(float(row[1]), 6), round(float(row[2]), 6),
3686
+ str(row[3])], separators=(",", ":")).encode("utf-8")).hexdigest()[:16]
3687
+
3688
+
3689
+ def _position_epoch(text) -> "float | None":
3690
+ try:
3691
+ return parse_iso_datetime(text, "five_hour_state.position").timestamp()
3692
+ except (TypeError, ValueError, AttributeError):
3693
+ return None
3694
+
3695
+
3696
+ def _rebase_five_hour_sources(conn, account_key, five_hour_window_key,
3697
+ confirming_source, *, arming_position,
3698
+ old_generation, new_generation):
3699
+ """Carry every OTHER contributor of the window across a v2 credit
3700
+ (#797/#823/#840 spec 4.9, D11).
3701
+
3702
+ A contributor whose pending descent was armed strictly AFTER the
3703
+ confirming source's arming observation observed its low after the credit
3704
+ happened, so that reading is independently observed post-credit growth:
3705
+ it becomes the contributor's baseline in the new generation (the
3706
+ observation identity carried with it) and the obsolete descent itself is
3707
+ retired, never relabelled. D11's trace: A arms `20 -> 5`, B observes a
3708
+ genuine 10, A confirms; B's `20 -> 10` is retired, its 10 is kept, and a
3709
+ later `10 -> 0` is detected. Every other contributor state is left to the
3710
+ generation rule, which retires it at that contributor's next
3711
+ observation; a state recorded without a generation is stamped with the
3712
+ old one first, so the rule reaches it too. An unknown or equal position
3713
+ is never "after"."""
3714
+ rows = conn.execute(
3715
+ "SELECT source, baseline_pct, pending_low_pct, pending_at_utc, "
3716
+ " pending_observation_id, baseline_observation_id, "
3717
+ " baseline_generation, pending_generation, pending_position "
3718
+ " FROM five_hour_credit_confirmation_state "
3719
+ " WHERE account_key = ? AND five_hour_window_key = ?",
3720
+ (account_key, int(five_hour_window_key)),
3721
+ ).fetchall()
3722
+ armed_at = _position_epoch(arming_position)
3723
+ for row in rows:
3724
+ source = row[0]
3725
+ if (source == confirming_source
3726
+ or str(source).startswith(FIVE_HOUR_CREDIT_FACTS_PREFIX)):
3727
+ continue
3728
+ position = _position_epoch(row[8])
3729
+ if (row[2] is not None
3730
+ and row[7] in (old_generation, None)
3731
+ and armed_at is not None and position is not None
3732
+ and position > armed_at):
3733
+ _write_five_hour_source_state(
3734
+ conn, account_key, five_hour_window_key, source,
3735
+ baseline_pct=float(row[2]),
3736
+ baseline_observation_id=row[4],
3737
+ baseline_generation=new_generation,
3738
+ )
3739
+ elif row[6] is None or (row[2] is not None and row[7] is None):
3740
+ _write_five_hour_source_state(
3741
+ conn, account_key, five_hour_window_key, source,
3742
+ baseline_pct=float(row[1]),
3743
+ pending_low_pct=row[2], pending_at_utc=row[3],
3744
+ pending_observation_id=row[4],
3745
+ baseline_observation_id=row[5],
3746
+ baseline_generation=row[6] or old_generation,
3747
+ pending_generation=(
3748
+ None if row[2] is None else row[7] or old_generation),
3749
+ pending_position=row[8],
3750
+ )
3751
+
3752
+
3753
+ def _record_five_hour_credit_facts(conn, account_key, five_hour_window_key,
3754
+ confirming_source, *, prior_pct, post_pct,
3755
+ armed_state, old_generation,
3756
+ new_generation):
3757
+ """Retain one v2 credit's causal facts beside the confirmation state
3758
+ (#797/#823/#840 spec 4.4, 4.9): which source confirmed it, the
3759
+ observation that established that source's baseline and the arming
3760
+ observation. The status line resolves a five-hour credit's causal low
3761
+ through them (a `statusline` credit, or an `api` credit whose baseline
3762
+ and arming observations are provider samples); the confirmation state's
3763
+ own row is replaced at the confirmation, so without this nothing would
3764
+ say which observations a credit came from.
3765
+
3766
+ It is disposable exactly like the state it sits in: a rebuild drops it,
3767
+ and a credit without facts is resolved as unknown, which invalidates and
3768
+ so never refuses. The row is keyed by its ordinal in the window and the
3769
+ confirming source, so the writer's trailing DELETE retires the same key
3770
+ of earlier windows and the table stays proportional to live state. It
3771
+ stores the credit's two ends in the baseline and pending columns, the
3772
+ arming observation and instant, the baseline observation, and the
3773
+ generation before (``baseline_generation``) and after
3774
+ (``pending_generation``, which binds it to the credit's identity and
3775
+ revision) the credit."""
3776
+ count = conn.execute(
3777
+ "SELECT COUNT(*) FROM five_hour_credit_confirmation_state "
3778
+ " WHERE account_key = ? AND five_hour_window_key = ? "
3779
+ " AND source LIKE '#credit|%'",
3780
+ (account_key, int(five_hour_window_key)),
3781
+ ).fetchone()[0]
3782
+ _write_five_hour_source_state(
3783
+ conn, account_key, five_hour_window_key,
3784
+ f"{FIVE_HOUR_CREDIT_FACTS_PREFIX}{int(count) + 1:02d}|{confirming_source}",
3785
+ baseline_pct=float(prior_pct),
3786
+ pending_low_pct=float(post_pct),
3787
+ pending_at_utc=None if armed_state is None else armed_state[2],
3788
+ pending_observation_id=None if armed_state is None else armed_state[3],
3789
+ baseline_observation_id=None if armed_state is None else armed_state[4],
3790
+ baseline_generation=old_generation,
3791
+ pending_generation=new_generation,
3792
+ pending_position=None if armed_state is None else armed_state[7],
3793
+ )
3794
+
3795
+
3586
3796
  # ``CreditPlan`` / ``_parse_credit_at`` / ``_build_credit_plan`` now live in
3587
3797
  # ``bin/_lib_credit.py`` (#279 S4 F1); re-imported at module top so the
3588
3798
  # ``bin/cctally`` re-exports and this module's own callers
@@ -3804,9 +4014,11 @@ def _credit_retirement_bands(conn, *, week_start_date, week_start_at,
3804
4014
 
3805
4015
  #834 S1 (#835). `_doomed_snapshot_rows` names the rows a firing credit is
3806
4016
  about to remove; this names, for a week that has already been credited, the
3807
- (floor, retired value) pairs a stored row can still match. It exists because
3808
- #835 preserves held rows, so a row carrying a retired weekly value can now
3809
- outlive the credit that retired it and a read must recognize it.
4017
+ (floor, retired value) pairs a stored row can still match. #835 used the
4018
+ retired values as a read-side band; #840 (spec section 4.8) removed that
4019
+ band, so the one remaining reader is `_latest_credit_floor_instant`, which
4020
+ takes only the floors: a stored value is read as stored, and a poisoned one
4021
+ is corrected by `db rederive`'s reviewed `retract`.
3810
4022
 
3811
4023
  The two legs are scoped exactly the way `_reset_aware_floor`
3812
4024
  (bin/_cctally_core.py) scopes them, and the two must stay in step: a
@@ -3875,8 +4087,7 @@ def _latest_credit_floor_instant(bands):
3875
4087
  drift apart, and so a caller cannot accidentally take the FIRST floor, which
3876
4088
  would admit rows an intervening credit already invalidated.
3877
4089
 
3878
- An unparseable floor contributes nothing, matching
3879
- `_weekly_value_is_retired_replica`, which skips one for the same reason."""
4090
+ An unparseable floor contributes nothing."""
3880
4091
  latest = None
3881
4092
  for floor_at, _retired, _inclusive in bands:
3882
4093
  try:
@@ -3888,71 +4099,6 @@ def _latest_credit_floor_instant(bands):
3888
4099
  return latest
3889
4100
 
3890
4101
 
3891
- def _weekly_value_is_retired_replica(bands, *, captured_at_utc, weekly_percent):
3892
- """True when a stored weekly value is one a credit in `bands` retired.
3893
-
3894
- #834 S1 (#835). The same band and floor predicate the stale-replica DELETE
3895
- applies, applied as a READ filter instead of as a deletion: captured at or
3896
- after the credit's floor, and within 1.0 point of the value that credit
3897
- retired.
3898
-
3899
- Instants are compared as PARSED moments rather than as text, because the two
3900
- sides carry mixed offset spellings (`Z` on a capture stamp, `+00:00` on a
3901
- floor) — the same reason every SQL site wraps both sides in `unixepoch()`.
3902
-
3903
- AN UNPARSEABLE CAPTURE STAMP IS TREATED AS NOT RETIRED HERE, and what that
3904
- means for the read depends on the CALLER. There are three, and the floor bound
3905
- belongs to exactly one of them.
3906
-
3907
- `_latest_seven_day_and_window` (bin/_cctally_five_hour.py) carries a floor bound
3908
- ahead of this predicate, added in Gate A R1. That bound treats an unparseable
3909
- stamp inside a credited week as pre-floor and ends the walk with `None`, which
3910
- is the right disposition: a row that cannot be placed relative to the floor
3911
- cannot be certified as able to state the week's current effective value. So
3912
- there, a malformed row blanks the read.
3913
-
3914
- `_load_five_hour_milestones` (bin/_cctally_five_hour.py, from tranche A) carries
3915
- no such bound. An unparseable `snapshot_captured_at_utc` makes this function
3916
- return `False` and the joined effective crossing value RENDERS. A correction of
3917
- record: an earlier version of this docstring generalized R1's floor bound to
3918
- every caller, and it holds for one.
3919
-
3920
- `_credit_aware_block_weekly_axes` (bin/_cctally_five_hour.py) carries no floor
3921
- bound either, but since Gate A R8 an unparseable instant never reaches this
3922
- predicate from it: `_block_weekly_axis_weeks` parses every axis instant first —
3923
- including `block_start_at`, which the start axis falls back to — and returns an
3924
- EMPTY TUPLE when it cannot, which resolves no bands. (It returned `None` when
3925
- this paragraph was written; R8 made it return a tuple of candidate weeks and R9
3926
- added a third resolution pass, and both forms are falsy, so the caller's `if not
3927
- week_ids` guard is unaffected. The stated value was simply stale.) The value
3928
- renders for that reason rather than this one. Same outcome, different mechanism,
3929
- and the mechanism matters because only one of the two is a deliberate decision.
3930
-
3931
- What this function's own tolerance buys in every case is that a malformed stamp
3932
- is never MISclassified as retired, which is what keeps the uncredited-week path
3933
- byte-stable."""
3934
- if weekly_percent is None or not bands:
3935
- return False
3936
- try:
3937
- captured = parse_iso_datetime(captured_at_utc, "captured_at").timestamp()
3938
- except (ValueError, TypeError):
3939
- return False
3940
- for floor_at, retired, inclusive in bands:
3941
- try:
3942
- floor = parse_iso_datetime(floor_at, "credit_floor").timestamp()
3943
- except (ValueError, TypeError):
3944
- continue
3945
- if captured < floor:
3946
- continue
3947
- delta = abs(float(weekly_percent) - retired)
3948
- # The band's comparison belongs to the credit that set it: the automatic
3949
- # path is inclusive, `record-credit` is strict. See
3950
- # `_doomed_snapshot_rows` for why those two differ.
3951
- if (delta <= 1.0) if inclusive else (delta < 1.0):
3952
- return True
3953
- return False
3954
-
3955
-
3956
4102
  def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
3957
4103
  *, observed_pre_credit_pct, effective_dt,
3958
4104
  as_of=None, commit=True, ctx=None,
@@ -4189,7 +4335,8 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4189
4335
  ctx=None, account_key=_lib_accounts.UNATTRIBUTED,
4190
4336
  origin_observation_id=None, capture_at=None,
4191
4337
  source=None, hold_weekly_axis=False,
4192
- outcomes_allowed=True, fold_policy=0):
4338
+ outcomes_allowed=True, fold_policy=0,
4339
+ detector_policy="historical"):
4193
4340
  """Detect + record weekly and 5h reset/credit artifacts for one usage
4194
4341
  observation (extracted from ``cmd_record_usage``; DB journal redesign
4195
4342
  §5.2.3).
@@ -4277,6 +4424,15 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4277
4424
  records every doomed row in its one ``suppression`` list, exactly as
4278
4425
  the historical policy writes it, so the previous release folds it,
4279
4426
  and the current applier replays it with the same split.
4427
+ - ``detector_policy`` (#797/#823/#840 spec 4.9, default
4428
+ ``"historical"``): the detector revision the raw observation recorded,
4429
+ as `_cctally_journal.detector_policy_of` reads it. ``"v2"`` decides
4430
+ five-hour credits by the per-account, per-window credit generation
4431
+ instead of the ``(prior_percent, post_percent)`` pair check. The
4432
+ default keeps every direct caller, and every observation an earlier
4433
+ release journaled, on the historical detector. The pipeline never
4434
+ calls this for an ``"unsupported"`` revision (the step is withheld),
4435
+ and passes a projection-filled axis as not observed (D6).
4280
4436
  """
4281
4437
  c = _cctally()
4282
4438
  now_utc = _as_of_or_command(as_of)
@@ -4560,6 +4716,16 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4560
4716
  # SECOND reading would become the baseline and a descent
4561
4717
  # between them would be invisible.
4562
4718
  five_hour_source = source or FIVE_HOUR_UNKNOWN_SOURCE
4719
+ if five_hour_source.startswith(FIVE_HOUR_CREDIT_FACTS_PREFIX):
4720
+ five_hour_source = FIVE_HOUR_UNKNOWN_SOURCE
4721
+ # #797/#823/#840 spec 4.9 (D3, D11): the v2 detector decides
4722
+ # against the window's current credit generation, read in this
4723
+ # same transaction, so the confirmation rechecks it.
4724
+ v2_detector = detector_policy == "v2"
4725
+ generation = (
4726
+ five_hour_credit_generation(
4727
+ conn, five_hour_window_key, account_key=account_key)
4728
+ if v2_detector else None)
4563
4729
  src_decision, src_state = _step_five_hour_source_state(
4564
4730
  conn, account_key, five_hour_window_key, five_hour_source,
4565
4731
  new_pct=float(five_hour_percent),
@@ -4567,6 +4733,8 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4567
4733
  drop_threshold=c._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD,
4568
4734
  window_live=window_live,
4569
4735
  now_iso=now_utc.isoformat(timespec="seconds"),
4736
+ generation=generation,
4737
+ capture_at=capture_dt.isoformat(timespec="seconds"),
4570
4738
  )
4571
4739
  if src_decision.action == FIVE_HOUR_CONFIRM and not outcomes_allowed:
4572
4740
  # Quarantined (spec 5.6 item 2): no event, no stale-replica
@@ -4634,21 +4802,34 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4634
4802
  # `post_5h_pct`, not the confirming tick's percent —
4635
4803
  # comparing an armed low against a confirming percent
4636
4804
  # answers no question at all.
4637
- most_recent = conn.execute(
4638
- "SELECT prior_percent, post_percent "
4639
- " FROM five_hour_reset_events "
4640
- " WHERE five_hour_window_key = ? "
4641
- " AND account_key = ? "
4642
- " ORDER BY id DESC LIMIT 1",
4643
- (int(five_hour_window_key), account_key),
4644
- ).fetchone()
4645
- is_dup = (
4646
- most_recent is not None
4647
- and round(prior_5h_pct, 1)
4648
- == round(float(most_recent["prior_percent"]), 1)
4649
- and round(post_5h_pct, 1)
4650
- == round(float(most_recent["post_percent"]), 1)
4651
- )
4805
+ #
4806
+ # #797/#823/#840 spec 4.9 (D3): the v2 detector replaces
4807
+ # the pair check with causal retry identity plus the
4808
+ # credit generation. A credit is now confirmed only from a
4809
+ # descent armed in the current generation, so a replay of
4810
+ # one descent, or a second source's copy of it, can no
4811
+ # longer reach this point, while a genuine second
4812
+ # `20 -> 5` in a distinct slot is a new credit the pair
4813
+ # check would have swallowed. The historical detector
4814
+ # keeps the pair check exactly.
4815
+ if v2_detector:
4816
+ is_dup = False
4817
+ else:
4818
+ most_recent = conn.execute(
4819
+ "SELECT prior_percent, post_percent "
4820
+ " FROM five_hour_reset_events "
4821
+ " WHERE five_hour_window_key = ? "
4822
+ " AND account_key = ? "
4823
+ " ORDER BY id DESC LIMIT 1",
4824
+ (int(five_hour_window_key), account_key),
4825
+ ).fetchone()
4826
+ is_dup = (
4827
+ most_recent is not None
4828
+ and round(prior_5h_pct, 1)
4829
+ == round(float(most_recent["prior_percent"]), 1)
4830
+ and round(post_5h_pct, 1)
4831
+ == round(float(most_recent["post_percent"]), 1)
4832
+ )
4652
4833
  # 10-min floor (spec §2.3 — bounded
4653
4834
  # stacked-credit resolution; one event per
4654
4835
  # 10-min slot per block). Resolved BEFORE
@@ -4911,10 +5092,37 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
4911
5092
  # armed so the next same-source reading below the
4912
5093
  # baseline re-confirms and re-runs the idempotent
4913
5094
  # pivots, rather than losing the credit outright.
5095
+ new_generation = None
5096
+ if v2_detector:
5097
+ # The generation this credit established (unchanged
5098
+ # when the ten-minute slot absorbed it). When it moved,
5099
+ # every other contributor is carried across it and the
5100
+ # credit's causal facts are retained.
5101
+ new_generation = five_hour_credit_generation(
5102
+ conn, five_hour_window_key,
5103
+ account_key=account_key)
5104
+ if new_generation != generation:
5105
+ _rebase_five_hour_sources(
5106
+ conn, account_key, five_hour_window_key,
5107
+ five_hour_source,
5108
+ arming_position=(
5109
+ src_state[7] or src_state[2]
5110
+ if src_state is not None else None),
5111
+ old_generation=generation,
5112
+ new_generation=new_generation)
5113
+ _record_five_hour_credit_facts(
5114
+ conn, account_key, five_hour_window_key,
5115
+ five_hour_source, prior_pct=prior_5h_pct,
5116
+ post_pct=post_5h_pct, armed_state=src_state,
5117
+ old_generation=generation,
5118
+ new_generation=new_generation)
4914
5119
  _write_five_hour_source_state(
4915
5120
  conn, account_key, five_hour_window_key,
4916
5121
  five_hour_source,
4917
5122
  baseline_pct=float(five_hour_percent),
5123
+ baseline_observation_id=(
5124
+ origin_observation_id if v2_detector else None),
5125
+ baseline_generation=new_generation,
4918
5126
  )
4919
5127
  except (sqlite3.DatabaseError, ValueError, TypeError) as exc:
4920
5128
  # Exception discipline (6c-gate P1): on the ingest path
@@ -6182,6 +6390,16 @@ def record_usage_bound(
6182
6390
  raw["five_hour_percent"] = five_hour_percent
6183
6391
  if five_hour_resets_at_str is not None:
6184
6392
  raw["five_hour_resets_at"] = five_hour_resets_at_str
6393
+ # #797/#823/#840 spec 4.3 D8: the request-start instant `q` of the OAuth
6394
+ # fetch publishing this reading, an additive raw key the previous release
6395
+ # reads past with `.get`. Only an api publication whose fetch recorded `q`
6396
+ # carries it; nothing else — a status-line reading, a direct
6397
+ # `record-usage`, a frozen plan's recovery — ever gains one.
6398
+ request_started = getattr(args, "oauth_request_started_at", None)
6399
+ if (isinstance(request_started, str) and publication is None
6400
+ and raw["source"] == "api"):
6401
+ import _lib_oauth_samples
6402
+ raw[_lib_oauth_samples.REQUEST_STARTED_KEY] = request_started
6185
6403
  # #825/#832 spec 4.7 / 5.1: the binding and the plan's stable publication
6186
6404
  # identity live in the RAW observation payload (never in a known event
6187
6405
  # payload). The previous release reads this payload by `.get`, so the extra
@@ -6207,6 +6425,13 @@ def record_usage_bound(
6207
6425
  raw[_jr.FOLD_POLICY_KEY] = _jr.CURRENT_FOLD_POLICY
6208
6426
  elif _jr.PLAN_FOLD_POLICY_KEY in publication:
6209
6427
  raw[_jr.FOLD_POLICY_KEY] = publication[_jr.PLAN_FOLD_POLICY_KEY]
6428
+ # #797/#823/#840 spec 4.9 (D12): the detector revision, recorded the same
6429
+ # way. A plan frozen before it carries none, so its recovery publishes an
6430
+ # observation the historical detector decides, byte-identical to before.
6431
+ if publication is None:
6432
+ raw[_jr.DETECTOR_POLICY_KEY] = _jr.DETECTOR_POLICY
6433
+ elif _jr.PLAN_DETECTOR_POLICY_KEY in publication:
6434
+ raw[_jr.DETECTOR_POLICY_KEY] = publication[_jr.PLAN_DETECTOR_POLICY_KEY]
6210
6435
  import _lib_accounts
6211
6436
  # Observe-and-stamp (#341, spec §1): resolve the active Claude account from
6212
6437
  # ~/.claude.json (stable-read, mtime-cached) and stamp the usage obs with it.
@@ -7842,6 +8067,59 @@ def _five_hour_saved_from_fold(fold, *, snapshot_id, capture_at,
7842
8067
  }
7843
8068
 
7844
8069
 
8070
+ def _detector_observation_mask(payload, detector_policy, *, account_key,
8071
+ five_hour) -> "tuple[bool, bool] | None":
8072
+ """``(observe_weekly, observe_five_hour)`` for one raw observation, or
8073
+ None when its detector step is withheld (#797/#823/#840 spec 4.9, D6).
8074
+
8075
+ The historical detector sees every axis, as every earlier release did. A
8076
+ v2 observation that is not a status-line publication (a direct
8077
+ `record-usage`, an OAuth reading) is a reading of every axis it carries.
8078
+ A v2 publication's validated provenance decides per axis: a `projection`
8079
+ axis was filled from the store and is not a new reading of its source,
8080
+ while `candidate` and `retained_low` axes are. Provenance that does not
8081
+ classify a published axis for this account cannot say which axes are
8082
+ readings, so the step is withheld."""
8083
+ if detector_policy == "unsupported":
8084
+ return None
8085
+ if detector_policy == "historical" or "publication_id" not in payload:
8086
+ return True, True
8087
+ provenance = payload.get("publication")
8088
+ if not isinstance(provenance, dict):
8089
+ return None
8090
+ observed = {}
8091
+ for axis, present in (("sevenDay", True), ("fiveHour", five_hour)):
8092
+ if not present:
8093
+ observed[axis] = False
8094
+ continue
8095
+ source = provenance.get(axis)
8096
+ if (not isinstance(source, dict)
8097
+ or source.get("account") != account_key
8098
+ or source.get("kind") not in ("candidate", "projection",
8099
+ "retained_low")):
8100
+ return None
8101
+ observed[axis] = source["kind"] != "projection"
8102
+ return observed["sevenDay"], observed["fiveHour"]
8103
+
8104
+
8105
+ def _detect_observation(c, conn, *, observe, detector_policy, **kwargs):
8106
+ """The shared detector over one observation's observed axes (spec 4.9).
8107
+ A projection-filled weekly axis takes the held branch (no weekly reset or
8108
+ credit detection at all, the five-hour detector still runs); a
8109
+ projection-filled five-hour axis is not passed, so the whole source-local
8110
+ step — baseline, arming, confirmation, cancellation, generation change
8111
+ and pruning — is skipped for it. The filled values stay available to the
8112
+ fold, which the caller runs on the observation unchanged."""
8113
+ observe_weekly, observe_five_hour = observe
8114
+ if not observe_weekly:
8115
+ kwargs["hold_weekly_axis"] = True
8116
+ if not observe_five_hour:
8117
+ kwargs["five_hour_window_key"] = None
8118
+ kwargs["five_hour_percent"] = None
8119
+ c.detect_reset_and_credit(
8120
+ conn, commit=False, detector_policy=detector_policy, **kwargs)
8121
+
8122
+
7845
8123
  def _pipeline_claude_usage(ctx, rec):
7846
8124
  """Obs-derivation pipeline hook (spec §5.2 step 4b) — the ingest-cycle
7847
8125
  transplant of ``cmd_record_usage``'s derivation body.
@@ -7909,6 +8187,15 @@ def _pipeline_claude_usage(ctx, rec):
7909
8187
  import _lib_accounts
7910
8188
  account_key = rec.get("account") or _lib_accounts.UNATTRIBUTED
7911
8189
 
8190
+ # #797/#823/#840 spec 4.3 D8: the provider sample, projected from the RAW
8191
+ # record before any detection or fold decision, so equal readings the fold
8192
+ # deduplicates still each produce one, and neither the five-hour
8193
+ # adjustment nor a credit's suppression or retirement can alter it. The
8194
+ # same call serves live ingest and both scratch replays; the apply-only
8195
+ # rebuild offers its read pass to the same projection.
8196
+ import _cctally_oauth_samples
8197
+ _cctally_oauth_samples.project_record(conn, rec)
8198
+
7912
8199
  # Week boundaries from resets_at (cmd_record_usage canonicalization).
7913
8200
  # Normalize before deriving the date keys as well as the ISO overrides:
7914
8201
  # rounding a 23:30–23:59 boundary crosses midnight, and letting
@@ -7976,33 +8263,37 @@ def _pipeline_claude_usage(ctx, rec):
7976
8263
  # ordinary recording below continues (C3).
7977
8264
  import _cctally_quarantine
7978
8265
  gate = _cctally_quarantine.quarantine_gate(ctx, account_key)
7979
- c.detect_reset_and_credit(
7980
- conn,
7981
- week_start_date=week_start_date,
7982
- week_end_at=week_end_at,
7983
- weekly_percent=weekly_percent,
7984
- five_hour_window_key=five_hour_window_key,
7985
- five_hour_percent=five_hour_percent,
7986
- as_of=as_of,
7987
- commit=False,
7988
- ctx=ctx,
7989
- account_key=account_key,
7990
- # #750 S3: the raw journal id of the line this derivation came from.
7991
- # The debounce ARM records it so a byte-identical replay of the first
7992
- # zero cannot confirm itself.
7993
- origin_observation_id=rec.get("id"),
7994
- # The CAPTURE stamp, not `as_of`. It is what every
7995
- # `effective_reset_at_utc` records, so live detection and the backfill
7996
- # (which reads `weekly_usage_snapshots.captured_at_utc`) agree about
7997
- # the same physical reset.
7998
- capture_at=capture_at,
7999
- # #769 S2 §3: the contributor discriminator for five-hour credit
8000
- # confirmation. `payload.source`, never `rec["src"]`.
8001
- source=source,
8002
- hold_weekly_axis=hold_weekly_axis,
8003
- outcomes_allowed=gate.allowed,
8004
- fold_policy=fold_policy,
8005
- )
8266
+ # #797/#823/#840 spec 4.9 (D6, D12): the detector revision this raw
8267
+ # observation recorded, and which of its axes are readings of its source.
8268
+ # An unsupported or malformed revision, or a v2 publication whose
8269
+ # provenance does not classify its axes, withholds the whole detector step
8270
+ # (an outcome may always be withheld); ordinary recording below continues.
8271
+ detector_policy = jr.detector_policy_of(payload)
8272
+ observe = _detector_observation_mask(
8273
+ payload, detector_policy, account_key=account_key,
8274
+ five_hour=five_hour_percent is not None)
8275
+ if observe is not None:
8276
+ _detect_observation(
8277
+ c, conn, observe=observe, detector_policy=detector_policy,
8278
+ week_start_date=week_start_date, week_end_at=week_end_at,
8279
+ weekly_percent=weekly_percent,
8280
+ five_hour_window_key=five_hour_window_key,
8281
+ five_hour_percent=five_hour_percent, as_of=as_of, ctx=ctx,
8282
+ account_key=account_key,
8283
+ # #750 S3: the raw journal id of the line this derivation came
8284
+ # from. The debounce ARM records it so a byte-identical replay of
8285
+ # the first zero cannot confirm itself.
8286
+ origin_observation_id=rec.get("id"),
8287
+ # The CAPTURE stamp, not `as_of`. It is what every
8288
+ # `effective_reset_at_utc` records, so live detection and the
8289
+ # backfill (which reads `weekly_usage_snapshots.captured_at_utc`)
8290
+ # agree about the same physical reset.
8291
+ capture_at=capture_at,
8292
+ # #769 S2 §3: the contributor discriminator for five-hour credit
8293
+ # confirmation. `payload.source`, never `rec["src"]`.
8294
+ source=source,
8295
+ hold_weekly_axis=hold_weekly_axis, outcomes_allowed=gate.allowed,
8296
+ fold_policy=fold_policy)
8006
8297
 
8007
8298
  # 2. Accept/skip DECISION (clamp + dedup), made ONCE and journaled via the
8008
8299
  # snapshot_accept evt (so replay never re-derives it — spec §5.3).