cctally 1.116.1 → 1.117.2

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 (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -42,7 +42,9 @@ import datetime as dt
42
42
  import json
43
43
  import sqlite3
44
44
  import sys
45
+ from dataclasses import dataclass
45
46
 
47
+ import _cctally_core # `weekly_held_exclusion`: the held-column guard (#836)
46
48
  import _lib_accounts # pure stdlib kernel; UNATTRIBUTED sentinel (#341)
47
49
  import _lib_rebuild_progress # #997 D9 progress ticks (stdlib only)
48
50
  from _lib_five_hour import latest_five_hour_reading
@@ -1085,13 +1087,57 @@ def _credit_aware_block_weekly_axes(conn, block, *, now_utc, cache=None):
1085
1087
  block.get("seven_day_pct_at_block_end"))
1086
1088
 
1087
1089
 
1090
+ def _weekly_retired_sql(conn, alias: str) -> "str | None":
1091
+ """The suppression-retired predicate over ``alias``, or None on a store
1092
+ without the held column (#836, spec 5.4).
1093
+
1094
+ A held WRITE stores ``"weeklyObservationHeld": true`` in ``payload_json``
1095
+ beside its effective basis value (`_cctally_record`, the held branch of
1096
+ the snapshot write). A weekly credit's cleanup instead turns an UNHELD row,
1097
+ whose payload never carried that marker, into a held one and leaves its
1098
+ stale `weekly_percent` in place (`_delete_doomed_snapshot_rows`, replay
1099
+ `_cctally_journal._retire_weekly_effects`). The marker therefore separates
1100
+ the two exactly. A store without the column cannot hold a held row, the
1101
+ reason `_cctally_core.weekly_held_exclusion` omits its own predicate there.
1102
+ """
1103
+ if not _cctally_core.weekly_held_exclusion(conn):
1104
+ return None
1105
+ return (f"({alias}.weekly_observation_held = 1 AND "
1106
+ f"json_extract({alias}.payload_json, '$.weeklyObservationHeld') "
1107
+ f"IS NOT 1)")
1108
+
1109
+
1110
+ def effective_weekly_value_sql(conn, alias: str = "s") -> str:
1111
+ """The joined snapshot's EFFECTIVE weekly value (#836, spec 5.4): NULL for
1112
+ a suppression-retired row, its stored `weekly_percent` otherwise. A
1113
+ clamped held write and a genuine climb-back keep their values; no
1114
+ percentage band is involved."""
1115
+ retired = _weekly_retired_sql(conn, alias)
1116
+ if retired is None:
1117
+ return f"{alias}.weekly_percent"
1118
+ return f"CASE WHEN {retired} THEN NULL ELSE {alias}.weekly_percent END"
1119
+
1120
+
1121
+ def weekly_join_unavailable_sql(conn, alias: str = "s") -> str:
1122
+ """A five-hour milestone's effective weekly join is UNAVAILABLE (#836,
1123
+ spec 5.4): the joined snapshot is absent — deleted, or the ``0`` sentinel
1124
+ `trg_usage_snapshot_suppression` writes — or suppression-retired. The
1125
+ shared predicate of the detail stamp and the dashboard's retired-join
1126
+ signature leg, over a ``LEFT JOIN`` aliased ``alias``."""
1127
+ retired = _weekly_retired_sql(conn, alias)
1128
+ if retired is None:
1129
+ return f"({alias}.id IS NULL)"
1130
+ return f"({alias}.id IS NULL OR {retired})"
1131
+
1132
+
1088
1133
  #: The six `five_hour_milestones` columns every surface already published, plus
1089
- #: the joined EFFECTIVE weekly value. Ordering is `captured_at_utc, id` — spec
1090
- #: §5.2's merged-stream order, which post-credit threshold repeats depend on.
1134
+ #: the joined EFFECTIVE weekly value (`effective_weekly_value_sql`, filled in
1135
+ #: as ``{effective}``). Ordering is `captured_at_utc, id` — spec §5.2's
1136
+ #: merged-stream order, which post-credit threshold repeats depend on.
1091
1137
  _FIVE_HOUR_MILESTONE_SQL = """
1092
1138
  SELECT m.percent_threshold, m.captured_at_utc, m.block_cost_usd,
1093
1139
  m.marginal_cost_usd, m.seven_day_pct_at_crossing, m.reset_event_id,
1094
- s.weekly_percent AS effective_seven_day_pct_at_crossing
1140
+ {effective} AS effective_seven_day_pct_at_crossing
1095
1141
  FROM five_hour_milestones m
1096
1142
  LEFT JOIN weekly_usage_snapshots s ON s.id = m.usage_snapshot_id
1097
1143
  WHERE {selector}
@@ -1138,6 +1184,12 @@ def _load_five_hour_milestones(
1138
1184
  store is corrected by `db rederive`'s reviewed `retract`. A missing joined
1139
1185
  snapshot still publishes None and never falls back to the raw value.
1140
1186
 
1187
+ **A suppression-retired snapshot publishes None** (#836, spec 5.4). The
1188
+ fold rejected its weekly value, and the cleanup kept the row only for its
1189
+ five-hour evidence; its provenance is exact (no held-write marker on a
1190
+ held row), so this is not the withdrawn band. See
1191
+ `effective_weekly_value_sql`.
1192
+
1141
1193
  SELECTOR. Exactly one of `block_id` or `five_hour_window_key` must be given.
1142
1194
  `block_id` is the precise form — `five_hour_milestones.block_id` names one
1143
1195
  block outright. The pair form filters on the window key AND the account,
@@ -1165,7 +1217,9 @@ def _load_five_hour_milestones(
1165
1217
  selector += " AND m.account_key = ?"
1166
1218
  params += (account_key,)
1167
1219
  rows = conn.execute(
1168
- _FIVE_HOUR_MILESTONE_SQL.format(selector=selector), params,
1220
+ _FIVE_HOUR_MILESTONE_SQL.format(
1221
+ selector=selector,
1222
+ effective=effective_weekly_value_sql(conn, "s")), params,
1169
1223
  ).fetchall()
1170
1224
  out: list[dict] = []
1171
1225
  for r in rows:
@@ -1779,9 +1833,359 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1779
1833
  conn.close()
1780
1834
 
1781
1835
 
1836
+ @dataclass(frozen=True)
1837
+ class OpenBlockEnd:
1838
+ """The reading an open five-hour block ends at (#956 class 1, spec
1839
+ 4.5.1): its capture, five-hour percent and weekly percent, as stored and
1840
+ fold-effective. ``origin`` names where it came from: ``"snapshot"`` is the
1841
+ window's latest surviving five-hour reading, ``"opening"`` the opening
1842
+ reading of the window's retained opening fact (`five_hour_block_openings`,
1843
+ #956 class 2, spec 4.5.4) when no reading survives."""
1844
+
1845
+ captured_at_utc: str
1846
+ five_hour_percent: float
1847
+ seven_day_percent: "float | None"
1848
+ origin: str
1849
+
1850
+
1851
+ def select_open_block_end(
1852
+ conn: sqlite3.Connection, *, account_key: str, five_hour_window_key: int,
1853
+ ) -> "OpenBlockEnd | None":
1854
+ """The end reading of one open block, read only (#956 class 1): the
1855
+ window's latest surviving five-hour reading in CAPTURE order, held rows
1856
+ included, ties broken by logical identity (`journal_id`, a NULL one
1857
+ lowest), never by journal order or rowid.
1858
+
1859
+ With no surviving reading (a correction removed every one, or `record-
1860
+ credit --force` removed the synthetics that were the window's only
1861
+ readings) it is the window's opening fact's OPENING reading (#956 spec
1862
+ 4.5.4): its capture, five-hour percent and weekly start value, retained
1863
+ and immutable, so live, every rebuild and rederive agree. ``None`` only
1864
+ for a legacy window with neither (residual R4); the caller then leaves
1865
+ the end columns as they are.
1866
+
1867
+ Live ingest, derivation completion and the rebuild's Phase 2a all read
1868
+ the end here, so an earlier capture appended later, a credit that
1869
+ suppresses the selected reading and a `record-credit` synthetic move
1870
+ every path identically. `latest_five_hour_reading` (the week-scoped
1871
+ selection forecast, statusline and the dashboard read) is deliberately
1872
+ a different function and is not changed."""
1873
+ # The order `unixepoch(captured_at_utc) DESC, journal_id DESC` (NULLs
1874
+ # lowest on both keys, as SQLite sorts them under DESC) is applied here
1875
+ # over the window's rows rather than by an ORDER BY: this runs on every
1876
+ # live tick, and the window-key index cannot serve an expression order,
1877
+ # so SQL would sort through a temp B-tree each time (#902's recurring-
1878
+ # plan guard). One five-hour window holds a bounded number of readings.
1879
+ rows = conn.execute(
1880
+ """
1881
+ SELECT unixepoch(captured_at_utc) AS captured_epoch, journal_id,
1882
+ captured_at_utc, five_hour_percent, weekly_percent
1883
+ FROM weekly_usage_snapshots
1884
+ WHERE account_key = ? AND five_hour_window_key = ?
1885
+ AND five_hour_percent IS NOT NULL
1886
+ """,
1887
+ (account_key, int(five_hour_window_key)),
1888
+ ).fetchall()
1889
+ if not rows:
1890
+ seed = conn.execute(
1891
+ "SELECT first_observed_at_utc, five_hour_percent, "
1892
+ " seven_day_pct_at_block_start "
1893
+ " FROM five_hour_block_openings "
1894
+ " WHERE account_key = ? AND five_hour_window_key = ?",
1895
+ (account_key, int(five_hour_window_key)),
1896
+ ).fetchone()
1897
+ if seed is None:
1898
+ return None
1899
+ return OpenBlockEnd(
1900
+ captured_at_utc=seed[0],
1901
+ five_hour_percent=float(seed[1]),
1902
+ seven_day_percent=None if seed[2] is None else float(seed[2]),
1903
+ origin="opening",
1904
+ )
1905
+ row = max(rows, key=lambda r: (
1906
+ r["captured_epoch"] is not None, r["captured_epoch"] or 0,
1907
+ r["journal_id"] is not None, r["journal_id"] or "",
1908
+ ))
1909
+ return OpenBlockEnd(
1910
+ captured_at_utc=row["captured_at_utc"],
1911
+ five_hour_percent=float(row["five_hour_percent"]),
1912
+ seven_day_percent=(
1913
+ None if row["weekly_percent"] is None
1914
+ else float(row["weekly_percent"])
1915
+ ),
1916
+ origin="snapshot",
1917
+ )
1918
+
1919
+
1920
+ def project_open_block_end(
1921
+ conn: sqlite3.Connection, block, end: "OpenBlockEnd | None", *,
1922
+ ownership, journal_ctx, as_of: str,
1923
+ ) -> None:
1924
+ """Project one OPEN block's end columns and accounting from ``end``
1925
+ (#956 class 1, spec 4.5.1), within the caller's transaction.
1926
+
1927
+ It sets `last_observed_at_utc`, `final_five_hour_percent` and
1928
+ `seven_day_pct_at_block_end` from ``end`` (left as they are when ``end``
1929
+ is None), and recomputes the four token totals, `total_cost_usd`, both
1930
+ child sets, `pricing_provenance_json` and `crossed_seven_day_reset` to
1931
+ the end's capture, the INCLUSIVE observation cutoff. Ownership is
1932
+ `_lib_blocks.resolve_owning_window` over ``ownership`` (OwnedWindow
1933
+ values of the account's block windows, the block's own included), with
1934
+ the half-open ``[start, reset)`` interval. With rederive's
1935
+ `block_source_evidence` sink present it records the owned entries behind
1936
+ these totals. It never touches start columns, never refreshes a closed
1937
+ block (a journaled fact), and stamps `last_updated_at_utc = as_of`.
1938
+
1939
+ Cache inputs are read with ``skip_sync=True``: this runs inside a stats
1940
+ transaction (and inside `open_db()` on the rebuild path), and the live
1941
+ tick's own totals computation already synced the cache."""
1942
+ _c = _cctally()
1943
+ current = conn.execute(
1944
+ "SELECT * FROM five_hour_blocks WHERE id = ?", (int(block["id"]),),
1945
+ ).fetchone()
1946
+ if current is None or int(current["is_closed"]) != 0:
1947
+ return
1948
+ account_key = current["account_key"]
1949
+ window_key = int(current["five_hour_window_key"])
1950
+ block_start_dt = parse_iso_datetime(
1951
+ current["block_start_at"], "five_hour_blocks.block_start_at",
1952
+ )
1953
+ if end is None:
1954
+ last_observed = current["last_observed_at_utc"]
1955
+ final_five_hour = current["final_five_hour_percent"]
1956
+ weekly_end = current["seven_day_pct_at_block_end"]
1957
+ else:
1958
+ last_observed = end.captured_at_utc
1959
+ final_five_hour = float(end.five_hour_percent)
1960
+ weekly_end = end.seven_day_percent
1961
+ cutoff_dt = parse_iso_datetime(
1962
+ last_observed, "five_hour_blocks.last_observed_at_utc",
1963
+ )
1964
+ windows = list(ownership)
1965
+ if not any(w.key == window_key for w in windows):
1966
+ windows.append(_c.OwnedWindow(
1967
+ key=window_key,
1968
+ start=block_start_dt.astimezone(dt.timezone.utc),
1969
+ reset=parse_iso_datetime(
1970
+ current["five_hour_resets_at"],
1971
+ "five_hour_blocks.five_hour_resets_at",
1972
+ ).astimezone(dt.timezone.utc),
1973
+ ))
1974
+ pricing_evidence: dict = {}
1975
+ source_sink = getattr(journal_ctx, "block_source_evidence", None)
1976
+ source_key = (account_key, window_key)
1977
+ owned_entries = (
1978
+ [] if source_sink is not None and source_key in source_sink else None
1979
+ )
1980
+ totals = _c._compute_block_totals(
1981
+ block_start_dt, cutoff_dt, skip_sync=True,
1982
+ owner_key=window_key, windows=windows,
1983
+ pricing_evidence=pricing_evidence, owned_entries=owned_entries,
1984
+ )
1985
+ if owned_entries is not None:
1986
+ source_sink[source_key] = tuple(owned_entries)
1987
+ # The reset-crossing flag over [block_start_at, end], from the same two
1988
+ # sources and predicates as the live sweep
1989
+ # (`_flag_blocks_spanning_week_resets`), computed exactly so an end that
1990
+ # moves backward clears it too.
1991
+ crossed = conn.execute(
1992
+ """
1993
+ SELECT
1994
+ EXISTS (
1995
+ SELECT 1 FROM week_reset_events
1996
+ WHERE account_key = ?
1997
+ AND unixepoch(effective_reset_at_utc)
1998
+ BETWEEN unixepoch(?) AND unixepoch(?)
1999
+ )
2000
+ OR EXISTS (
2001
+ SELECT 1 FROM weekly_usage_snapshots
2002
+ WHERE week_start_at IS NOT NULL
2003
+ AND account_key = ?
2004
+ AND unixepoch(week_start_at) > unixepoch(?)
2005
+ AND unixepoch(week_start_at) <= unixepoch(?)
2006
+ )
2007
+ """,
2008
+ (account_key, current["block_start_at"], last_observed,
2009
+ account_key, current["block_start_at"], last_observed),
2010
+ ).fetchone()[0]
2011
+ conn.execute(
2012
+ """
2013
+ UPDATE five_hour_blocks
2014
+ SET last_observed_at_utc = ?,
2015
+ final_five_hour_percent = ?,
2016
+ seven_day_pct_at_block_end = ?,
2017
+ total_input_tokens = ?,
2018
+ total_output_tokens = ?,
2019
+ total_cache_create_tokens = ?,
2020
+ total_cache_read_tokens = ?,
2021
+ total_cost_usd = ?,
2022
+ pricing_provenance_json = ?,
2023
+ crossed_seven_day_reset = ?,
2024
+ last_updated_at_utc = ?
2025
+ WHERE id = ? AND is_closed = 0
2026
+ """,
2027
+ (
2028
+ last_observed, final_five_hour, weekly_end,
2029
+ totals["input_tokens"], totals["output_tokens"],
2030
+ totals["cache_create_tokens"], totals["cache_read_tokens"],
2031
+ totals["cost_usd"],
2032
+ _c._block_pricing_provenance_json(pricing_evidence),
2033
+ 1 if crossed else 0, as_of, int(current["id"]),
2034
+ ),
2035
+ )
2036
+ # Replace-all children, keyed on the window (not block_id) so an orphan
2037
+ # child of an earlier parent rebuild is cleaned up as well.
2038
+ block_id = int(current["id"])
2039
+ conn.execute(
2040
+ "DELETE FROM five_hour_block_models "
2041
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2042
+ (window_key, account_key),
2043
+ )
2044
+ if totals.get("by_model"):
2045
+ conn.executemany(
2046
+ """
2047
+ INSERT INTO five_hour_block_models (
2048
+ block_id, five_hour_window_key, model,
2049
+ input_tokens, output_tokens,
2050
+ cache_create_tokens, cache_read_tokens,
2051
+ cost_usd, entry_count, account_key
2052
+ )
2053
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2054
+ """,
2055
+ [
2056
+ (
2057
+ block_id, window_key, model,
2058
+ b["input_tokens"], b["output_tokens"],
2059
+ b["cache_create_tokens"], b["cache_read_tokens"],
2060
+ b["cost_usd"], b["entry_count"], account_key,
2061
+ )
2062
+ for model, b in totals["by_model"].items()
2063
+ ],
2064
+ )
2065
+ conn.execute(
2066
+ "DELETE FROM five_hour_block_projects "
2067
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2068
+ (window_key, account_key),
2069
+ )
2070
+ if totals.get("by_project"):
2071
+ conn.executemany(
2072
+ """
2073
+ INSERT INTO five_hour_block_projects (
2074
+ block_id, five_hour_window_key, project_path,
2075
+ input_tokens, output_tokens,
2076
+ cache_create_tokens, cache_read_tokens,
2077
+ cost_usd, entry_count, account_key
2078
+ )
2079
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2080
+ """,
2081
+ [
2082
+ (
2083
+ block_id, window_key, project_path,
2084
+ b["input_tokens"], b["output_tokens"],
2085
+ b["cache_create_tokens"], b["cache_read_tokens"],
2086
+ b["cost_usd"], b["entry_count"], account_key,
2087
+ )
2088
+ for project_path, b in totals["by_project"].items()
2089
+ ],
2090
+ )
2091
+
2092
+
2093
+ def _materialize_open_candidate(conn, key, acct, start_row) -> int:
2094
+ """Insert one rebuild open-block candidate with its start columns from
2095
+ ``start_row`` (the first writer, else the earliest survivor) and its end
2096
+ from `select_open_block_end`; zero totals and NULL provenance until
2097
+ `_refresh_every_open_block` projects it in the same transaction. Returns
2098
+ the inserted row count."""
2099
+ end = select_open_block_end(
2100
+ conn, account_key=acct, five_hour_window_key=key)
2101
+ if start_row is None or end is None:
2102
+ return 0 # defensive — should be unreachable per the keys query
2103
+ resets_at = start_row["five_hour_resets_at"]
2104
+ block_start_at = (
2105
+ parse_iso_datetime(resets_at, "resets_at backfill")
2106
+ - dt.timedelta(hours=5)
2107
+ ).isoformat(timespec="seconds")
2108
+ # A rebuild's only-missing row is the trailing unjournaled projection,
2109
+ # never a close decision. Wall time may be past its reset, but only a
2110
+ # later retained observation may close and clock it (#399).
2111
+ cur = conn.execute(
2112
+ """
2113
+ INSERT OR IGNORE INTO five_hour_blocks (
2114
+ five_hour_window_key, five_hour_resets_at, block_start_at,
2115
+ first_observed_at_utc, last_observed_at_utc,
2116
+ final_five_hour_percent,
2117
+ seven_day_pct_at_block_start, seven_day_pct_at_block_end,
2118
+ crossed_seven_day_reset,
2119
+ total_input_tokens, total_output_tokens,
2120
+ total_cache_create_tokens, total_cache_read_tokens,
2121
+ total_cost_usd, pricing_provenance_json, is_closed,
2122
+ created_at_utc, last_updated_at_utc, account_key
2123
+ )
2124
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, NULL, 0, ?, ?, ?)
2125
+ """,
2126
+ (
2127
+ key, resets_at, block_start_at,
2128
+ start_row["captured_at_utc"], end.captured_at_utc,
2129
+ end.five_hour_percent,
2130
+ start_row["weekly_percent"], end.seven_day_percent,
2131
+ start_row["captured_at_utc"], end.captured_at_utc, acct,
2132
+ ),
2133
+ )
2134
+ return cur.rowcount or 0
2135
+
2136
+
2137
+ def _refresh_every_open_block(conn) -> int:
2138
+ """Project every open block through `select_open_block_end` /
2139
+ `project_open_block_end` (#956 class 1, spec 4.5.3), with each account's
2140
+ ownership context built from every `five_hour_blocks` row as it stands
2141
+ now. Runs inside the caller's transaction. Returns the blocks refreshed.
2142
+ A block whose window has no surviving reading keeps its end columns, and
2143
+ its accounting is recomputed to its stored `last_observed_at_utc`."""
2144
+ _c = _cctally()
2145
+ ownership: dict = {}
2146
+ for row in conn.execute(
2147
+ "SELECT five_hour_window_key, block_start_at, "
2148
+ " five_hour_resets_at, account_key FROM five_hour_blocks"
2149
+ ).fetchall():
2150
+ try:
2151
+ w_start = parse_iso_datetime(
2152
+ row["block_start_at"], "five_hour_blocks.block_start_at",
2153
+ ).astimezone(dt.timezone.utc)
2154
+ w_reset = parse_iso_datetime(
2155
+ row["five_hour_resets_at"],
2156
+ "five_hour_blocks.five_hour_resets_at",
2157
+ ).astimezone(dt.timezone.utc)
2158
+ except ValueError:
2159
+ continue
2160
+ ownership.setdefault(row["account_key"], []).append(_c.OwnedWindow(
2161
+ key=int(row["five_hour_window_key"]), start=w_start,
2162
+ reset=w_reset,
2163
+ ))
2164
+ refreshed = 0
2165
+ for index, block in enumerate(conn.execute(
2166
+ "SELECT id, account_key, five_hour_window_key, last_updated_at_utc "
2167
+ " FROM five_hour_blocks WHERE is_closed = 0"
2168
+ ).fetchall(), 1):
2169
+ if not index & 1023:
2170
+ _lib_rebuild_progress.tick(blocks=index)
2171
+ end = select_open_block_end(
2172
+ conn, account_key=block["account_key"],
2173
+ five_hour_window_key=int(block["five_hour_window_key"]))
2174
+ project_open_block_end(
2175
+ conn, block, end,
2176
+ ownership=ownership.get(block["account_key"], []),
2177
+ journal_ctx=None,
2178
+ as_of=(end.captured_at_utc if end is not None
2179
+ else block["last_updated_at_utc"]),
2180
+ )
2181
+ refreshed += 1
2182
+ return refreshed
2183
+
2184
+
1782
2185
  def _backfill_five_hour_blocks(
1783
2186
  conn: sqlite3.Connection, *, only_missing: bool = False,
1784
2187
  journaled_first: "dict | None" = None,
2188
+ retired_openings: frozenset = frozenset(),
1785
2189
  ) -> int:
1786
2190
  """One-shot historical backfill of five_hour_blocks from existing
1787
2191
  weekly_usage_snapshots data. Idempotent via UNIQUE(five_hour_window_key)
@@ -1834,6 +2238,26 @@ def _backfill_five_hour_blocks(
1834
2238
  ordinary (legacy, pre-journal) backfill keeps NULL: its blocks predate
1835
2239
  provenance, which `_lib_cost_provenance.historical_close_marker` reads
1836
2240
  as unproven.
2241
+
2242
+ #956 class 1 (spec 4.5.3): with ``only_missing`` the end columns and the
2243
+ accounting come from the one capture-order projection live ingest uses
2244
+ (`select_open_block_end` / `project_open_block_end`), not from a
2245
+ backfill-local end row. The pass materializes the missing candidates
2246
+ (start columns only, as above), then builds each account's ownership
2247
+ context from EVERY block row after that materialization, and refreshes
2248
+ EVERY open block, not only those it created: a block another path
2249
+ materialized is refreshed against the same window set. The candidate
2250
+ ranking breaks equal-second ties by `journal_id` (logical identity), never
2251
+ by rowid, which a journal correction can reorder.
2252
+
2253
+ #956 class 2 (spec 4.6.3): a window created by a current binary has a
2254
+ retained opening fact whose fold already materialized its block, so it
2255
+ is never a candidate here and its start columns are the seed's. The
2256
+ candidates are the LEGACY open blocks (created before opening facts, R4):
2257
+ each account's latest-captured window with no block row, except a window
2258
+ in ``retired_openings`` — the `(account_key, five_hour_window_key)` pairs
2259
+ whose opening fact a correction tombstoned (`_cctally_journal.
2260
+ retired_block_openings`): its block was removed, never "never seeded".
1837
2261
  """
1838
2262
  _c = _cctally()
1839
2263
  inserted = 0
@@ -1884,7 +2308,7 @@ def _backfill_five_hour_blocks(
1884
2308
  " SELECT id, ROW_NUMBER() OVER ("
1885
2309
  " PARTITION BY account_key"
1886
2310
  " ORDER BY unixepoch(captured_at_utc) DESC,"
1887
- " id DESC"
2311
+ " journal_id DESC"
1888
2312
  " ) AS rn"
1889
2313
  " FROM weekly_usage_snapshots"
1890
2314
  " WHERE five_hour_window_key IS NOT NULL"
@@ -1899,6 +2323,9 @@ def _backfill_five_hour_blocks(
1899
2323
  " )"
1900
2324
  )
1901
2325
  keys = [(int(r[0]), r[1]) for r in conn.execute(keys_sql).fetchall()]
2326
+ if only_missing and retired_openings:
2327
+ keys = [(key, acct) for key, acct in keys
2328
+ if (acct, key) not in retired_openings]
1902
2329
 
1903
2330
  now_iso = now_utc_iso()
1904
2331
  now_dt = parse_iso_datetime(now_iso, "now")
@@ -1911,10 +2338,10 @@ def _backfill_five_hour_blocks(
1911
2338
  # pair re-prices its overlap twice.
1912
2339
  ownership: dict[Any, list] = {}
1913
2340
  seen_keys: set = set()
1914
- for row in conn.execute(
2341
+ for row in ([] if only_missing else conn.execute(
1915
2342
  "SELECT five_hour_window_key, block_start_at, "
1916
2343
  " five_hour_resets_at, account_key FROM five_hour_blocks"
1917
- ).fetchall():
2344
+ ).fetchall()):
1918
2345
  try:
1919
2346
  w_start = parse_iso_datetime(
1920
2347
  row["block_start_at"], "five_hour_blocks.block_start_at",
@@ -1930,7 +2357,7 @@ def _backfill_five_hour_blocks(
1930
2357
  ownership.setdefault(row["account_key"], []).append(
1931
2358
  _c.OwnedWindow(key=w_key, start=w_start, reset=w_reset)
1932
2359
  )
1933
- for key, acct in keys:
2360
+ for key, acct in ([] if only_missing else keys):
1934
2361
  if (acct, key) in seen_keys:
1935
2362
  continue
1936
2363
  # Same start-row pick the insert loop makes below, so the
@@ -1973,6 +2400,10 @@ def _backfill_five_hour_blocks(
1973
2400
  # (window_key, account_key) so a shared window's per-account
1974
2401
  # blocks each read their own account's boundary rows (#341).
1975
2402
  min_row = _first_row(key, acct)
2403
+ if only_missing:
2404
+ inserted += _materialize_open_candidate(
2405
+ conn, key, acct, min_row)
2406
+ continue
1976
2407
  max_row = conn.execute(
1977
2408
  """
1978
2409
  SELECT captured_at_utc, weekly_percent, five_hour_percent
@@ -2038,26 +2469,16 @@ def _backfill_five_hour_blocks(
2038
2469
  # reset, but only a later retained observation may close and
2039
2470
  # clock it (#399). The ordinary legacy backfill keeps its
2040
2471
  # historical wall-time classification.
2041
- is_closed = (
2042
- 0 if only_missing else (1 if resets_dt < now_dt else 0)
2043
- )
2044
- projection_created_at = first_obs if only_missing else now_iso
2045
- projection_updated_at = last_obs if only_missing else now_iso
2472
+ is_closed = 1 if resets_dt < now_dt else 0
2046
2473
 
2047
2474
  # Token + cost totals — recomputed via the shared helper,
2048
2475
  # which routes through get_entries() and falls back to
2049
2476
  # JSONL if cache.db is unreadable.
2050
2477
  # skip_sync=True: backfill runs inside open_db(); a sync_cache
2051
2478
  # cascade here would reopen cache.db recursively.
2052
- pricing_evidence = {} if only_missing else None
2053
2479
  totals = _c._compute_block_totals(
2054
2480
  block_start_dt, last_obs_dt, skip_sync=True,
2055
2481
  owner_key=key, windows=ownership.get(acct, []),
2056
- pricing_evidence=pricing_evidence,
2057
- )
2058
- pricing_provenance_json = (
2059
- _c._block_pricing_provenance_json(pricing_evidence)
2060
- if pricing_evidence is not None else None
2061
2482
  )
2062
2483
 
2063
2484
  cur = conn.execute(
@@ -2101,10 +2522,10 @@ def _backfill_five_hour_blocks(
2101
2522
  totals["cache_create_tokens"],
2102
2523
  totals["cache_read_tokens"],
2103
2524
  totals["cost_usd"],
2104
- pricing_provenance_json,
2525
+ None,
2105
2526
  is_closed,
2106
- projection_created_at,
2107
- projection_updated_at,
2527
+ now_iso,
2528
+ now_iso,
2108
2529
  acct,
2109
2530
  ),
2110
2531
  )
@@ -2176,6 +2597,9 @@ def _backfill_five_hour_blocks(
2176
2597
  ],
2177
2598
  )
2178
2599
 
2600
+ if only_missing:
2601
+ _refresh_every_open_block(conn)
2602
+
2179
2603
  # Mark child-table migrations done so the upgrade-user gates in
2180
2604
  # open_db() don't re-fire on next open. Inside the same
2181
2605
  # transaction as the per-window inserts so partial-fail leaves