cctally 1.96.2 → 1.98.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.
@@ -55,6 +55,8 @@ from _cctally_core import (
55
55
  )
56
56
  from _lib_dashboard_sources import (
57
57
  SOURCE_SCHEMA_VERSION,
58
+ canonical_alerted_at as _canonical_alerted_at,
59
+ canonical_alerted_at_sql as _canonical_alerted_at_sql,
58
60
  dashboard_resource_key as _dashboard_resource_key,
59
61
  )
60
62
  from _lib_display_tz import _compute_display_block, format_display_dt
@@ -361,6 +363,16 @@ def _alert_account_resolver(conn: sqlite3.Connection):
361
363
  return fields
362
364
 
363
365
 
366
+ # #556 S3 §2.3. Every per-axis mapper orders by the CANONICAL firing instant
367
+ # before its ``LIMIT``, because that limit decides which rows exist downstream
368
+ # and a row excluded there is unrecoverable. Two spellings of one instant
369
+ # compare equal here; a nonzero offset — which the ``all-combined`` fixture
370
+ # stores deliberately — orders by its true instant rather than by its
371
+ # local-time text.
372
+ _CANON_ALERTED_AT = _canonical_alerted_at_sql()
373
+ _CANON_ALERTED_AT_M = _canonical_alerted_at_sql("m.alerted_at")
374
+
375
+
364
376
  def _envelope_rows_weekly(
365
377
  conn, descriptor, limit, severity_for, account_fields,
366
378
  ) -> list[dict]:
@@ -377,7 +389,7 @@ def _envelope_rows_weekly(
377
389
  alerted_at, cumulative_cost_usd, reset_event_id, account_key
378
390
  FROM {descriptor.milestone_table}
379
391
  WHERE alerted_at IS NOT NULL
380
- ORDER BY alerted_at DESC
392
+ ORDER BY {_CANON_ALERTED_AT} DESC
381
393
  LIMIT ?
382
394
  """,
383
395
  (limit,),
@@ -431,7 +443,7 @@ def _envelope_rows_five_hour(
431
443
  ON b.five_hour_window_key = m.five_hour_window_key
432
444
  AND b.account_key = m.account_key
433
445
  WHERE m.alerted_at IS NOT NULL
434
- ORDER BY m.alerted_at DESC
446
+ ORDER BY {_CANON_ALERTED_AT_M} DESC
435
447
  LIMIT ?
436
448
  """,
437
449
  (limit,),
@@ -506,7 +518,7 @@ def _envelope_rows_budget_family(
506
518
  budget_usd, spent_usd, consumption_pct, account_key
507
519
  FROM {descriptor.milestone_table}
508
520
  WHERE vendor = ? AND alerted_at IS NOT NULL
509
- ORDER BY alerted_at DESC
521
+ ORDER BY {_CANON_ALERTED_AT} DESC
510
522
  LIMIT ?
511
523
  """,
512
524
  (default_noun, vendor, limit),
@@ -572,7 +584,7 @@ def _envelope_rows_projected(
572
584
  denominator, crossed_at_utc, alerted_at, account_key
573
585
  FROM {descriptor.milestone_table}
574
586
  WHERE alerted_at IS NOT NULL
575
- ORDER BY alerted_at DESC
587
+ ORDER BY {_CANON_ALERTED_AT} DESC
576
588
  LIMIT ?
577
589
  """,
578
590
  (limit,),
@@ -629,7 +641,7 @@ def _envelope_rows_project_budget(
629
641
  consumption_pct, crossed_at_utc, alerted_at, account_key
630
642
  FROM {descriptor.milestone_table}
631
643
  WHERE alerted_at IS NOT NULL
632
- ORDER BY alerted_at DESC
644
+ ORDER BY {_CANON_ALERTED_AT} DESC
633
645
  LIMIT ?
634
646
  """,
635
647
  (limit,),
@@ -858,11 +870,29 @@ def _build_alerts_envelope_array(
858
870
  ))
859
871
 
860
872
  # Python's list.sort is stable. When two alerts share the same
861
- # `alerted_at` ISO string (rare; multiple axes firing within the same
862
- # millisecond), the union order (weekly, then 5h, then budget, then
873
+ # `alerted_at` instant (rare; multiple axes firing within the same
874
+ # second), the union order (weekly, then 5h, then budget, then
863
875
  # projected) determines the tiebreaker — no extra deterministic key is
864
876
  # added because the spec doesn't require one.
865
- out.sort(key=lambda a: a["alerted_at"], reverse=True)
877
+ #
878
+ # #556 S3 §2.3: the slice below is a truncation, so this re-sort compares
879
+ # instants rather than spellings. Each axis reaches here already ordered by
880
+ # its own canonical SQL expression, but the axes are merged raw, so two
881
+ # differently-spelled instants meet for the first time right here.
882
+ def _order_key(alert):
883
+ # A raise here empties the whole legacy array AND the Claude projection
884
+ # derived from it, so the diagnostic must name the offending row the way
885
+ # `_combined_alert_rows` does — otherwise the operator sees an empty
886
+ # panel and a message that identifies nothing.
887
+ try:
888
+ return _canonical_alerted_at(alert["alerted_at"])
889
+ except (KeyError, ValueError) as exc:
890
+ identity = alert.get("key") or alert.get("id")
891
+ raise ValueError(
892
+ f"alert row {identity!r} (axis {alert.get('axis')!r}): {exc}"
893
+ ) from exc
894
+
895
+ out.sort(key=_order_key, reverse=True)
866
896
  return out[:limit]
867
897
 
868
898
 
@@ -1188,6 +1218,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1188
1218
 
1189
1219
  week_lbl: "str | None" = None
1190
1220
  reset_at_utc: "dt.datetime | None" = None
1221
+ week_start_at_utc: "dt.datetime | None" = None
1191
1222
  if cw is not None:
1192
1223
  ws = getattr(cw, "week_start_at", None)
1193
1224
  we = getattr(cw, "week_end_at", None)
@@ -1203,6 +1234,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1203
1234
  elif ws is not None:
1204
1235
  week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
1205
1236
  reset_at_utc = we
1237
+ week_start_at_utc = ws
1206
1238
 
1207
1239
  # Header forecast_pct should match the projection that drove the
1208
1240
  # verdict pill next to it. The View (issue #57) carries the
@@ -1268,21 +1300,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1268
1300
  }
1269
1301
 
1270
1302
  def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
1271
- return {
1272
- "date": r.date,
1273
- "label": r.label,
1274
- "cost_usd": r.cost_usd,
1275
- "is_today": r.is_today,
1276
- "intensity_bucket": r.intensity_bucket,
1277
- "models": list(r.models),
1278
- # ---- v2.3 additions ----
1279
- "input_tokens": r.input_tokens,
1280
- "output_tokens": r.output_tokens,
1281
- "cache_creation_tokens": r.cache_creation_tokens,
1282
- "cache_read_tokens": r.cache_read_tokens,
1283
- "total_tokens": r.total_tokens,
1284
- "cache_hit_pct": r.cache_hit_pct,
1285
- }
1303
+ # #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
1304
+ # `periods.daily_aggregate.rows` sibling publishes the same shape, and
1305
+ # the client reads one shape whichever sibling produced it, so the
1306
+ # dict is built in `_cctally_dashboard` and this renderer delegates.
1307
+ return sys.modules["_cctally_dashboard"].daily_panel_row_to_wire(r)
1286
1308
 
1287
1309
  # Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
1288
1310
  # Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
@@ -1616,7 +1638,19 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1616
1638
  None if cw.five_hour_resets_at is None
1617
1639
  else max(0, int((cw.five_hour_resets_at - now_utc).total_seconds())),
1618
1640
  "spent_usd": cw.spent_usd,
1641
+ # #556 S1 §3.3 — the token half of the same accumulation pass
1642
+ # that produced `spent_usd`. `getattr` keeps legacy fixture
1643
+ # modules that construct `TuiCurrentWeek` without the field
1644
+ # serializing, the same way `five_hour_block` does.
1645
+ "total_tokens": getattr(cw, "total_tokens", 0),
1619
1646
  "dollar_per_pct": cw.dollars_per_percent,
1647
+ # #556 S1 §3.5 — the effective cycle start, the companion of
1648
+ # the already-published `reset_at_utc` end. Composition needs
1649
+ # BOTH bounds to label the Claude leg's period, and the source
1650
+ # version needs them to detect a nominal rollover (§3.6).
1651
+ # Effective, not nominal: `_tui_build_current_week` stores this
1652
+ # AFTER `_apply_midweek_reset_override`.
1653
+ "week_start_at": _iso_z(week_start_at_utc),
1620
1654
  "reset_at_utc": _iso_z(reset_at_utc),
1621
1655
  "reset_in_sec":
1622
1656
  None if reset_at_utc is None
@@ -50,7 +50,6 @@ from _cctally_config import save_config, _load_config_unlocked
50
50
  from _lib_fmt import stable_sum
51
51
  from _lib_pricing import _calculate_entry_cost, claude_usage_dict
52
52
  from _lib_five_hour import _canonical_5h_window_key
53
- from _lib_dashboard_sources import source_domain_freshness
54
53
  from _lib_display_tz import _resolve_tz, resolve_display_tz_name
55
54
 
56
55
 
@@ -1783,12 +1782,33 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1783
1782
  )
1784
1783
 
1785
1784
 
1785
+ def _share_current_week_evidence_is_stale(state) -> bool:
1786
+ """Whether ONE provider's own current-cycle evidence is stale (#556 §4.7).
1787
+
1788
+ This used to read the shared ``domain_freshness.hero`` axis, which #556 S1
1789
+ repointed to accounting resolvability. The aggregate ``quota`` axis is not
1790
+ the substitute: a stale five-hour row stales it independently of the weekly
1791
+ cycle this note describes. So each provider is read on its own field —
1792
+ Claude's percent-observation label under ``hero.current_week.freshness``,
1793
+ Codex's additive ``hero.cycle_freshness``, which is omitted while fresh.
1794
+ """
1795
+ data = getattr(state, "data", None)
1796
+ hero = data.get("hero") if isinstance(data, Mapping) else None
1797
+ if not isinstance(hero, Mapping):
1798
+ return False
1799
+ if getattr(state, "source", None) == "claude":
1800
+ current_week = hero.get("current_week")
1801
+ freshness = (
1802
+ current_week.get("freshness")
1803
+ if isinstance(current_week, Mapping) else None
1804
+ )
1805
+ return isinstance(freshness, Mapping) and freshness.get("label") == "stale"
1806
+ return hero.get("cycle_freshness") == "stale"
1807
+
1808
+
1786
1809
  def _share_apply_current_week_freshness(snapshot, state, panel: str):
1787
1810
  """Qualify retained current-week actuals with provider-local evidence age."""
1788
- if (
1789
- panel != "current-week"
1790
- or source_domain_freshness(state, "hero") != "stale"
1791
- ):
1811
+ if panel != "current-week" or not _share_current_week_evidence_is_stale(state):
1792
1812
  return snapshot
1793
1813
  provider = "Claude" if state.source == "claude" else "Codex"
1794
1814
  note = (
@@ -37,6 +37,8 @@ from _lib_dashboard_sources import (
37
37
  SourceDashboardState,
38
38
  SourceDashboardWarning,
39
39
  assess_codex_projection_coherence,
40
+ canonical_alerted_at,
41
+ canonical_alerted_at_sql,
40
42
  dashboard_resource_key,
41
43
  )
42
44
  from _lib_quota import (
@@ -860,6 +862,33 @@ _RESOURCE_ROWS = {
860
862
  "block": ("quota", "blocks"),
861
863
  }
862
864
 
865
+ # #556 S2: additional collections a resource may ALSO be routed through.
866
+ #
867
+ # The primary above stays the capability gate — its absence is still
868
+ # `SourceCapabilityUnavailable`. These are searched only after the primary
869
+ # misses, and their own absence is an ordinary not-found rather than a
870
+ # capability failure, because a provider legitimately need not publish them (a
871
+ # Codex source has no aggregate sibling, and a Claude source whose bounded fold
872
+ # failed publishes none either).
873
+ #
874
+ # Projects needs one because `projects.rows` is the current SUBSCRIPTION WEEK
875
+ # while `projects.aggregate.rows` is folded over the thirty-day shared range.
876
+ # Without this the aggregate ranking would publish rows the drill-down route
877
+ # answers 404 for — in the committed `all-combined` fixture, four of six.
878
+ _RESOURCE_EXTRA_ROWS: Mapping[str, tuple[tuple[str, ...], ...]] = MappingProxyType({
879
+ "project": (("projects", "aggregate", "rows"),),
880
+ })
881
+
882
+
883
+ def _rows_at(data: Mapping, path: "tuple[str, ...]") -> "list | tuple":
884
+ """Read one optional nested rows collection, or an empty tuple."""
885
+ node: object = data
886
+ for step in path:
887
+ if not isinstance(node, Mapping):
888
+ return ()
889
+ node = node.get(step)
890
+ return node if isinstance(node, (list, tuple)) else ()
891
+
863
892
 
864
893
  def _public_copy(value: object) -> object:
865
894
  """Detach a bounded source row from its immutable published state."""
@@ -910,6 +939,21 @@ def source_detail_lookup(
910
939
  row for row in rows
911
940
  if isinstance(row, Mapping) and row.get("key") == key
912
941
  ]
942
+ if not key_matches:
943
+ # Only after the primary collection misses, so every key that resolves
944
+ # today keeps resolving to the same row. The account-ownership branch
945
+ # below does NOT see an unchanged candidate set — a key that resolves
946
+ # only here reaches it as a row the primary collection never carried.
947
+ # That leaks nothing, because an aggregate row carries no
948
+ # `account_key` and the ownership check refuses a row it cannot
949
+ # attribute, but the set is genuinely wider than it was.
950
+ for path in _RESOURCE_EXTRA_ROWS.get(resource, ()):
951
+ key_matches = [
952
+ row for row in _rows_at(data, path)
953
+ if isinstance(row, Mapping) and row.get("key") == key
954
+ ]
955
+ if key_matches:
956
+ break
913
957
  if not key_matches:
914
958
  raise SourceResourceNotFound()
915
959
  if account is not None:
@@ -1261,6 +1305,24 @@ def _codex_cache_report_wire(
1261
1305
  )
1262
1306
 
1263
1307
 
1308
+ #: Per-file terminal thread aliases, joined to their first accounting entry.
1309
+ #: The ``(source_root_key, source_path)`` join predicate needs the composite
1310
+ #: ``idx_codex_entries_root_path`` to stay linear: a single-column root index
1311
+ #: cannot discriminate when every rollout resolves to one provider root, so the
1312
+ #: join degenerates to files x entries. Module-level so the query-plan
1313
+ #: regression asserts THIS text rather than a copy that can drift from it.
1314
+ _CODEX_FILE_ALIAS_SQL = (
1315
+ "SELECT f.source_root_key, f.path, f.last_native_thread_id, "
1316
+ "f.last_session_id, MIN(e.timestamp_utc) "
1317
+ "FROM codex_session_files AS f "
1318
+ "LEFT JOIN codex_session_entries AS e "
1319
+ "ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
1320
+ "WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
1321
+ "GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
1322
+ "ORDER BY f.last_ingested_at DESC, f.path DESC"
1323
+ )
1324
+
1325
+
1264
1326
  def _codex_conversation_metadata(
1265
1327
  cache_conn: sqlite3.Connection,
1266
1328
  ) -> dict[tuple[str, str], dict[str, object]]:
@@ -1302,16 +1364,7 @@ def _codex_conversation_metadata(
1302
1364
  cwd, git_json, first_seen_at, _last_seen_at,
1303
1365
  ) in core_rows
1304
1366
  )
1305
- file_aliases = tuple(cache_conn.execute(
1306
- "SELECT f.source_root_key, f.path, f.last_native_thread_id, "
1307
- "f.last_session_id, MIN(e.timestamp_utc) "
1308
- "FROM codex_session_files AS f "
1309
- "LEFT JOIN codex_session_entries AS e "
1310
- "ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
1311
- "WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
1312
- "GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
1313
- "ORDER BY f.last_ingested_at DESC, f.path DESC"
1314
- ))
1367
+ file_aliases = tuple(cache_conn.execute(_CODEX_FILE_ALIAS_SQL))
1315
1368
  native_ids = tuple(sorted({
1316
1369
  str(native_thread_id) for _, _, native_thread_id, *_ in rows
1317
1370
  if isinstance(native_thread_id, str) and native_thread_id
@@ -2393,6 +2446,11 @@ def refresh_codex_source_clock(
2393
2446
  "hero",
2394
2447
  ),)
2395
2448
  availability = "partial"
2449
+ # #556 S1 §4.1: an EXPIRED boundary is exactly the state the
2450
+ # accounting axis reports as stale. Build time can never see it
2451
+ # (`_resolve_codex_weekly_cycle` retains only `resets_at > now`),
2452
+ # so this clock is the only writer of that value.
2453
+ domain_freshness["hero"] = "stale"
2396
2454
  cycle_changed = True
2397
2455
  # 3. budget last
2398
2456
  if refreshed_budget is not None:
@@ -2411,7 +2469,17 @@ def refresh_codex_source_clock(
2411
2469
  data=data,
2412
2470
  domain_freshness=domain_freshness,
2413
2471
  clock_data=state.clock_data,
2472
+ # #556 S1 §3.8: this constructor lists every field explicitly, so an
2473
+ # omission silently drops the authoritative account count and makes the
2474
+ # combined figure fail closed on an idle tick that changed nothing else.
2475
+ account_scope=state.account_scope,
2414
2476
  private_session_labels=state.private_session_labels,
2477
+ # #556 S2 §3.6: the aggregate carrier travels with the rows it
2478
+ # describes. This clock refreshes presentation axes only and publishes
2479
+ # the SAME rows, so it must carry the range that bounded them —
2480
+ # dropping it here would withhold the aggregate on any idle tick whose
2481
+ # clock moved.
2482
+ aggregate_scope=state.aggregate_scope,
2415
2483
  )
2416
2484
  return state if refreshed_state == state else refreshed_state
2417
2485
 
@@ -2457,39 +2525,55 @@ def _alerts_wire(
2457
2525
  "accountLabel": label,
2458
2526
  }
2459
2527
 
2528
+ # #556 S3 §2.1/§2.4: the firing instant is what this wire filters on, what
2529
+ # the panel orders by and what it prints, so it is what every leg selects,
2530
+ # orders by and publishes. Two legs previously ordered, truncated and
2531
+ # published the CROSSING instant instead, which is a different moment: a
2532
+ # row that fired most recently but crossed longest ago was dropped at the
2533
+ # LIMIT and never reached the panel at all. `created_at` stays as an
2534
+ # equal-valued compatibility alias for a client reading a pre-v7 envelope.
2535
+ canon = canonical_alerted_at_sql()
2536
+
2537
+ def _instants(raw: object) -> dict[str, str]:
2538
+ value = canonical_alerted_at(raw)
2539
+ return {"alerted_at": value, "created_at": value}
2540
+
2460
2541
  try:
2461
- for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
2462
- "SELECT period, threshold, consumption_pct, crossed_at_utc, account_key "
2542
+ for period, threshold, consumption_pct, crossed_at, alerted_at, account_key in stats_conn.execute(
2543
+ "SELECT period, threshold, consumption_pct, crossed_at_utc, alerted_at, account_key "
2463
2544
  "FROM budget_milestones WHERE vendor='codex' AND alerted_at IS NOT NULL "
2464
- "ORDER BY crossed_at_utc DESC, threshold DESC LIMIT ?",
2545
+ f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
2465
2546
  (SOURCE_HISTORY_LIMIT,),
2466
2547
  ):
2467
2548
  rows.append({
2549
+ # The resource key keeps the crossing instant it has always
2550
+ # carried: it is an opaque identity, and re-keying every
2551
+ # historical Codex alert row is not this session's change.
2468
2552
  "key": dashboard_resource_key("alert", "codex", "codex_budget", period, threshold, crossed_at),
2469
2553
  "source": "codex",
2470
2554
  "axis": "codex_budget", "period": period, "threshold": threshold,
2471
- "value": consumption_pct, "created_at": crossed_at,
2555
+ "value": consumption_pct, **_instants(alerted_at),
2472
2556
  **_account(account_key),
2473
2557
  })
2474
- for period, threshold, projected_value, crossed_at, account_key in stats_conn.execute(
2475
- "SELECT period, threshold, projected_value, crossed_at_utc, account_key "
2558
+ for period, threshold, projected_value, crossed_at, alerted_at, account_key in stats_conn.execute(
2559
+ "SELECT period, threshold, projected_value, crossed_at_utc, alerted_at, account_key "
2476
2560
  "FROM projected_milestones WHERE metric='codex_budget_usd' AND alerted_at IS NOT NULL "
2477
- "ORDER BY crossed_at_utc DESC, threshold DESC LIMIT ?",
2561
+ f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
2478
2562
  (SOURCE_HISTORY_LIMIT,),
2479
2563
  ):
2480
2564
  rows.append({
2481
2565
  "key": dashboard_resource_key("alert", "codex", "projected", period, threshold, crossed_at),
2482
2566
  "source": "codex",
2483
2567
  "axis": "projected", "period": period, "threshold": threshold,
2484
- "value": projected_value, "created_at": crossed_at,
2568
+ "value": projected_value, **_instants(alerted_at),
2485
2569
  **_account(account_key),
2486
2570
  })
2487
2571
  for (root_key, logical_key, observed_slot, window_minutes, resets_at,
2488
- threshold, severity, created_at, account_key) in stats_conn.execute(
2572
+ threshold, severity, created_at, alerted_at, account_key) in stats_conn.execute(
2489
2573
  "SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, resets_at_utc, "
2490
- "threshold, severity, created_at_utc, account_key FROM quota_threshold_events "
2574
+ "threshold, severity, created_at_utc, alerted_at, account_key FROM quota_threshold_events "
2491
2575
  "WHERE source='codex' AND disposition='alerted' AND orphaned_at IS NULL "
2492
- "ORDER BY created_at_utc DESC, source_root_key, logical_limit_key, observed_slot, threshold "
2576
+ f"ORDER BY {canon} DESC, source_root_key, logical_limit_key, observed_slot, threshold "
2493
2577
  "LIMIT ?",
2494
2578
  (SOURCE_HISTORY_LIMIT,),
2495
2579
  ):
@@ -2500,14 +2584,14 @@ def _alerts_wire(
2500
2584
  ),
2501
2585
  "source": "codex",
2502
2586
  "axis": "quota", "threshold": threshold, "severity": severity,
2503
- "created_at": created_at,
2587
+ **_instants(alerted_at),
2504
2588
  **_account(account_key),
2505
2589
  })
2506
2590
  except sqlite3.Error:
2507
2591
  return ()
2508
2592
  return tuple(sorted(
2509
2593
  rows,
2510
- key=lambda item: str(item.get("created_at") or ""),
2594
+ key=lambda item: canonical_alerted_at(item["alerted_at"]),
2511
2595
  reverse=True,
2512
2596
  )[:SOURCE_HISTORY_LIMIT])
2513
2597
 
@@ -3408,7 +3492,6 @@ def build_codex_source_state(
3408
3492
  cache_conn=context.cache_conn,
3409
3493
  )
3410
3494
  budget_entries = _codex_entries_from_accounting(accounting_entries)
3411
- cycle_reason: str | None = None
3412
3495
  cycles_all: list[CodexCycleBoundary] = []
3413
3496
  try:
3414
3497
  # Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
@@ -3419,9 +3502,12 @@ def build_codex_source_state(
3419
3502
  # `conflicting`.
3420
3503
  cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
3421
3504
  cycle = cycles_all[0] if cycles_all else None
3422
- except CodexCycleUnavailable as exc:
3505
+ except CodexCycleUnavailable:
3506
+ # #556 S1 §4.1: the reason no longer moves a freshness axis. A
3507
+ # `stale` reason is observation AGE, which `quota` owns; every reason
3508
+ # here leaves `cycle` unresolved, which `cycle_failure` below turns
3509
+ # into the hero failure the accounting axis actually reports.
3423
3510
  cycle = None
3424
- cycle_reason = exc.reason
3425
3511
  cycle_failure = cycle is None and has_cached_codex_accounting_entries(
3426
3512
  cache_conn=context.cache_conn,
3427
3513
  )
@@ -3826,16 +3912,17 @@ def build_codex_source_state(
3826
3912
  if ingest_backlog is not None else {}),
3827
3913
  },
3828
3914
  domain_freshness={
3829
- "hero": (
3830
- "stale"
3831
- if cycle_reason == "stale"
3832
- or (
3833
- cycle is not None
3834
- and not hero_failure
3835
- and cycle.evidence_stale
3836
- )
3837
- else "fresh"
3838
- ),
3915
+ # #556 S1 §4.1: `hero` means current-cycle ACCOUNTING
3916
+ # resolvability, not observation age. A stale-but-still-future
3917
+ # boundary stays RESOLVED — the spend it bounds is correct, and
3918
+ # Codex has no background quota poll, so `stale_after_seconds`
3919
+ # (3600) makes an idle weekly observation stale within the hour
3920
+ # while nothing about the accounting changed. The percent age is
3921
+ # already carried by `quota` below and by the additive hero-local
3922
+ # `cycle_freshness` field. `_resolve_codex_weekly_cycle` retains
3923
+ # only boundaries with `resets_at > now`, so a resolved cycle is
3924
+ # never expired at build time; the idle clock owns expiry.
3925
+ "hero": "stale" if hero_failure else "fresh",
3839
3926
  "quota": (
3840
3927
  "stale"
3841
3928
  if quota["summary"]["freshness"] == "stale"
@@ -4310,6 +4310,16 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4310
4310
  "CREATE INDEX IF NOT EXISTS idx_codex_entries_ts_root_conversation "
4311
4311
  "ON codex_session_entries(timestamp_utc, source_root_key, conversation_key)"
4312
4312
  )
4313
+ # The per-file alias join in `_codex_conversation_metadata` matches on
4314
+ # (source_root_key, source_path). `idx_codex_entries_source_root` cannot
4315
+ # serve it: a machine normally has ONE provider root, so a root-only search
4316
+ # visits every entry row for every file and the join costs files x entries
4317
+ # on every dashboard snapshot build. Re-derivable, so it belongs on the
4318
+ # unconditional path with the S3 index above rather than in a migration.
4319
+ conn.execute(
4320
+ "CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path "
4321
+ "ON codex_session_entries(source_root_key, source_path)"
4322
+ )
4313
4323
  # The per-file terminal thread facts seed a later append without rereading
4314
4324
  # the prefix. They are nullable for old cache rows; migration 024 never
4315
4325
  # fabricates these source facts and instead clears/rederives them.
@@ -38,6 +38,7 @@ import sys
38
38
 
39
39
  import _cctally_core
40
40
  import _lib_changelog
41
+ import _lib_journal_router
41
42
  from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
42
43
  from _lib_dashboard_json import encode_dashboard_json
43
44
 
@@ -46,10 +47,10 @@ from _lib_dashboard_json import encode_dashboard_json
46
47
  #: (`_lib_journal.resolve_effective_events`) reads only `evt`, `correction` and
47
48
  #: `correction_batch`; `op` is kept because the rebuild-equivalent account
48
49
  #: normalization is defined over evt/op records. Everything else — above all the
49
- #: `obs` lines, ~97% of a real journal — is dropped as it is decoded, so the deep
50
- #: gather's peak RSS tracks the decision history rather than the whole journal.
51
- _CONFLICT_SCAN_RECORD_TYPES = frozenset(
52
- {"evt", "correction", "correction_batch", "op"})
50
+ #: `obs` lines, ~97% of a real journal — becomes a `None` positional slot as it
51
+ #: is decoded, so peak RSS tracks decision dictionaries plus one pointer per
52
+ #: decoded line rather than the whole decoded journal.
53
+ _CONFLICT_SCAN_RECORD_TYPES = _lib_journal_router.RETAINED_RECORD_TYPES
53
54
 
54
55
  #: Doctor needs only recent evidence for this diagnostic. Bound both line count
55
56
  #: and bytes so a corrupt single-line file cannot defeat the tail limit.
@@ -1798,10 +1799,13 @@ def _doctor_gather_state_impl(
1798
1799
  # RETAIN ONLY what the selector consumes. `obs` lines are
1799
1800
  # ~97% of a real journal (984k of 1.02M) and
1800
1801
  # `resolve_effective_events` ignores them entirely —
1801
- # keeping them cost 4.3 GB of peak RSS for an identical
1802
- # result (#374 review).
1803
- if record.get("t") in _CONFLICT_SCAN_RECORD_TYPES:
1804
- decoded_records.append(record)
1802
+ # keeping their dictionaries cost 4.3 GB of peak RSS for
1803
+ # an identical result (#374 review). They still consume a
1804
+ # lightweight slot because their physical sequence is
1805
+ # part of three durable violation fingerprints (#508).
1806
+ decoded_records.append(
1807
+ _lib_journal_router.selector_slot(record)
1808
+ )
1805
1809
  prior_high_water = (
1806
1810
  seg,
1807
1811
  offset + len(raw) + 1,
@@ -1821,8 +1825,9 @@ def _doctor_gather_state_impl(
1821
1825
  else _jr.resolve_cutover_claude_account()
1822
1826
  )
1823
1827
  for record in decoded_records:
1824
- _jr._normalize_legacy_account_stamp(
1825
- record, cutover_claude)
1828
+ if record is not None:
1829
+ _jr._normalize_legacy_account_stamp(
1830
+ record, cutover_claude)
1826
1831
  selection = _jl.resolve_effective_events(
1827
1832
  decoded_records,
1828
1833
  protocol_prefix_evidence=protocol_evidence,
@@ -54,10 +54,10 @@ def _read_prefix(high_water):
54
54
  inline exactly as `rebuild_stats_index` captures it. Before this, those were
55
55
  four separate whole-prefix traversals on top of this one (#496 S5 §4).
56
56
 
57
- Every record stays decoded. Unlike the rebuild, the selector here feeds an
58
- acknowledgement the repair command may then mint, and unlike the rebuild's
59
- filtered retention there is no placeholder scheme to keep the `enumerate`
60
- numbering identical so the list is unfiltered, exactly as before.
57
+ Every decoded line contributes one selector slot. Decision records stay
58
+ decoded; observations and other irrelevant records become ``None``
59
+ placeholders, preserving the `enumerate` numbering that durable violation
60
+ fingerprints hash without retaining whole-prefix decoded history.
61
61
  """
62
62
  if high_water is None:
63
63
  return [], (), None, {}
@@ -106,7 +106,7 @@ def _read_prefix(high_water):
106
106
  and record.get("id") == _journal.CUTOVER_OP_ID
107
107
  ):
108
108
  cutover_captured = _journal._cutover_value_of(record)
109
- records.append(record)
109
+ records.append(_lib_journal_router.selector_slot(record))
110
110
  prior_high_water = record_end
111
111
  prefix_hash = hasher.digest_at(high_water)
112
112
  # The accumulator buffers the segment it is reading — 410 MB on the
@@ -136,7 +136,8 @@ def _read_prefix(high_water):
136
136
  else:
137
137
  cutover_claude = cutover_captured
138
138
  for record in records:
139
- _journal._normalize_legacy_account_stamp(record, cutover_claude)
139
+ if record is not None:
140
+ _journal._normalize_legacy_account_stamp(record, cutover_claude)
140
141
  return records, tuple(evidence), prefix_hash, audit_ends
141
142
 
142
143