cctally 1.105.0 → 1.106.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 (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_core.py +151 -20
  3. package/bin/_cctally_dashboard.py +20 -227
  4. package/bin/_cctally_dashboard_conversation.py +38 -0
  5. package/bin/_cctally_dashboard_envelope.py +79 -10
  6. package/bin/_cctally_dashboard_share.py +7 -1
  7. package/bin/_cctally_db.py +267 -5
  8. package/bin/_cctally_doctor.py +40 -15
  9. package/bin/_cctally_five_hour.py +53 -6
  10. package/bin/_cctally_forecast.py +197 -42
  11. package/bin/_cctally_journal.py +217 -27
  12. package/bin/_cctally_milestone_history.py +141 -58
  13. package/bin/_cctally_milestones.py +72 -0
  14. package/bin/_cctally_parser.py +10 -6
  15. package/bin/_cctally_percent_breakdown.py +26 -20
  16. package/bin/_cctally_project.py +5 -3
  17. package/bin/_cctally_quota.py +12 -2
  18. package/bin/_cctally_quota_model.py +12 -4
  19. package/bin/_cctally_record.py +1254 -300
  20. package/bin/_cctally_reporting.py +19 -0
  21. package/bin/_cctally_share.py +21 -3
  22. package/bin/_cctally_statusline.py +15 -11
  23. package/bin/_cctally_tui.py +192 -42
  24. package/bin/_cctally_weekrefs.py +322 -161
  25. package/bin/_lib_credit.py +9 -2
  26. package/bin/_lib_credit_identity.py +159 -0
  27. package/bin/_lib_credit_selection.py +342 -0
  28. package/bin/_lib_dashboard_sources.py +16 -4
  29. package/bin/_lib_doctor.py +1 -1
  30. package/bin/_lib_ingest_frontier.py +46 -0
  31. package/bin/_lib_quota_model.py +34 -28
  32. package/bin/_lib_record.py +41 -9
  33. package/bin/_lib_rederive.py +8 -0
  34. package/bin/_lib_render.py +72 -4
  35. package/bin/_lib_snapshot_cache.py +13 -10
  36. package/bin/_lib_subscription_weeks.py +141 -54
  37. package/bin/_lib_view_models.py +128 -22
  38. package/bin/cctally +7 -0
  39. package/dashboard/static/assets/{ConversationsView-DA4j7Yso.js → ConversationsView-Dst33C0n.js} +13 -13
  40. package/dashboard/static/assets/{DoctorModal-D4PbVnbE.js → DoctorModal-5rvkyksy.js} +1 -1
  41. package/dashboard/static/assets/ModalRoot-BJH02VzE.js +1 -0
  42. package/dashboard/static/assets/{ProjectsDrillPanel-DkPlB9JH.js → ProjectsDrillPanel-CsR1bjJu.js} +1 -1
  43. package/dashboard/static/assets/{SourceDetailModal-BE7FONsS.js → SourceDetailModal-BnTIwt-R.js} +1 -1
  44. package/dashboard/static/assets/{UpdateModal-BJuI8glf.js → UpdateModal-D_RfssnW.js} +3 -3
  45. package/dashboard/static/assets/index-rzXw99Hy.js +13 -0
  46. package/dashboard/static/assets/index-u3wLfjt2.css +1 -0
  47. package/dashboard/static/assets/{outlineNavigation-zDVm6Hdd.js → outlineNavigation-C7J9lhGB.js} +1 -1
  48. package/dashboard/static/assets/useKeymap-B3KCc_c7.js +1 -0
  49. package/dashboard/static/dashboard.html +3 -3
  50. package/package.json +3 -1
  51. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +0 -1
  52. package/dashboard/static/assets/index-klO46NcU.css +0 -1
  53. package/dashboard/static/assets/index-w_bINkJ8.js +0 -13
  54. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +0 -1
@@ -1326,6 +1326,30 @@ def _forecast_quota_envelope(fc, cw, rate_change):
1326
1326
  }
1327
1327
 
1328
1328
 
1329
+ def _trend_withheld_key(row) -> dict:
1330
+ """The `dollar_per_pct_withheld` key for one trend row, or no key at all.
1331
+
1332
+ #703 + #707 §6.3. `build_trend_view` resolves the cause per week, and both
1333
+ trend arrays feed a `$/1%` column — ``weeks`` the panel's, ``history`` the
1334
+ modal's. Without the cause the column renders the em-dash that reads as "no
1335
+ usage recorded", which is a different and wrong statement about a week that
1336
+ holds a credit.
1337
+
1338
+ The key is OMITTED rather than published as null when nothing is withheld,
1339
+ the way the session rows' ``title`` key is. The client's
1340
+ ``withheldDollarPerPctLabel`` treats an absent key and a null one
1341
+ identically, a week that withholds nothing is the overwhelming majority,
1342
+ and publishing the null on all twenty rows of the two arrays would move
1343
+ every committed dashboard golden and the corpus-wide envelope oracle to
1344
+ carry a value no consumer can distinguish from its absence.
1345
+
1346
+ ``getattr`` tolerates the minimal trend shapes — SimpleNamespace test rows,
1347
+ older fixtures — that the sibling ``cost_usd`` key already tolerates.
1348
+ """
1349
+ cause = getattr(row, "dpp_withheld_cause", None)
1350
+ return {} if cause is None else {"dollar_per_pct_withheld": cause}
1351
+
1352
+
1329
1353
  def _header_rate_and_delta(current_rate, current_week_start, trend):
1330
1354
  """`(dollar_per_pct, vs_last_week_delta)` from ONE current operand.
1331
1355
 
@@ -1595,22 +1619,39 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1595
1619
  week_lbl: "str | None" = None
1596
1620
  reset_at_utc: "dt.datetime | None" = None
1597
1621
  week_start_at_utc: "dt.datetime | None" = None
1622
+ nominal_week_start_at_utc: "dt.datetime | None" = None
1598
1623
  if cw is not None:
1599
1624
  ws = getattr(cw, "week_start_at", None)
1600
1625
  we = getattr(cw, "week_end_at", None)
1626
+ # #703 + #707 §6.1/§6.4 — the LABEL names the week, not the credit.
1627
+ # `week_start_at` is the rate anchor and moves to the credit instant on
1628
+ # a credited week, which is intended for the `$/1%` numerator and wrong
1629
+ # for the window a person is shown: a credit moves no boundary.
1630
+ # Labelling from it made the hero read `WEEK USAGE · Apr 16–Apr 20` over
1631
+ # a week running Apr 13–Apr 20, over milestone rows dated before Apr 16,
1632
+ # and against the credit tooltip's own promise that the week keeps its
1633
+ # own boundaries.
1634
+ #
1635
+ # `displayed_week_start_at` falls back to the rate anchor, so legacy and
1636
+ # fixture snapshots that construct `TuiCurrentWeek` without the field
1637
+ # render exactly what they rendered before.
1638
+ from _lib_view_models import displayed_week_start_at
1639
+ ws_label = displayed_week_start_at(cw)
1601
1640
  # Full window "Apr 13–20" so the dashboard matches the TUI/report
1602
1641
  # headers and doesn't hide month crossings (e.g. "Apr 27–May 03").
1603
1642
  # En-dash U+2013 matches the TUI format at cmd_tui.
1604
- if ws is not None and we is not None:
1643
+ if ws_label is not None and we is not None:
1605
1644
  week_lbl = (
1606
- f"{format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)}"
1645
+ f"{format_display_dt(ws_label, resolved_tz_obj, fmt='%b %d', suffix=False)}"
1607
1646
  f"–"
1608
1647
  f"{format_display_dt(we, resolved_tz_obj, fmt='%b %d', suffix=False)}"
1609
1648
  )
1610
- elif ws is not None:
1611
- week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
1649
+ elif ws_label is not None:
1650
+ week_lbl = format_display_dt(
1651
+ ws_label, resolved_tz_obj, fmt='%b %d', suffix=False)
1612
1652
  reset_at_utc = we
1613
1653
  week_start_at_utc = ws
1654
+ nominal_week_start_at_utc = ws_label
1614
1655
 
1615
1656
  # Header forecast_pct should match the projection that drove the
1616
1657
  # verdict pill next to it. The View (issue #57) carries the
@@ -1648,6 +1689,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1648
1689
  "models": list(r.models),
1649
1690
  "week_start_at": r.week_start_at,
1650
1691
  "week_end_at": r.week_end_at,
1692
+ # #703 + #707 §6.4/§6.6: additive, so no `schemaVersion` bump. The
1693
+ # week no longer splits, so `credited` is what explains a low
1694
+ # percent late in a heavy week; the withheld cause distinguishes
1695
+ # "the epoch supports no divisor" from "no usage recorded", which a
1696
+ # bare null cannot.
1697
+ "credited": r.credited,
1698
+ "dollar_per_pct_withheld": r.dollar_per_pct_withheld_cause,
1651
1699
  }
1652
1700
 
1653
1701
  def _monthly_row_to_dict(r: "MonthlyPeriodRow") -> dict:
@@ -2045,13 +2093,32 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2045
2093
  # serializing, the same way `five_hour_block` does.
2046
2094
  "total_tokens": getattr(cw, "total_tokens", 0),
2047
2095
  "dollar_per_pct": cw.dollars_per_percent,
2048
- # #556 S1 §3.5 — the effective cycle start, the companion of
2049
- # the already-published `reset_at_utc` end. Composition needs
2050
- # BOTH bounds to label the Claude leg's period, and the source
2051
- # version needs them to detect a nominal rollover (§3.6).
2052
- # Effective, not nominal: `_tui_build_current_week` stores this
2053
- # AFTER `_apply_midweek_reset_override`.
2096
+ # #703 + #707 §6.3 — why the ratio above is null on a credited
2097
+ # week. Additive and optional, the same shape the weekly rows
2098
+ # publish, so the two surfaces on one screen state one cause.
2099
+ # Without it the hero rendered `$0.000` beside `spent $0.00`,
2100
+ # which is the misleading figure the withholding rule exists to
2101
+ # replace. `getattr` keeps legacy fixture modules that construct
2102
+ # `TuiCurrentWeek` without the field serializing.
2103
+ "dollar_per_pct_withheld": getattr(
2104
+ cw, "dpp_withheld_cause", None),
2105
+ # #556 S1 §3.5/§3.6 — the RATE cycle start, the companion of
2106
+ # the already-published `reset_at_utc` end.
2107
+ # `_tui_build_current_week` stores this AFTER
2108
+ # `_apply_midweek_reset_override`, so on a credited week it is
2109
+ # the credit instant. The source version detects a nominal
2110
+ # rollover from it (§3.6), and the Projects panel and its drill
2111
+ # anchor their grid on it (#620).
2054
2112
  "week_start_at": _iso_z(week_start_at_utc),
2113
+ # #703 + #707 §6.2 — the week's OWN start, which is the range
2114
+ # `spent_usd` above covers. Published because composition labels
2115
+ # the Claude leg's period over exactly the spend that leg
2116
+ # reports, and `week_start_at` stopped being that bound when
2117
+ # spend moved back to the full week: a leg built from it named a
2118
+ # four-day window `Claude subscription week` and reported a full
2119
+ # week's spend inside it. Additive and optional, so an older
2120
+ # client and an older reader both keep working.
2121
+ "nominal_week_start_at": _iso_z(nominal_week_start_at_utc),
2055
2122
  "reset_at_utc": _iso_z(reset_at_utc),
2056
2123
  "reset_in_sec":
2057
2124
  None if reset_at_utc is None
@@ -2137,6 +2204,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2137
2204
  # that predate this nullable field. ``None`` when the
2138
2205
  # week has no cost snapshot.
2139
2206
  "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
2207
+ **_trend_withheld_key(w),
2140
2208
  }
2141
2209
  for w in snap.trend
2142
2210
  ],
@@ -2151,6 +2219,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2151
2219
  # #264 S3: additive weekly cost (see weeks[] above; same
2152
2220
  # getattr tolerance for minimal/older trend shapes).
2153
2221
  "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
2222
+ **_trend_withheld_key(w),
2154
2223
  }
2155
2224
  for w in (snap.weekly_history or [])
2156
2225
  ],
@@ -720,7 +720,13 @@ def _build_current_week_share_panel_data(options: dict,
720
720
  "week_start_date": _share_now_utc().strftime("%Y-%m-%d"),
721
721
  "display_tz": options.get("display_tz", "Etc/UTC"),
722
722
  }
723
- week_start = getattr(cw, "week_start_at", None)
723
+ # #703 + #707 §6.2 — the week's OWN start, not the rate anchor. It dates
724
+ # this card, clips `daily_progression`, and bounds `top_projects`, and all
725
+ # three describe `kpi_cost_usd`, which is the whole week's spend. Anchoring
726
+ # them at a credit instant would date the card at the credit while reporting
727
+ # the week's money, and would decompose that money over part of its range.
728
+ from _lib_view_models import displayed_week_start_at
729
+ week_start = displayed_week_start_at(cw)
724
730
  week_end = getattr(cw, "week_end_at", None)
725
731
  week_start_date = (
726
732
  week_start.strftime("%Y-%m-%d") if isinstance(week_start, dt.datetime)
@@ -190,6 +190,132 @@ def add_column_if_missing(
190
190
  return True
191
191
 
192
192
 
193
+ # --------------------------------------------------------------------------
194
+ # week_reset_events reshape for the unified credit record (#703 + #707)
195
+ # --------------------------------------------------------------------------
196
+ #
197
+ # The unified credit record changed `week_reset_events` in three ways that
198
+ # `CREATE TABLE IF NOT EXISTS` cannot deliver to a store whose table already
199
+ # exists: six fact columns were added, both boundary columns became nullable,
200
+ # and the row constraint moved from the boundary pair to
201
+ # `(account_key, credit_key)`. Only a rename-recreate-copy applies all three.
202
+ #
203
+ # The epoch bump alone covers ONE of the two upgrade paths. A store above
204
+ # `LEGACY_STATS_HEAD` defers to a rebuild that materializes a fresh scratch
205
+ # index through the current DDL, so it arrives correct. A PRE-CUTOVER store at
206
+ # `user_version <= 13` takes the other path: the schema apply is a no-op
207
+ # against the table that is already there, the cutover stamps the new epoch in
208
+ # place, and every later open fast-returns at the epoch gate before any schema
209
+ # work runs. Without this reshape such a store REPORTS the current epoch while
210
+ # missing every fact column and keeping both NOT NULL boundaries — a state with
211
+ # no path back, in which the manual credit fold, the automatic fold and every
212
+ # fact read raise.
213
+ #
214
+ # `add_column_if_missing` is not sufficient on its own, because it cannot relax
215
+ # a NOT NULL constraint or replace a UNIQUE, and the manual fold inserts NULL
216
+ # into both boundary columns.
217
+ #
218
+ # Split in two so the canonical DDL has exactly one copy — the one in
219
+ # `_cctally_core.open_db`. `begin_week_reset_events_reshape` renames the legacy
220
+ # table out of the way; the caller's own `CREATE TABLE IF NOT EXISTS` then
221
+ # builds the current shape; `finish_week_reset_events_reshape` copies the rows
222
+ # across and drops the legacy table. `sqlite_master.sql` for the recreated
223
+ # table is therefore byte-identical to a freshly created one, which the rebuild
224
+ # validator's schema fingerprint requires.
225
+
226
+ #: Where the pre-reshape table is parked between the two halves. A leftover
227
+ #: under this name means a previous attempt crashed after the rename; the next
228
+ #: open resumes from it rather than starting over.
229
+ WEEK_RESET_EVENTS_RESHAPE_OLD = "week_reset_events_pre_1012"
230
+
231
+ #: Presence of these decides whether a reshape is needed. They arrived together
232
+ #: with the nullability and constraint changes, so any store missing them is a
233
+ #: store that predates all three.
234
+ _WEEK_RESET_EVENTS_FACT_COLUMNS = frozenset({
235
+ "week_start_date", "observed_at_utc", "confirming_capture_at_utc",
236
+ "observed_post_credit_pct", "credit_key", "credit_order",
237
+ })
238
+
239
+
240
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
241
+ return conn.execute(
242
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
243
+ ).fetchone() is not None
244
+
245
+
246
+ def begin_week_reset_events_reshape(conn: sqlite3.Connection) -> bool:
247
+ """Park a pre-#703 `week_reset_events` aside; return whether one is parked.
248
+
249
+ Returns True when the caller must call `finish_week_reset_events_reshape`
250
+ after creating the current-shape table — either because this call renamed a
251
+ legacy table, or because a previous attempt already did and crashed before
252
+ copying.
253
+ """
254
+ if _table_exists(conn, WEEK_RESET_EVENTS_RESHAPE_OLD):
255
+ return True
256
+ if not _table_exists(conn, "week_reset_events"):
257
+ return False
258
+ cols = {
259
+ str(r[1])
260
+ for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
261
+ }
262
+ if _WEEK_RESET_EVENTS_FACT_COLUMNS <= cols:
263
+ return False
264
+ conn.execute(
265
+ f"ALTER TABLE week_reset_events "
266
+ f"RENAME TO {WEEK_RESET_EVENTS_RESHAPE_OLD}"
267
+ )
268
+ return True
269
+
270
+
271
+ def finish_week_reset_events_reshape(conn: sqlite3.Connection) -> None:
272
+ """Copy the parked rows into the current-shape table and drop the parked one.
273
+
274
+ `id` is copied explicitly. `percent_milestones.reset_event_id` names a
275
+ credit by that identifier and the foreign keys in this codebase are
276
+ documentation-only, so letting AUTOINCREMENT renumber the rows would
277
+ silently re-point every milestone at a different credit with nothing
278
+ raising.
279
+
280
+ Columns present only on the legacy table are dropped and columns present
281
+ only on the new one arrive NULL, which is the honest value: NULL on a fact
282
+ column means the row predates the change, exactly as it does for a legacy
283
+ `wr:` event replayed on the rebuild path.
284
+
285
+ The parked table's indexes followed it through the rename and still hold
286
+ the `idx_week_reset_events_*` names, so `DROP TABLE` here is what frees
287
+ those names for the caller's own `CREATE INDEX IF NOT EXISTS` to rebuild
288
+ them against the new table. Dropping them any later would leave the new
289
+ table unindexed while the statements that would have built them no-op.
290
+ """
291
+ if not _table_exists(conn, WEEK_RESET_EVENTS_RESHAPE_OLD):
292
+ return
293
+ old_cols = [
294
+ str(r[1])
295
+ for r in conn.execute(
296
+ f"PRAGMA table_info({WEEK_RESET_EVENTS_RESHAPE_OLD})").fetchall()
297
+ ]
298
+ new_cols = {
299
+ str(r[1])
300
+ for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
301
+ }
302
+ shared = [name for name in old_cols if name in new_cols]
303
+ projection = ", ".join(f'"{name}"' for name in shared)
304
+ if conn.in_transaction:
305
+ conn.commit()
306
+ conn.execute("BEGIN IMMEDIATE")
307
+ try:
308
+ conn.execute(
309
+ f"INSERT INTO week_reset_events ({projection}) "
310
+ f"SELECT {projection} FROM {WEEK_RESET_EVENTS_RESHAPE_OLD}"
311
+ )
312
+ conn.execute(f"DROP TABLE {WEEK_RESET_EVENTS_RESHAPE_OLD}")
313
+ conn.commit()
314
+ except Exception:
315
+ conn.rollback()
316
+ raise
317
+
318
+
193
319
  # === Region 2: Migration framework + dispatcher (was bin/cctally:10952-11229) ===
194
320
 
195
321
  _MIGRATION_NAME_RE = re.compile(r"^\d{3}_[a-z0-9_]+$")
@@ -3381,6 +3507,13 @@ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
3381
3507
  switches from ``round(weekly_percent,1) = round(?,1)`` to
3382
3508
  ``ABS(weekly_percent - ?) < 1.0``.
3383
3509
 
3510
+ That companion predicate is HISTORY, not current behaviour: #703 + #707
3511
+ replaced the tolerance band with the evidence rules in
3512
+ ``bin/_lib_credit_selection.py``, because the band compared the stored
3513
+ percent against a remembered level rather than against evidence. The column
3514
+ this migration adds is still written and still read; only the predicate that
3515
+ consumed it changed.
3516
+
3384
3517
  Idempotent: a second invocation finds the column already present
3385
3518
  and returns. Empty-column fast path: when the live CREATE TABLE
3386
3519
  already carries the column (fresh install), return without an ALTER
@@ -4206,10 +4339,18 @@ def _apply_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
4206
4339
  class SchemaDeliveryObject(NamedTuple):
4207
4340
  """One re-derivable schema object declared by a version-gated apply.
4208
4341
 
4209
- ``kind`` is one of ``table``, ``view``, ``trigger`` or ``index``. The two
4210
- store registries and their structural tests cover every explicit object of
4211
- those kinds, including virtual tables while excluding SQLite-created FTS
4212
- shadow objects.
4342
+ ``kind`` is one of ``table``, ``view``, ``trigger``, ``index`` or
4343
+ ``column``. The two store registries and their structural tests cover
4344
+ every explicit object of those kinds, including virtual tables while
4345
+ excluding SQLite-created FTS shadow objects.
4346
+
4347
+ A ``column`` record is named ``<table>.<column>`` and covers exactly the
4348
+ columns a schema apply declares with ``add_column_if_missing``. Those
4349
+ are the columns whose delivery depends on the version gate, because a
4350
+ store created from scratch receives them from the CREATE TABLE body
4351
+ instead. #682 added two such declarations with no companion migration,
4352
+ the head never moved, and every already-current store kept the old table
4353
+ shape while the shipped code assumed the new one.
4213
4354
 
4214
4355
  ``ensure_helper`` names the shared module-level function that owns the DDL,
4215
4356
  or is ``None`` when the statement is written inline in the schema body.
@@ -4243,6 +4384,42 @@ class SchemaDeliveryObject(NamedTuple):
4243
4384
  #: identity so even a same-kind remove-one/add-one substitution fails.
4244
4385
  CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4245
4386
  # ── frozen baseline: audited objects without handler-owned delivery DDL ──
4387
+ # Columns predating this registry's column kind. Every one was verified
4388
+ # present in a real production cache.db stamped at head 44, so the
4389
+ # baseline is an observation rather than an assumption. Several arrived
4390
+ # alongside a migration that backfilled their VALUES, but no handler owns
4391
+ # the ALTER itself, so naming one here would fail the delivery assertion.
4392
+ SchemaDeliveryObject("column", "codex_session_entries.account_key", None, None),
4393
+ SchemaDeliveryObject("column", "codex_session_entries.conversation_key", None, None),
4394
+ SchemaDeliveryObject("column", "codex_session_entries.source_root_key", None, None),
4395
+ SchemaDeliveryObject("column", "codex_session_files.account_key", None, None),
4396
+ SchemaDeliveryObject("column", "codex_session_files.last_conversation_key", None, None),
4397
+ SchemaDeliveryObject("column", "codex_session_files.last_native_thread_id", None, None),
4398
+ SchemaDeliveryObject("column", "codex_session_files.last_parent_thread_id", None, None),
4399
+ SchemaDeliveryObject("column", "codex_session_files.last_root_thread_id", None, None),
4400
+ SchemaDeliveryObject("column", "codex_session_files.last_turn_id", None, None),
4401
+ SchemaDeliveryObject("column", "codex_session_files.source_root_key", None, None),
4402
+ SchemaDeliveryObject("column", "conversation_messages.attribution_plugin", None, None),
4403
+ SchemaDeliveryObject("column", "conversation_messages.attribution_skill", None, None),
4404
+ SchemaDeliveryObject("column", "conversation_messages.search_thinking", None, None),
4405
+ SchemaDeliveryObject("column", "conversation_messages.search_tool", None, None),
4406
+ SchemaDeliveryObject("column", "conversation_messages.source_tool_use_id", None, None),
4407
+ SchemaDeliveryObject("column", "conversation_messages.stop_reason", None, None),
4408
+ SchemaDeliveryObject("column", "conversation_sessions.git_branch", None, None),
4409
+ SchemaDeliveryObject("column", "conversation_sessions.models_json", None, None),
4410
+ SchemaDeliveryObject("column", "conversation_sessions.title", None, None),
4411
+ SchemaDeliveryObject("column", "quota_window_snapshots.account_key", None, None),
4412
+ SchemaDeliveryObject("column", "quota_window_snapshots.canonical_resets_at_utc", None, None),
4413
+ SchemaDeliveryObject("column", "quota_window_snapshots.observed_model", None, None),
4414
+ SchemaDeliveryObject("column", "session_entries.account_key", None, None),
4415
+ SchemaDeliveryObject("column", "session_entries.cache_create_1h_tokens", None, None),
4416
+ SchemaDeliveryObject("column", "session_entries.cache_create_5m_tokens", None, None),
4417
+ SchemaDeliveryObject("column", "session_entries.mutation_min_ts", None, None),
4418
+ SchemaDeliveryObject("column", "session_entries.mutation_seq", None, None),
4419
+ SchemaDeliveryObject("column", "session_entries.speed", None, None),
4420
+ SchemaDeliveryObject("column", "session_files.account_key", None, None),
4421
+ SchemaDeliveryObject("column", "session_files.project_path", None, None),
4422
+ SchemaDeliveryObject("column", "session_files.session_id", None, None),
4246
4423
  SchemaDeliveryObject("index", "idx_codex_conv_msgs_conversation", None, None),
4247
4424
  SchemaDeliveryObject("index", "idx_codex_conv_msgs_source", None, None),
4248
4425
  SchemaDeliveryObject("index", "idx_codex_conv_rollups_recent", None, None),
@@ -4301,6 +4478,15 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4301
4478
  SchemaDeliveryObject("trigger", "conv_title_fts_ai", None, None),
4302
4479
  SchemaDeliveryObject("trigger", "conv_title_fts_au", None, None),
4303
4480
  # ── post-baseline: each migration owns the object's delivery DDL ──
4481
+ SchemaDeliveryObject(
4482
+ "column", "codex_session_files.ingest_complete", None,
4483
+ "038_codex_session_files_ingest_complete"),
4484
+ SchemaDeliveryObject(
4485
+ "column", "codex_conversation_rollups.render_revision", None,
4486
+ "045_conversation_render_revision_columns"),
4487
+ SchemaDeliveryObject(
4488
+ "column", "conversation_sessions.render_revision", None,
4489
+ "045_conversation_render_revision_columns"),
4304
4490
  SchemaDeliveryObject(
4305
4491
  "index", "idx_entries_physical", None,
4306
4492
  "020_session_entries_physical_unique"),
@@ -5368,6 +5554,22 @@ def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
5368
5554
  #: projection is the one audited family with handler-owned delivery DDL.
5369
5555
  CONVERSATIONS_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
5370
5556
  # ── frozen baseline: audited objects without handler-owned delivery DDL ──
5557
+ # Same column-kind baseline rule as the cache registry above. This store is
5558
+ # projected from the cache schema, so most of these are the same physical
5559
+ # declarations scoped to the tables conversations.db actually carries; all
5560
+ # twelve were verified present in a real production conversations.db.
5561
+ SchemaDeliveryObject("column", "codex_conversation_events.account_key", None, None),
5562
+ SchemaDeliveryObject("column", "codex_conversation_messages.account_key", None, None),
5563
+ SchemaDeliveryObject("column", "conversation_messages.account_key", None, None),
5564
+ SchemaDeliveryObject("column", "conversation_messages.attribution_plugin", None, None),
5565
+ SchemaDeliveryObject("column", "conversation_messages.attribution_skill", None, None),
5566
+ SchemaDeliveryObject("column", "conversation_messages.search_thinking", None, None),
5567
+ SchemaDeliveryObject("column", "conversation_messages.search_tool", None, None),
5568
+ SchemaDeliveryObject("column", "conversation_messages.source_tool_use_id", None, None),
5569
+ SchemaDeliveryObject("column", "conversation_messages.stop_reason", None, None),
5570
+ SchemaDeliveryObject("column", "conversation_sessions.git_branch", None, None),
5571
+ SchemaDeliveryObject("column", "conversation_sessions.models_json", None, None),
5572
+ SchemaDeliveryObject("column", "conversation_sessions.title", None, None),
5371
5573
  SchemaDeliveryObject(
5372
5574
  "index", "idx_codex_conv_messages_account_conversation", None, None),
5373
5575
  SchemaDeliveryObject("index", "idx_codex_conv_msgs_conversation", None, None),
@@ -5411,7 +5613,13 @@ CONVERSATIONS_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
5411
5613
  SchemaDeliveryObject("trigger", "conv_title_fts_ad", None, None),
5412
5614
  SchemaDeliveryObject("trigger", "conv_title_fts_ai", None, None),
5413
5615
  SchemaDeliveryObject("trigger", "conv_title_fts_au", None, None),
5414
- # ── post-baseline: migration 004 owns the shared projection helper ──
5616
+ # ── post-baseline: each migration owns the object's delivery DDL ──
5617
+ SchemaDeliveryObject(
5618
+ "column", "codex_conversation_rollups.render_revision", None,
5619
+ "008_conversation_render_revision"),
5620
+ SchemaDeliveryObject(
5621
+ "column", "conversation_sessions.render_revision", None,
5622
+ "008_conversation_render_revision"),
5415
5623
  SchemaDeliveryObject(
5416
5624
  "index", "idx_codex_find_projection_conversation_order",
5417
5625
  "_apply_codex_find_projection_schema", "004_codex_find_projection"),
@@ -8485,6 +8693,60 @@ def _044_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
8485
8693
  conn.commit()
8486
8694
 
8487
8695
 
8696
+ @cache_migration("045_conversation_render_revision_columns")
8697
+ def _045_conversation_render_revision_columns(conn: sqlite3.Connection) -> None:
8698
+ """Deliver #682's two ``render_revision`` columns to an existing store.
8699
+
8700
+ ``_apply_cache_schema`` already declares both columns with
8701
+ ``add_column_if_missing``, so a store created from scratch has them. That
8702
+ schema pass is VERSION-GATED though: ``open_cache_db`` runs it only when
8703
+ ``PRAGMA user_version`` differs from ``len(_CACHE_MIGRATIONS)``. #682
8704
+ shipped the two declarations with no companion migration, so the head never
8705
+ moved, an already-current store never re-ran the apply, and the columns
8706
+ reached new installs only. Registering here bumps the head, which is the
8707
+ same mechanism migrations 029, 031 and 044 rely on and is stated there in
8708
+ the same words.
8709
+
8710
+ The consequence on an unmigrated store was total rather than partial:
8711
+ ``_recompute_conversation_sessions`` runs
8712
+ ``UPDATE conversation_sessions SET render_revision=?`` on every sync, that
8713
+ statement cannot be prepared against the old shape, and so every
8714
+ ``sync_cache`` failed with ``no such column: render_revision`` while the
8715
+ dashboard showed a permanent ``server sync error``.
8716
+
8717
+ No backfill is needed. ``render_revision`` is a monotonic invalidation
8718
+ frontier whose whole purpose is to force re-assembly, and the DEFAULT 0 the
8719
+ column arrives with is already the value that marks every existing row as
8720
+ needing one. The rollup recompute chokepoints advance it from there.
8721
+
8722
+ Both tables are checked for existence first, because their absence is a
8723
+ reachable state rather than a corrupt one: cache migration 028 removes
8724
+ the legacy transcript objects, and ``open_cache_db`` recreates them only
8725
+ afterwards. ``add_column_if_missing`` raises on a missing table, so an
8726
+ unguarded ALTER here would turn that ordinary state into a migration
8727
+ failure with a rendered error banner. Skipping is correct rather than
8728
+ merely safe: a table created later is created by the schema apply, whose
8729
+ CREATE TABLE body already carries the column.
8730
+
8731
+ The ``add_column_if_missing`` pair below is a defensive re-assert, not the
8732
+ primary delivery path, exactly as migration 031 re-asserts its two tables:
8733
+ it keeps the handler self-contained if the schema apply's ordering ever
8734
+ drifts, it is what the per-migration golden exercises, and it is what
8735
+ ``tests/test_cache_schema_delivery.py`` matches against this migration's
8736
+ two registry records. Re-running is a no-op. NO self-stamp — the dispatcher
8737
+ central-stamps on a clean return (#140).
8738
+ """
8739
+ if _table_exists(conn, "conversation_sessions"):
8740
+ add_column_if_missing(
8741
+ conn, "conversation_sessions", "render_revision",
8742
+ "INTEGER NOT NULL DEFAULT 0")
8743
+ if _table_exists(conn, "codex_conversation_rollups"):
8744
+ add_column_if_missing(
8745
+ conn, "codex_conversation_rollups", "render_revision",
8746
+ "INTEGER NOT NULL DEFAULT 0")
8747
+ conn.commit()
8748
+
8749
+
8488
8750
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
8489
8751
 
8490
8752
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -1399,31 +1399,56 @@ def _doctor_gather_state_impl(
1399
1399
  # post_credit_milestone_count == 0.
1400
1400
  # unixepoch() normalizes the cross-offset comparison.
1401
1401
  try:
1402
+ # #703 + #707 §6.2: select by the canonical WEEK and the
1403
+ # ACCOUNT, not by `new_week_end_at`. A manual credit
1404
+ # leaves both boundary columns NULL, so the boundary
1405
+ # join found no snapshot and every `record-credit` week
1406
+ # was silently skipped by this check. The instant is the
1407
+ # accounting one, matching every other accounting read.
1402
1408
  credit_rows = conn.execute(
1403
1409
  """
1404
1410
  SELECT wre.id AS event_id,
1405
- wre.new_week_end_at AS end_at,
1406
- wre.effective_reset_at_utc AS effective
1411
+ wre.week_start_date AS week_start_date,
1412
+ wre.account_key AS account_key,
1413
+ wre.new_week_end_at AS end_at
1407
1414
  FROM week_reset_events wre
1408
- WHERE unixepoch(wre.effective_reset_at_utc)
1415
+ WHERE unixepoch(COALESCE(wre.observed_at_utc,
1416
+ wre.effective_reset_at_utc))
1409
1417
  <= unixepoch(?)
1410
1418
  """,
1411
1419
  (now_utc_iso(),),
1412
1420
  ).fetchall()
1413
1421
  credited_weeks = []
1414
1422
  for cr in credit_rows:
1415
- end_at = cr[1]
1416
- evt_id = cr[0]
1417
- latest = conn.execute(
1418
- """
1419
- SELECT week_start_date, weekly_percent
1420
- FROM weekly_usage_snapshots
1421
- WHERE week_end_at = ?
1422
- ORDER BY captured_at_utc DESC, id DESC
1423
- LIMIT 1
1424
- """,
1425
- (end_at,),
1426
- ).fetchone()
1423
+ evt_id = cr["event_id"]
1424
+ # A row that predates `week_start_date` carries only
1425
+ # the boundary, so that join is the fallback for
1426
+ # exactly those rows and nothing else.
1427
+ if cr["week_start_date"]:
1428
+ latest = conn.execute(
1429
+ """
1430
+ SELECT week_start_date, weekly_percent
1431
+ FROM weekly_usage_snapshots
1432
+ WHERE week_start_date = ?
1433
+ AND account_key = ?
1434
+ ORDER BY captured_at_utc DESC, id DESC
1435
+ LIMIT 1
1436
+ """,
1437
+ (cr["week_start_date"], cr["account_key"]),
1438
+ ).fetchone()
1439
+ elif cr["end_at"]:
1440
+ latest = conn.execute(
1441
+ """
1442
+ SELECT week_start_date, weekly_percent
1443
+ FROM weekly_usage_snapshots
1444
+ WHERE week_end_at = ?
1445
+ ORDER BY captured_at_utc DESC, id DESC
1446
+ LIMIT 1
1447
+ """,
1448
+ (cr["end_at"],),
1449
+ ).fetchone()
1450
+ else:
1451
+ latest = None
1427
1452
  if latest is None or latest[0] is None:
1428
1453
  continue
1429
1454
  ws = latest[0]
@@ -351,13 +351,52 @@ def _load_recorded_five_hour_windows(
351
351
  # rendered as a phantom heuristic "~" row).
352
352
  credit_moments: list[dt.datetime] = []
353
353
  try:
354
+ # #703 + #707 §6.2: EVERY credit contributes a discontinuity
355
+ # moment, so the `old_week_end_at = effective_reset_at_utc`
356
+ # filter is gone. It selected only the automatic in-place shape,
357
+ # and a MANUAL credit leaves both boundary columns NULL — so a
358
+ # `record-credit` week produced no moment here at all and its
359
+ # overlapping canonical blocks still resolved by dropping one,
360
+ # leaving the phantom heuristic row this code exists to remove.
361
+ # The instant is the ACCOUNTING one, matching every other
362
+ # accounting read.
363
+ #
364
+ # This scan is account-BLIND, and deliberately stays so — but
365
+ # not for the reason first recorded here. That reason said no
366
+ # caller holds an account key, and it is false: the statusline
367
+ # resolves `_statusline_active_account()` and scopes its 7d
368
+ # clamp legs to it in the same render, then calls this function
369
+ # without one. The decision stands on the other two grounds. The
370
+ # canonical-block query above is account-blind, and so are the
371
+ # session entries these moments disambiguate, because `blocks`
372
+ # is a ccusage drop-in with no account axis at all. Scoping only
373
+ # this leg would make the truncation moments disagree with the
374
+ # anchors and the entries they are truncating; scoping the
375
+ # subsystem is a change to `blocks` itself.
376
+ #
377
+ # Two consequences are stated rather than left implicit. The
378
+ # widened selection admits manual credits and other accounts'
379
+ # credits as block-truncation points. And a moment can only be
380
+ # used when it falls in `[next_bs, rs]` for a canonical pair
381
+ # inside `[range_start, range_end]`; since a stored block start
382
+ # is at most one block duration before its own reset, every
383
+ # usable moment lies in `[range_start - BLOCK_DURATION,
384
+ # range_end]`, so the scan is bounded to exactly that and stops
385
+ # loading every credit ever recorded.
386
+ credit_scan_start = (
387
+ range_start - _c.BLOCK_DURATION).isoformat()
388
+ credit_scan_end = range_end.isoformat()
354
389
  credit_rows = conn.execute(
355
- "SELECT effective_reset_at_utc "
356
- "FROM week_reset_events "
357
- "WHERE old_week_end_at = effective_reset_at_utc"
390
+ "SELECT COALESCE(observed_at_utc, effective_reset_at_utc) "
391
+ " AS accounting_at "
392
+ " FROM week_reset_events "
393
+ " WHERE accounting_at IS NOT NULL "
394
+ " AND unixepoch(accounting_at) >= unixepoch(?) "
395
+ " AND unixepoch(accounting_at) <= unixepoch(?)",
396
+ (credit_scan_start, credit_scan_end),
358
397
  ).fetchall()
359
398
  for c in credit_rows:
360
- raw = c["effective_reset_at_utc"]
399
+ raw = c["accounting_at"]
361
400
  try:
362
401
  d = dt.datetime.fromisoformat(str(raw))
363
402
  except ValueError:
@@ -1647,12 +1686,20 @@ def _backfill_five_hour_blocks(
1647
1686
  # suffixes (block_start_at is host-local; week_start_at /
1648
1687
  # effective_reset_at_utc are ``+00:00``); see the live-path
1649
1688
  # comment for rationale.
1689
+ # #703 + #707: the ACCOUNTING instant, so a block is flagged as
1690
+ # crossing the moment the counter actually moved rather than the
1691
+ # hour it is displayed at — the floored instant can fall in the
1692
+ # previous block.
1650
1693
  cross_row = conn.execute(
1651
1694
  """
1652
1695
  SELECT 1 FROM week_reset_events
1653
1696
  WHERE account_key = ?
1654
- AND unixepoch(effective_reset_at_utc) >= unixepoch(?)
1655
- AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
1697
+ AND unixepoch(COALESCE(observed_at_utc,
1698
+ effective_reset_at_utc))
1699
+ >= unixepoch(?)
1700
+ AND unixepoch(COALESCE(observed_at_utc,
1701
+ effective_reset_at_utc))
1702
+ <= unixepoch(?)
1656
1703
  LIMIT 1
1657
1704
  """,
1658
1705
  (acct, block_start_at, last_obs),