cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -43,11 +43,19 @@ from _lib_quota import (
43
43
  build_blocks,
44
44
  build_history,
45
45
  forecast_quota,
46
+ latest_physical_observation,
46
47
  percent_milestones,
47
48
  quota_freshness,
48
49
  select_baseline,
50
+ stale_after_seconds,
49
51
  )
50
- from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
52
+ from _lib_jsonl import CodexEntry
53
+ from _lib_codex_pools import (
54
+ codex_history_is_model_scoped,
55
+ codex_model_scoped_quota_pool,
56
+ is_model_scoped_codex_quota,
57
+ )
58
+ from _lib_codex_conversation import _display_title as _codex_display_title
51
59
  from _lib_fmt import stable_sum
52
60
  from _lib_aggregators import _aggregate_codex_buckets
53
61
  from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
@@ -140,6 +148,10 @@ class CodexCycleBoundary:
140
148
  # Exact server-side quota identity selected for the hero. It is never
141
149
  # serialized; milestone-history keys hash it opaquely.
142
150
  quota_identity: QuotaWindowIdentity | None = None
151
+ # #350: whether this boundary won the §3.2 ranking on STALE evidence (no
152
+ # fresh boundary existed for the account). Backward-looking actuals stay
153
+ # bounded, but the hero discloses it through ``hero.cycle_freshness``.
154
+ evidence_stale: bool = False
143
155
 
144
156
 
145
157
  @dataclass(frozen=True)
@@ -152,19 +164,22 @@ class CodexWeeklyPeriod:
152
164
  used_percent: float | None = None
153
165
 
154
166
 
155
- def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
156
- """Whether an interpreted native identity belongs outside standard quota."""
157
- if not isinstance(logical_limit_key, str):
158
- return False
159
- try:
160
- payload = json.loads(logical_limit_key)
161
- except (json.JSONDecodeError, TypeError):
162
- return False
163
- return (
164
- isinstance(payload, dict)
165
- and isinstance(payload.get("modelPool"), str)
166
- and bool(payload["modelPool"].strip())
167
- )
167
+ def _codex_history_row_is_model_scoped(row: object) -> bool:
168
+ """Whether a SERIALIZED Codex quota history row sits outside account quota.
169
+
170
+ The single predicate both the initial build (``_quota_read_model``) and the
171
+ idle refresh (``refresh_codex_source_clock``) consult, so the two paths
172
+ cannot drift apart: the build stamps ``model_scoped`` from
173
+ ``codex_history_is_model_scoped`` and then asks THIS function about the row
174
+ it just built, and the refresh — which only ever sees the serialized row —
175
+ asks the same function. Fixing one path and not the other is exactly how
176
+ the quota summary and its idle refresh would disagree (#373 spec §7.2).
177
+
178
+ The key is additive and OMITTED when false (spec §7.3), so a row without it
179
+ is standard account quota and every fixture that has no model-scoped window
180
+ serializes byte-identically.
181
+ """
182
+ return bool(isinstance(row, Mapping) and row.get("model_scoped"))
168
183
 
169
184
 
170
185
  def _resolve_codex_weekly_cycle(
@@ -180,34 +195,68 @@ def _resolve_codex_weekly_cycle(
180
195
  Raises ``CodexCycleUnavailable`` only when NO account yields a live cycle. A
181
196
  single-account install returns a 1-element list = today's boundary
182
197
  byte-for-byte (the hero path is unchanged, spec R8).
198
+
199
+ #350 — FRESH-FIRST ranking (spec §3.2). Codex has no background quota poll,
200
+ so ``stale_after_seconds(10_080) == 3600`` makes an idle weekly observation
201
+ stale after exactly one hour. Discarding a stale-but-FUTURE boundary blanked
202
+ the hero's backward-looking actuals even though the spend was never lost, so
203
+ each account's future weekly boundaries are now collected into a fresh set
204
+ and a stale set and ranked:
205
+
206
+ 1. exactly one FRESH boundary -> valid, cycle fresh;
207
+ 2. else, no fresh boundaries and exactly one STALE boundary -> valid, cycle
208
+ stale (``CodexCycleBoundary.evidence_stale``, surfaced as the additive
209
+ ``hero.cycle_freshness``);
210
+ 3. else -> invalid, with today's ``conflicting``/``stale``/``missing`` reason.
211
+
212
+ Fresh-first ordering is load-bearing: a flat count over the union would
213
+ regress one-fresh-plus-one-stale, which resolves valid today. Only the EXACT
214
+ ``"stale"`` freshness state is eligible — ``"future"`` (a capture ahead of
215
+ ``now``) and ``"unavailable"`` stay invalid and keep today's reason.
183
216
  """
184
- per_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
217
+ fresh_by_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
218
+ stale_by_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
185
219
  accounts_seen: set[str] = set()
186
- stale_by_account: dict[str, bool] = {}
220
+ ineligible_by_account: dict[str, bool] = {}
187
221
  for history in build_history(tuple(observations)):
188
222
  if history.identity.window_minutes != 10_080:
189
223
  continue
190
- if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
224
+ # The baseline observation is the §7.1 label authority, so it is
225
+ # resolved BEFORE classification wherever the call site has one.
226
+ baseline = select_baseline(history.observations, now_utc)
227
+ if codex_history_is_model_scoped(history, baseline=baseline):
191
228
  continue
192
229
  account = history.identity.account_key
193
230
  accounts_seen.add(account)
194
- baseline = select_baseline(history.observations, now_utc)
195
231
  if baseline is None or baseline.resets_at <= now_utc:
196
232
  continue
197
- if quota_freshness(history.physical_observations, now_utc).state != "fresh":
198
- stale_by_account[account] = True
199
- continue
233
+ state = quota_freshness(history.physical_observations, now_utc).state
200
234
  boundary = (history.identity.window_minutes, baseline.resets_at)
201
- per_account.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
235
+ if state == "fresh":
236
+ bucket = fresh_by_account
237
+ elif state == "stale":
238
+ bucket = stale_by_account
239
+ else:
240
+ # "future"/"unavailable" evidence is never a stale fallback; it keeps
241
+ # today's non-fresh reason so the envelope degrades exactly as before.
242
+ ineligible_by_account[account] = True
243
+ continue
244
+ bucket.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
202
245
  cycles: list[CodexCycleBoundary] = []
203
246
  reasons: list[str] = []
204
- for account in sorted(accounts_seen | set(per_account)):
205
- boundaries = per_account.get(account, {})
206
- if len(boundaries) != 1:
207
- # Within one account: 0 boundaries -> stale/missing; >=2 -> conflicting.
247
+ for account in sorted(accounts_seen | set(fresh_by_account) | set(stale_by_account)):
248
+ fresh_boundaries = fresh_by_account.get(account, {})
249
+ stale_boundaries = stale_by_account.get(account, {})
250
+ if len(fresh_boundaries) == 1:
251
+ boundaries, evidence_stale = fresh_boundaries, False
252
+ elif not fresh_boundaries and len(stale_boundaries) == 1:
253
+ boundaries, evidence_stale = stale_boundaries, True
254
+ else:
255
+ # Within one account: >=2 fresh, or no fresh and >=2 stale ->
256
+ # conflicting; nothing eligible -> today's stale/missing reason.
208
257
  reasons.append(
209
- "conflicting" if boundaries
210
- else ("stale" if stale_by_account.get(account) else "missing")
258
+ "conflicting" if (fresh_boundaries or stale_boundaries)
259
+ else ("stale" if ineligible_by_account.get(account) else "missing")
211
260
  )
212
261
  continue
213
262
  (window_minutes, resets_at), candidates = next(iter(boundaries.items()))
@@ -232,6 +281,7 @@ def _resolve_codex_weekly_cycle(
232
281
  source_root_keys=(selected_identity.source_root_key,),
233
282
  used_percent=float(baseline.used_percent),
234
283
  quota_identity=selected_identity,
284
+ evidence_stale=evidence_stale,
235
285
  ))
236
286
  if not cycles:
237
287
  # Aggregate reason: for a single account this is exactly the old reason
@@ -244,6 +294,157 @@ def _resolve_codex_weekly_cycle(
244
294
  return cycles
245
295
 
246
296
 
297
+ def resolve_codex_cycle_detail_identity(
298
+ cache_conn,
299
+ *,
300
+ source_root_keys: Iterable[str],
301
+ now_utc: dt.datetime,
302
+ ):
303
+ """The live-cycle identity for a per-request Codex cycle-DETAIL read (#373).
304
+
305
+ The cycle INDEX is built with the hero's live ``CodexCycleBoundary``; the
306
+ detail route runs outside the snapshot build and has no envelope, so it used
307
+ to pass a stub carrying no ``resets_at``. The former future-reset proxy then
308
+ reported ``is_current: true`` for every future-ending cycle — including a
309
+ historic one that an early re-anchor had already closed. Resolving the same
310
+ boundary here is what makes one cycle key describe one cycle on both routes.
311
+
312
+ ``source_root_keys`` scopes the returned identity's cycle LOOKUP (the caller
313
+ passes every retained Codex root, so a just-closed cycle stays fetchable);
314
+ the live boundary itself is resolved from the active roots' observations,
315
+ exactly as the source build does.
316
+
317
+ Degrades to a bare-roots identity — today's behaviour — whenever no live
318
+ cycle resolves. The clip guard stays unarmed on that identity by design
319
+ (``_boundary_has_live_reset``), so the detail keeps clipping as it did
320
+ before #373 rather than trusting the proxy.
321
+ """
322
+ identity = SimpleNamespace(
323
+ source_root_keys=tuple(source_root_keys),
324
+ resets_at=None,
325
+ quota_identity=None,
326
+ )
327
+ if cache_conn is None:
328
+ return identity
329
+ try:
330
+ active_roots = tuple(sorted(
331
+ str(row[0]) for row in cache_conn.execute(
332
+ "SELECT source_root_key FROM codex_source_roots"
333
+ )
334
+ ))
335
+ if not active_roots:
336
+ return identity
337
+ observations = load_codex_quota_observations(
338
+ source_root_keys=active_roots,
339
+ cache_conn=cache_conn,
340
+ captured_at_or_after=(
341
+ now_utc - dt.timedelta(days=DASHBOARD_QUOTA_RECENT_DAYS)
342
+ ),
343
+ active_at=now_utc,
344
+ max_rows=DASHBOARD_QUOTA_OBSERVATION_LIMIT,
345
+ )
346
+ cycles = _resolve_codex_weekly_cycle(observations, now_utc)
347
+ except (sqlite3.Error, CodexCycleUnavailable, ValueError):
348
+ return identity
349
+ if not cycles:
350
+ return identity
351
+ boundary = cycles[0]
352
+ identity.resets_at = boundary.resets_at
353
+ identity.quota_identity = boundary.quota_identity
354
+ return identity
355
+
356
+
357
+ def _codex_next_decision_at(
358
+ observations: Iterable[object],
359
+ cycles: Iterable[CodexCycleBoundary],
360
+ now_utc: dt.datetime,
361
+ ) -> dt.datetime | None:
362
+ """The earliest future instant at which weekly-cycle resolution can change.
363
+
364
+ #350 spec §3.3. Cycle validity is time-dependent even on FROZEN evidence:
365
+ ``_resolve_codex_weekly_cycle`` passes ``now_utc`` to both ``select_baseline``
366
+ (a future-dated capture becomes baseline-eligible purely because time passed,
367
+ which can switch the selected reset) and ``quota_freshness`` (fresh flips to
368
+ stale as age crosses ``stale_after_seconds``). One fresh plus one stale
369
+ boundary resolves FRESH today and ``conflicting`` an hour later on the very
370
+ same rows. The idle clock cannot re-resolve that itself — the public
371
+ histories it sees are capped at ``SOURCE_HISTORY_LIMIT`` and omit
372
+ ``logical_limit_key`` (§2.3) — so build time instead records WHEN the clock
373
+ must stop trusting its verdict, and the tick rebuilds authoritatively at the
374
+ crossing. That is one rebuild per deadline (a handful per weekly cycle), not
375
+ one per tick.
376
+
377
+ The deadline is the ``min`` of three candidate kinds, dropping any candidate
378
+ at or before ``now_utc``:
379
+
380
+ 1. every selected cycle's ``resets_at`` (expiry — including the
381
+ #341 multi-account case where account A expires while B stays live);
382
+ 2. ``latest_physical_capture + stale_after_seconds(window)`` for every weekly
383
+ history with a live baseline (fresh -> stale). This is a deliberate
384
+ SUPERSET of the §3.2 ranking participants — it also covers histories whose
385
+ freshness is ``"future"``/``"unavailable"`` and so never enter the ranking
386
+ — because an extra candidate can only pull the deadline EARLIER, and an
387
+ earlier deadline is always the conservative direction;
388
+ 3. the ``captured_at`` of any future-dated weekly observation
389
+ (future -> fresh / baseline eligibility).
390
+
391
+ Returns ``None`` when nothing can flip. Server-only: it rides ``clock_data``
392
+ and never reaches the public source envelope.
393
+ """
394
+ candidates: list[dt.datetime] = []
395
+ for cycle in cycles:
396
+ candidates.append(cycle.resets_at.astimezone(UTC))
397
+ for history in build_history(tuple(observations)):
398
+ if history.identity.window_minutes != 10_080:
399
+ continue
400
+ baseline = select_baseline(history.observations, now_utc)
401
+ if codex_history_is_model_scoped(history, baseline=baseline):
402
+ continue
403
+ if baseline is not None and baseline.resets_at > now_utc:
404
+ latest = latest_physical_observation(history.physical_observations)
405
+ if latest is not None:
406
+ candidates.append(
407
+ latest.captured_at.astimezone(UTC)
408
+ + dt.timedelta(
409
+ seconds=stale_after_seconds(history.identity.window_minutes),
410
+ )
411
+ )
412
+ # A capture ahead of ``now`` is not baseline-eligible yet, so this runs
413
+ # even for a history with no live baseline at all.
414
+ for observation in (
415
+ *history.observations, *history.physical_observations,
416
+ ):
417
+ captured_at = observation.captured_at.astimezone(UTC)
418
+ if captured_at > now_utc:
419
+ candidates.append(captured_at)
420
+ live = [candidate for candidate in candidates if candidate > now_utc]
421
+ return min(live) if live else None
422
+
423
+
424
+ def codex_decision_deadline_passed(
425
+ state: object,
426
+ now_utc: dt.datetime,
427
+ ) -> bool:
428
+ """Whether a published Codex state's cycle decision deadline has elapsed.
429
+
430
+ #350 spec §3.3. When this holds the tick MUST rebuild Codex authoritatively
431
+ via ``build_codex_source_state`` — bypassing both the idle clock and
432
+ ``reuse_coherent_source_state`` — because the frozen evidence would now
433
+ resolve to a different cycle (or to none). A state with no recorded deadline
434
+ (``None``, or an older generation that predates the field) never forces a
435
+ rebuild; the clock's expiry guard remains its safety net.
436
+ """
437
+ clock_data = getattr(state, "clock_data", None)
438
+ if not isinstance(clock_data, Mapping):
439
+ return False
440
+ deadline = clock_data.get("codex_next_decision_at")
441
+ if not isinstance(deadline, dt.datetime):
442
+ return False
443
+ if deadline.tzinfo is None or deadline.utcoffset() is None:
444
+ return False
445
+ return now_utc.astimezone(UTC) >= deadline.astimezone(UTC)
446
+
447
+
247
448
  def _codex_weekly_periods(
248
449
  stats_conn: sqlite3.Connection,
249
450
  *,
@@ -265,7 +466,7 @@ def _codex_weekly_periods(
265
466
  placeholders = ",".join("?" for _ in roots)
266
467
  try:
267
468
  rows = stats_conn.execute(
268
- "SELECT source_root_key, logical_limit_key, resets_at_utc, "
469
+ "SELECT source_root_key, logical_limit_key, limit_name, resets_at_utc, "
269
470
  "nominal_start_at_utc, current_percent "
270
471
  "FROM quota_window_blocks "
271
472
  "WHERE source='codex' AND window_minutes=10080 "
@@ -277,10 +478,18 @@ def _codex_weekly_periods(
277
478
  except sqlite3.Error:
278
479
  rows = ()
279
480
 
280
- raw_boundaries: list[tuple[dt.datetime, dt.datetime, set[str], list[float]]] = []
281
-
282
- for root_key, logical_limit_key, resets_at_raw, start_at_raw, current_percent in rows:
283
- if _is_model_scoped_codex_quota(logical_limit_key):
481
+ # #373 §7.4: the fifth element marks the LIVE boundary, which is never
482
+ # clipped by a successor. Durable rows are never live on their own — only
483
+ # the caller's `active_cycle` is but the jitter-merge below folds the
484
+ # live boundary together with its own durable row, so the flag is OR-ed on
485
+ # merge rather than taken from either side.
486
+ raw_boundaries: list[
487
+ tuple[dt.datetime, dt.datetime, set[str], list[float], bool]
488
+ ] = []
489
+
490
+ for (root_key, logical_limit_key, limit_name, resets_at_raw,
491
+ start_at_raw, current_percent) in rows:
492
+ if is_model_scoped_codex_quota(logical_limit_key, limit_name):
284
493
  continue
285
494
  try:
286
495
  start_at = dt.datetime.fromisoformat(str(start_at_raw).replace("Z", "+00:00"))
@@ -296,18 +505,23 @@ def _codex_weekly_periods(
296
505
  used_values = []
297
506
  if isinstance(current_percent, (int, float)) and not isinstance(current_percent, bool):
298
507
  used_values.append(float(current_percent))
299
- raw_boundaries.append((start_at, resets_at, {str(root_key)}, used_values))
508
+ raw_boundaries.append((start_at, resets_at, {str(root_key)}, used_values, False))
300
509
 
510
+ # `active_cycle is None` is the case §7.4 calls out explicitly: no boundary
511
+ # is live, so nothing is exempt and every period clips exactly as before.
301
512
  if active_cycle is not None:
302
513
  raw_boundaries.append((
303
514
  active_cycle.start_at.astimezone(UTC),
304
515
  active_cycle.resets_at.astimezone(UTC),
305
516
  set(active_cycle.source_root_keys),
306
517
  [active_cycle.used_percent] if active_cycle.used_percent is not None else [],
518
+ True,
307
519
  ))
308
520
 
309
- ordered: list[tuple[dt.datetime, dt.datetime, set[str], list[float]]] = []
310
- for start_at, resets_at, period_roots, used_values in sorted(
521
+ ordered: list[
522
+ tuple[dt.datetime, dt.datetime, set[str], list[float], bool]
523
+ ] = []
524
+ for start_at, resets_at, period_roots, used_values, is_live in sorted(
311
525
  raw_boundaries, key=lambda item: (item[0], item[1]),
312
526
  ):
313
527
  if (
@@ -315,17 +529,23 @@ def _codex_weekly_periods(
315
529
  and (start_at - ordered[-1][0]).total_seconds()
316
530
  < _FIVE_HOUR_JITTER_FLOOR_SECONDS
317
531
  ):
318
- first_start, latest_reset, existing_roots, existing_used = ordered[-1]
532
+ first_start, latest_reset, existing_roots, existing_used, existing_live = ordered[-1]
319
533
  existing_roots.update(period_roots)
320
534
  existing_used.extend(used_values)
321
535
  ordered[-1] = (
322
536
  first_start, max(latest_reset, resets_at), existing_roots, existing_used,
537
+ existing_live or is_live,
323
538
  )
324
539
  else:
325
- ordered.append((start_at, resets_at, set(period_roots), list(used_values)))
540
+ ordered.append((
541
+ start_at, resets_at, set(period_roots), list(used_values), is_live,
542
+ ))
326
543
  periods: list[CodexWeeklyPeriod] = []
327
- for index, (start_at, resets_at, period_roots, used_values) in enumerate(ordered):
544
+ for index, (start_at, resets_at, period_roots, used_values, is_live) in enumerate(ordered):
328
545
  next_start = ordered[index + 1][0] if index + 1 < len(ordered) else None
546
+ # The live cycle always ends at its own reset (#373 §7.4).
547
+ if is_live:
548
+ next_start = None
329
549
  end_at = min(resets_at, next_start) if next_start is not None else resets_at
330
550
  if end_at <= start_at:
331
551
  continue
@@ -928,7 +1148,9 @@ def _codex_conversation_metadata(
928
1148
  f"SELECT id, title FROM threads WHERE id IN ({placeholders})",
929
1149
  batch,
930
1150
  ):
931
- clean_title = " ".join(str(title or "").split())
1151
+ clean_title = _codex_display_title(
1152
+ str(title) if title is not None else None
1153
+ )
932
1154
  if clean_title:
933
1155
  short_names[str(thread_id)] = clean_title
934
1156
  except (OSError, sqlite3.Error):
@@ -993,6 +1215,7 @@ def _session_wire(
993
1215
  view: Any,
994
1216
  *,
995
1217
  metadata: Mapping[tuple[str, str], Mapping[str, object]] | None = None,
1218
+ private_labels: dict[str, str] | None = None,
996
1219
  ) -> dict[str, object]:
997
1220
  rows = []
998
1221
  for row in view.rows:
@@ -1018,7 +1241,11 @@ def _session_wire(
1018
1241
  )
1019
1242
  )
1020
1243
  ), None)
1021
- title = str(row_metadata.get("title") or "").strip() if row_metadata else ""
1244
+ title = _codex_display_title(
1245
+ str(row_metadata.get("title"))
1246
+ if row_metadata and row_metadata.get("title") is not None
1247
+ else None
1248
+ )
1022
1249
  project = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
1023
1250
  started_at = row_metadata.get("started_at") if row_metadata else None
1024
1251
  duration_min = None
@@ -1028,12 +1255,14 @@ def _session_wire(
1028
1255
  duration_min = max(0, round((row.last_activity.astimezone(UTC) - started_dt.astimezone(UTC)).total_seconds() / 60))
1029
1256
  except (TypeError, ValueError):
1030
1257
  started_at = None
1258
+ key = dashboard_resource_key(
1259
+ "session", "codex", root_identity, row.session_id_path,
1260
+ )
1261
+ if title and private_labels is not None:
1262
+ private_labels[key] = title
1031
1263
  rows.append({
1032
- "key": dashboard_resource_key(
1033
- "session", "codex", root_identity, row.session_id_path,
1034
- ),
1264
+ "key": key,
1035
1265
  "source": "codex",
1036
- "label": title or None,
1037
1266
  "project": project or None,
1038
1267
  "project_key": row_metadata.get("project_key") if row_metadata else None,
1039
1268
  "started_at": started_at,
@@ -1298,10 +1527,14 @@ def _quota_read_model(
1298
1527
  milestone_rows: list[dict[str, object]] = []
1299
1528
  active_rows: list[dict[str, object]] = []
1300
1529
  # R8 (#341 Task 4): the per-account `account_key` is serialized onto each
1301
- # history row ONLY when the Codex provider has >1 REAL account, so the idle
1302
- # clock (`_clock_cycle_validity`) can scope weekly-cycle validity per account
1303
- # instead of degrading a genuine multi-account state to `conflicting`. A
1304
- # <=1-real-account install (all fixtures) stays byte-identical (no key added).
1530
+ # history row ONLY when the Codex provider has >1 REAL account, so the
1531
+ # dashboard client can scope per-account quota rows instead of merging them.
1532
+ # A <=1-real-account install (all fixtures) stays byte-identical (no key
1533
+ # added). #350 removed the original consumer, `_clock_cycle_validity`: the
1534
+ # idle clock no longer re-derives weekly-cycle validity at all, because this
1535
+ # public history view is LOSSY — capped at `SOURCE_HISTORY_LIMIT` and without
1536
+ # `logical_limit_key` — so it cannot resolve the cycle authoritatively. Build
1537
+ # time owns resolution; a `clock_data` decision deadline forces the rebuild.
1305
1538
  _codex_decorated = False
1306
1539
  try:
1307
1540
  import _cctally_account
@@ -1320,9 +1553,16 @@ def _quota_read_model(
1320
1553
  baseline = select_baseline(history.observations, context.now_utc)
1321
1554
  freshness = quota_freshness(history.physical_observations, context.now_utc)
1322
1555
  forecast = forecast_quota(history.physical_observations, context.now_utc)
1323
- history_rows.append({
1556
+ # #373: a window outside account-level standard quota (a separate model
1557
+ # pool such as GPT-5.3-Codex-Spark) stays LISTED — a legitimate
1558
+ # independent pool must remain visible — but is excluded from every
1559
+ # account-level aggregate below. `baseline` is the label authority when
1560
+ # one exists (spec §7.1).
1561
+ model_scoped = codex_history_is_model_scoped(history, baseline=baseline)
1562
+ row = {
1324
1563
  "key": dashboard_resource_key("quota", "codex", *key_parts),
1325
1564
  "source": "codex",
1565
+ **({"model_scoped": True} if model_scoped else {}),
1326
1566
  **({"account_key": identity.account_key} if _codex_decorated else {}),
1327
1567
  "label": _native_limit_label(identity.limit_name, identity.window_minutes),
1328
1568
  "observed_slot": identity.observed_slot,
@@ -1345,7 +1585,10 @@ def _quota_read_model(
1345
1585
  "sample_span_seconds": forecast.sample_span_seconds,
1346
1586
  "confidence": forecast.confidence,
1347
1587
  },
1348
- })
1588
+ }
1589
+ history_rows.append(row)
1590
+ if _codex_history_row_is_model_scoped(row):
1591
+ continue
1349
1592
  if baseline is not None and baseline.resets_at > context.now_utc:
1350
1593
  active_rows.append({
1351
1594
  "key": dashboard_resource_key("quota", "codex", *key_parts),
@@ -1463,10 +1706,28 @@ def _quota_read_model(
1463
1706
  "fresh" if active_rows and all(row["freshness"] == "fresh" for row in active_rows)
1464
1707
  else ("unavailable" if not active_rows else "stale")
1465
1708
  )
1466
- # Active identities are presentation-critical. Keep them ahead of
1467
- # inactive retained history before enforcing the public cardinality cap.
1709
+ # Active account identities are presentation-critical. Independent
1710
+ # model-scoped pools are also legitimate provider facts, so reserve the
1711
+ # remaining cap space for their newest captures before inactive account
1712
+ # history. Opaque resource-key order is only a stable tie-breaker.
1468
1713
  active_keys = {str(row["key"]) for row in active_rows}
1469
- history_rows.sort(key=lambda row: (str(row["key"]) not in active_keys, str(row["key"])))
1714
+
1715
+ def _history_retention_key(row):
1716
+ key = str(row["key"])
1717
+ if key in active_keys:
1718
+ return (0, 0.0, key)
1719
+ if _codex_history_row_is_model_scoped(row):
1720
+ captured_at = row.get("captured_at")
1721
+ try:
1722
+ captured_epoch = dt.datetime.fromisoformat(
1723
+ str(captured_at).replace("Z", "+00:00")
1724
+ ).timestamp()
1725
+ except (TypeError, ValueError):
1726
+ captured_epoch = float("-inf")
1727
+ return (1, -captured_epoch, key)
1728
+ return (2, 0.0, key)
1729
+
1730
+ history_rows.sort(key=_history_retention_key)
1470
1731
  history_rows = history_rows[:SOURCE_HISTORY_LIMIT]
1471
1732
  milestone_rows.sort(key=lambda row: str(row["captured_at"]), reverse=True)
1472
1733
  milestone_rows = milestone_rows[:SOURCE_HISTORY_LIMIT]
@@ -1501,67 +1762,25 @@ def _clock_freshness(
1501
1762
  return "stale" if age_seconds > stale_after else "fresh"
1502
1763
 
1503
1764
 
1504
- def _clock_cycle_validity(
1505
- histories: Iterable[object],
1506
- now_utc: dt.datetime,
1507
- ) -> tuple[bool, str]:
1508
- """Re-evaluate frozen weekly evidence without touching cache or rollouts.
1509
-
1510
- Per-account (#341 Task 4): boundaries are grouped by the history row's
1511
- ``account_key`` (serialized only when the Codex provider is DECORATED, i.e.
1512
- >1 real account — R8). The idle clock mirrors the build-time
1513
- ``_resolve_codex_weekly_cycle`` resolution: an account yields a live cycle iff
1514
- it has EXACTLY ONE fresh future boundary, and the hero is valid iff AT LEAST
1515
- ONE account does (raising the aggregate ``conflicting > stale > missing``
1516
- reason only when NO account yields one). When ``account_key`` is absent
1517
- (<=1-real-account install — no decoration) every row falls into one global
1518
- bucket, so this reduces EXACTLY to the prior single-boundary logic
1519
- (byte-stable). This removes the documented placeholder degrade where two real
1520
- accounts with distinct weekly cycles collapsed the whole hero to
1521
- ``conflicting`` on the idle clock.
1765
+ def _clock_cycle_expired(cycle: object, now_utc: dt.datetime) -> bool:
1766
+ """Whether a retained hero cycle has already reset (#350 spec §3.3).
1767
+
1768
+ The one invariant the idle clock still enforces on frozen evidence: a cycle
1769
+ whose ``resets_at`` is at or before ``now_utc`` cannot bound current
1770
+ accounting. Fails CLOSED on an unparseable or absent boundary, matching the
1771
+ prior behavior where malformed evidence yielded no valid boundary.
1522
1772
  """
1523
- per_account: dict[str, set[dt.datetime]] = {}
1524
- stale_by_account: dict[str, bool] = {}
1525
- for raw_history in histories:
1526
- if not isinstance(raw_history, Mapping):
1527
- continue
1528
- if raw_history.get("window_minutes") != 10_080:
1529
- continue
1530
- current = raw_history.get("current_percent")
1531
- forecast = raw_history.get("forecast")
1532
- if current is None or not isinstance(forecast, Mapping):
1533
- continue
1534
- try:
1535
- resets_at = dt.datetime.fromisoformat(
1536
- str(forecast.get("resets_at")).replace("Z", "+00:00")
1537
- ).astimezone(UTC)
1538
- except (TypeError, ValueError):
1539
- continue
1540
- if resets_at <= now_utc:
1541
- continue
1542
- # None account_key (undecorated / <=1 real account) -> one global bucket
1543
- # == today's behavior. A real key buckets per account.
1544
- acct = raw_history.get("account_key")
1545
- bucket = acct if acct is not None else "__all__"
1546
- if raw_history.get("freshness") != "fresh":
1547
- stale_by_account[bucket] = True
1548
- continue
1549
- per_account.setdefault(bucket, set()).add(resets_at)
1550
- accounts = set(per_account) | set(stale_by_account)
1551
- reasons: list[str] = []
1552
- for acct in accounts:
1553
- boundaries = per_account.get(acct, set())
1554
- if len(boundaries) == 1:
1555
- return True, "ok" # at least one account yields a live cycle
1556
- reasons.append(
1557
- "conflicting" if boundaries
1558
- else ("stale" if stale_by_account.get(acct) else "missing")
1773
+ if not isinstance(cycle, Mapping):
1774
+ return True
1775
+ try:
1776
+ resets_at = dt.datetime.fromisoformat(
1777
+ str(cycle.get("resets_at")).replace("Z", "+00:00")
1559
1778
  )
1560
- if "conflicting" in reasons:
1561
- return False, "conflicting"
1562
- if "stale" in reasons:
1563
- return False, "stale"
1564
- return False, "missing"
1779
+ except (TypeError, ValueError):
1780
+ return True
1781
+ if resets_at.tzinfo is None or resets_at.utcoffset() is None:
1782
+ return True
1783
+ return resets_at.astimezone(UTC) <= now_utc
1565
1784
 
1566
1785
 
1567
1786
  def _refresh_budget_status_clock(
@@ -1638,6 +1857,7 @@ def refresh_codex_source_clock(
1638
1857
  warnings = state.warnings
1639
1858
  availability = state.availability
1640
1859
  freshness = state.freshness
1860
+ domain_freshness = dict(state.domain_freshness or {})
1641
1861
  if isinstance(quota, Mapping):
1642
1862
  quota = dict(quota)
1643
1863
  refreshed_histories: list[dict[str, object]] = []
@@ -1646,10 +1866,16 @@ def refresh_codex_source_clock(
1646
1866
  if not isinstance(raw_history, Mapping):
1647
1867
  continue
1648
1868
  history = dict(raw_history)
1649
- freshness = _clock_freshness(
1869
+ # #350 spec §3.9: this is a PER-ROW value and must never shadow the
1870
+ # envelope-level `freshness`. It used to, so after the loop the
1871
+ # envelope held the LAST retained history row's freshness — often an
1872
+ # inactive row, and with a single weekly history the active weekly
1873
+ # one, which silently marked the whole provider stale on an idle
1874
+ # stale crossing and tripped idle eligibility on its own.
1875
+ row_freshness = _clock_freshness(
1650
1876
  history.get("captured_at"), history.get("stale_after_seconds"), now_utc,
1651
1877
  )
1652
- history["freshness"] = freshness
1878
+ history["freshness"] = row_freshness
1653
1879
  forecast = history.get("forecast")
1654
1880
  if isinstance(forecast, Mapping):
1655
1881
  forecast = dict(forecast)
@@ -1663,9 +1889,9 @@ def refresh_codex_source_clock(
1663
1889
  remaining = max(0, int((reset - now_utc).total_seconds())) if reset else None
1664
1890
  forecast["remaining_seconds"] = remaining
1665
1891
  sample_count = int(forecast.get("sample_count") or 0)
1666
- if freshness == "future":
1892
+ if row_freshness == "future":
1667
1893
  forecast["status"] = "future"
1668
- elif freshness == "stale":
1894
+ elif row_freshness == "stale":
1669
1895
  forecast["status"] = "stale"
1670
1896
  elif sample_count == 0:
1671
1897
  forecast["status"] = "insufficient-history"
@@ -1682,13 +1908,18 @@ def refresh_codex_source_clock(
1682
1908
  100.0, max(float(current), float(current) + float(rate) * remaining / 3600),
1683
1909
  )
1684
1910
  history["forecast"] = forecast
1685
- if reset is not None and reset > now_utc and current is not None:
1911
+ # #373: same rule as the initial build, through the same
1912
+ # predicate, so the two paths cannot drift.
1913
+ if (
1914
+ not _codex_history_row_is_model_scoped(history)
1915
+ and reset is not None and reset > now_utc and current is not None
1916
+ ):
1686
1917
  active_rows.append({
1687
1918
  "key": history.get("key"),
1688
1919
  "current_percent": current,
1689
1920
  "captured_at": history.get("captured_at"),
1690
1921
  "resets_at": resets_at,
1691
- "freshness": freshness,
1922
+ "freshness": row_freshness,
1692
1923
  "stale_after_seconds": history.get("stale_after_seconds"),
1693
1924
  })
1694
1925
  refreshed_histories.append(history)
@@ -1716,6 +1947,13 @@ def refresh_codex_source_clock(
1716
1947
  ),
1717
1948
  "active": active_rows,
1718
1949
  })
1950
+ # Only account-level active histories reach ``active_rows``; the shared
1951
+ # model-scoped predicate above excludes foreign pools. An unavailable
1952
+ # active set is a capability/data-availability fact, not invented
1953
+ # staleness, so only the exact stale verdict moves this axis.
1954
+ domain_freshness["quota"] = (
1955
+ "stale" if summary["freshness"] == "stale" else "fresh"
1956
+ )
1719
1957
  quota["summary"] = summary
1720
1958
  data["quota"] = quota
1721
1959
  quota_changed = bool(refreshed_histories)
@@ -1727,8 +1965,18 @@ def refresh_codex_source_clock(
1727
1965
  and hero_capability is not None
1728
1966
  and hero_capability.status == "supported"
1729
1967
  ):
1730
- cycle_valid, cycle_reason = _clock_cycle_validity(refreshed_histories, now_utc)
1731
- if not cycle_valid:
1968
+ # #350 spec §3.3: the clock no longer RE-DERIVES cycle validity.
1969
+ # Its public-history view is lossy (capped, no `logical_limit_key`,
1970
+ # no `quota_identity`), so it cannot resolve the cycle correctly —
1971
+ # and per §2.2 it cannot simply trust the old verdict forever either,
1972
+ # because resolution is time-dependent on frozen evidence. Build time
1973
+ # owns resolution and records a decision deadline in `clock_data`; the
1974
+ # tick rebuilds authoritatively at the crossing. All the clock keeps
1975
+ # is this cheap invariant guard: a cycle that has already RESET cannot
1976
+ # bound current accounting, so it degrades exactly as before.
1977
+ # Expiry is also deadline candidate #1, so the two paths are disjoint
1978
+ # belt-and-suspenders rather than a single mechanism.
1979
+ if _clock_cycle_expired(hero.get("cycle"), now_utc):
1732
1980
  hero = dict(hero)
1733
1981
  for field in (
1734
1982
  "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
@@ -1750,8 +1998,6 @@ def refresh_codex_source_clock(
1750
1998
  "hero",
1751
1999
  ),)
1752
2000
  availability = "partial"
1753
- if cycle_reason == "stale":
1754
- freshness = "stale"
1755
2001
  cycle_changed = True
1756
2002
  budget_domain = data.get("budget")
1757
2003
  budget_changed = False
@@ -1785,9 +2031,11 @@ def refresh_codex_source_clock(
1785
2031
  last_success_at=state.last_success_at,
1786
2032
  capabilities=capabilities,
1787
2033
  data=data,
2034
+ domain_freshness=domain_freshness,
1788
2035
  clock_data=state.clock_data,
2036
+ private_session_labels=state.private_session_labels,
1789
2037
  )
1790
- return state if refreshed_state.data == state.data else refreshed_state
2038
+ return state if refreshed_state == state else refreshed_state
1791
2039
 
1792
2040
 
1793
2041
  def _alerts_wire(stats_conn: sqlite3.Connection) -> tuple[dict[str, object], ...]:
@@ -1937,7 +2185,10 @@ def _partial_projects_wire(
1937
2185
  "reasoning_output_tokens": 0, "total_tokens": 0,
1938
2186
  })
1939
2187
  session_totals = group["session_rows"].setdefault(identity, {
1940
- "label": str(row_metadata.get("title") or "Session"),
2188
+ # A persisted Codex task title is transcript-derived content. The
2189
+ # partial project projection is shared across every dashboard
2190
+ # client, so retain only a non-sensitive generic label here.
2191
+ "label": "Session",
1941
2192
  "last_activity": timestamp.astimezone(UTC).isoformat(),
1942
2193
  "cost_usd": 0.0, "input_tokens": 0, "cached_input_tokens": 0,
1943
2194
  "output_tokens": 0, "reasoning_output_tokens": 0, "total_tokens": 0,
@@ -2105,7 +2356,12 @@ def _codex_account_five_hour_percent(
2105
2356
  for history in build_history(tuple(observations)):
2106
2357
  if history.identity.window_minutes != 300:
2107
2358
  continue
2359
+ # #373: the retained `codex_bengalfox` 5h rows are on the PRIMARY slot —
2360
+ # the same slot this account aggregate reads — so a foreign pool at 95%
2361
+ # would win the max outright over the real account window.
2108
2362
  baseline = select_baseline(history.observations, now_utc)
2363
+ if codex_history_is_model_scoped(history, baseline=baseline):
2364
+ continue
2109
2365
  if baseline is None or baseline.resets_at <= now_utc:
2110
2366
  continue
2111
2367
  acct = history.identity.account_key
@@ -2560,10 +2816,13 @@ def build_codex_source_state(
2560
2816
  # degrade to the byte-stable undecorated shape.
2561
2817
  accounts_wire = []
2562
2818
  hero_cycles_wire = []
2819
+ private_session_labels: dict[str, str] = {}
2563
2820
  return SourceDashboardState(
2564
2821
  source="codex",
2565
2822
  availability=availability,
2566
- freshness=("stale" if cycle_reason == "stale" else "fresh"),
2823
+ # A successful source build is one coherent provider generation. Quota
2824
+ # observation age and weekly-cycle evidence live on their own axes.
2825
+ freshness="fresh",
2567
2826
  warnings=tuple(warnings),
2568
2827
  data_version=data_version,
2569
2828
  last_success_at=context.now_utc,
@@ -2609,6 +2868,15 @@ def build_codex_source_state(
2609
2868
  }
2610
2869
  if cycle is not None and not hero_failure else None
2611
2870
  ),
2871
+ # #350 (spec §3.4): additive, hero-local staleness disclosure.
2872
+ # OMITTED when the cycle is fresh — never emitted as "fresh" —
2873
+ # for the legacy client transition. Provider metadata remains
2874
+ # coherent; ``domain_freshness.hero`` owns the shared axis.
2875
+ **(
2876
+ {"cycle_freshness": "stale"}
2877
+ if cycle is not None and not hero_failure and cycle.evidence_stale
2878
+ else {}
2879
+ ),
2612
2880
  "quota": quota["summary"],
2613
2881
  "budget": configured_budget,
2614
2882
  "alerts": {"count": len(alerts)},
@@ -2620,7 +2888,11 @@ def build_codex_source_state(
2620
2888
  "monthly": _period_wire(monthly),
2621
2889
  "weekly": _period_wire(weekly),
2622
2890
  },
2623
- "sessions": _session_wire(sessions, metadata=conversation_metadata),
2891
+ "sessions": _session_wire(
2892
+ sessions,
2893
+ metadata=conversation_metadata,
2894
+ private_labels=private_session_labels,
2895
+ ),
2624
2896
  "quota": quota,
2625
2897
  "budget": {
2626
2898
  "status": configured_budget,
@@ -2635,5 +2907,32 @@ def build_codex_source_state(
2635
2907
  },
2636
2908
  "cache_report": cache_report,
2637
2909
  },
2638
- clock_data={"codex_budget_cost_events": budget_cost_events},
2910
+ domain_freshness={
2911
+ "hero": (
2912
+ "stale"
2913
+ if cycle_reason == "stale"
2914
+ or (
2915
+ cycle is not None
2916
+ and not hero_failure
2917
+ and cycle.evidence_stale
2918
+ )
2919
+ else "fresh"
2920
+ ),
2921
+ "quota": (
2922
+ "stale"
2923
+ if quota["summary"]["freshness"] == "stale"
2924
+ else "fresh"
2925
+ ),
2926
+ "sessions": "fresh",
2927
+ },
2928
+ clock_data={
2929
+ "codex_budget_cost_events": budget_cost_events,
2930
+ # #350 spec §3.3: when the tick passes this instant it must rebuild
2931
+ # Codex authoritatively instead of idle-clocking or reusing, because
2932
+ # weekly-cycle resolution can change on identical frozen evidence.
2933
+ "codex_next_decision_at": _codex_next_decision_at(
2934
+ quota_observations, cycles_all, context.now_utc,
2935
+ ),
2936
+ },
2937
+ private_session_labels=private_session_labels,
2639
2938
  )