cctally 1.90.1 → 1.92.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 (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +2 -2
  3. package/bin/_cctally_cache.py +863 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +53 -8
  6. package/bin/_cctally_dashboard.py +146 -5
  7. package/bin/_cctally_dashboard_conversation.py +164 -18
  8. package/bin/_cctally_dashboard_envelope.py +69 -12
  9. package/bin/_cctally_dashboard_sources.py +27 -1
  10. package/bin/_cctally_db.py +372 -10
  11. package/bin/_cctally_doctor.py +18 -1
  12. package/bin/_cctally_journal.py +535 -13
  13. package/bin/_cctally_journal_repair.py +6 -0
  14. package/bin/_cctally_parser.py +6 -0
  15. package/bin/_cctally_quota.py +171 -55
  16. package/bin/_cctally_record.py +13 -1
  17. package/bin/_cctally_rederive.py +4 -0
  18. package/bin/_cctally_store.py +311 -6
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_lib_cache_report.py +8 -3
  21. package/bin/_lib_cache_report_wire.py +8 -20
  22. package/bin/_lib_codex_conversation.py +959 -81
  23. package/bin/_lib_codex_conversation_query.py +2792 -167
  24. package/bin/_lib_codex_find_projection.py +370 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_reasoning_headings.py +73 -0
  30. package/bin/_lib_codex_segments.py +259 -0
  31. package/bin/_lib_codex_title_clean.py +116 -0
  32. package/bin/_lib_conversation_dispatch.py +153 -21
  33. package/bin/_lib_conversation_watch.py +4 -2
  34. package/bin/_lib_dashboard_sources.py +33 -32
  35. package/bin/_lib_doctor.py +64 -0
  36. package/bin/_lib_quota_alert_axes.py +31 -34
  37. package/bin/_lib_stats_damage.py +523 -0
  38. package/bin/cctally +5 -0
  39. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  40. package/dashboard/static/assets/index-DnWdv8um.css +1 -0
  41. package/dashboard/static/dashboard.html +2 -2
  42. package/package.json +9 -1
  43. package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
  44. package/dashboard/static/assets/index-CRogVlEC.js +0 -92
@@ -45,6 +45,7 @@ import sys
45
45
 
46
46
  from _cctally_cache import (
47
47
  open_cache_db,
48
+ scope_conversations_db_to_account,
48
49
  sync_codex_cache,
49
50
  sync_claude_conversations,
50
51
  sync_codex_conversations,
@@ -222,13 +223,15 @@ def _conversation_query_impl():
222
223
  # are deliberately absent — they are meaningless on a collection route, so they
223
224
  # fall to "genuinely unknown → ignored".
224
225
  _RECOGNIZED_CONVERSATION_PARAMS = (
225
- "source", "project_key", "model", "limit", "cursor", "q", "kind",
226
+ "source", "account", "project_key", "model", "limit", "cursor", "q", "kind",
226
227
  "sort", "offset", "date_from", "date_to", "projects",
227
228
  "cost_min", "cost_max", "rebuild_min", "models",
228
229
  )
229
- _QUALIFIED_BROWSE_ACCEPTED = ("source", "project_key", "model", "limit", "cursor")
230
- _QUALIFIED_SEARCH_ACCEPTED = ("source", "q", "kind", "limit", "cursor")
231
- _QUALIFIED_FACETS_ACCEPTED = ("source",)
230
+ _QUALIFIED_BROWSE_ACCEPTED = (
231
+ "source", "account", "project_key", "model", "limit", "cursor")
232
+ _QUALIFIED_SEARCH_ACCEPTED = (
233
+ "source", "account", "q", "kind", "limit", "cursor")
234
+ _QUALIFIED_FACETS_ACCEPTED = ("source", "account")
232
235
  # A raw browse cursor is a conversation key — printable + URL-safe by construction
233
236
  # (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
234
237
  _BROWSE_CURSOR_RE = re.compile(r"\A[!-~]+\Z")
@@ -321,8 +324,12 @@ def _respond_qualified_json(handler, env):
321
324
  ``ok`` / ``normalization_pending`` → 200; ``gone`` → 410; ``validation_error``
322
325
  → 400; anything else (``not_found`` + unknown) → 404. Body is the envelope."""
323
326
  status = (env or {}).get("status")
324
- if status in ("ok", "normalization_pending"):
327
+ if status in ("ok", "normalization_pending", "ready", "indexing"):
325
328
  handler._respond_json(200, env)
329
+ elif status == "invalid_find_cursor":
330
+ handler._respond_json(400, {"error": "invalid find cursor"})
331
+ elif status == "stale_find_cursor":
332
+ handler._respond_json(409, {"error": "stale find cursor"})
326
333
  elif status == "gone":
327
334
  handler._respond_json(410, env)
328
335
  elif status == "validation_error":
@@ -378,7 +385,7 @@ def _handle_qualified_facets(handler, qs_raw, source):
378
385
  speed = _resolve_effective_speed()
379
386
  disp = _conversation_dispatch()
380
387
  ok, body = handler._run_conversation_query(
381
- lambda conn: disp.neutral_browse(
388
+ lambda conn: disp.neutral_facets(
382
389
  conn, source=source, effective_speed=speed),
383
390
  "/api/conversations/facets")
384
391
  if not ok:
@@ -469,6 +476,15 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
469
476
  )
470
477
  return False, None
471
478
  try:
479
+ import urllib.parse as _u
480
+ account_vals = _u.parse_qs(
481
+ handler.path.partition("?")[2], keep_blank_values=True
482
+ ).get("account")
483
+ if account_vals is not None:
484
+ if len(account_vals) != 1 or not account_vals[0]:
485
+ handler._respond_json(400, {"error": "invalid account"})
486
+ return False, None
487
+ scope_conversations_db_to_account(conn, account_vals[0])
472
488
  body = kernel_call(conn)
473
489
  except Exception as exc: # noqa: BLE001
474
490
  handler.log_error("%s failed: %r", log_label, exc)
@@ -794,25 +810,59 @@ def _run_conversation_events_stream(
794
810
  idle = 0.0
795
811
 
796
812
 
797
- def _bare_conversation_events(handler, session_id: str) -> None:
813
+ def _bare_conversation_events(
814
+ handler, session_id: str, account_key: str | None = None,
815
+ ) -> None:
798
816
  """Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
799
817
  behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
800
818
  cq = handler._conversation_query()
801
- _send_sse_headers(handler)
802
819
  passive = bool(type(handler).no_sync)
803
820
  try:
804
821
  conn = sys.modules["_cctally_dashboard"].open_conversations_db()
805
822
  except (sqlite3.DatabaseError, OSError):
806
- # Cache unavailable — degrade to keep-alive only; client backstop
807
- # tick still surfaces turns. (Headers already sent; can't 500.)
823
+ if account_key is not None:
824
+ # A qualified account boundary must be established before any SSE
825
+ # bytes. If it cannot be proven, fail closed as JSON.
826
+ handler._respond_json(500, {"error": "internal error"})
827
+ return
828
+ # Unqualified legacy behavior: degrade to keep-alive only; the client
829
+ # backstop tick still surfaces turns.
808
830
  passive = True
809
831
  conn = None
832
+ if conn is not None and account_key is not None:
833
+ scope_conversations_db_to_account(conn, account_key)
834
+ if conn.execute(
835
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
836
+ (session_id,),
837
+ ).fetchone() is None:
838
+ conn.close()
839
+ handler._respond_json(404, {"error": "conversation not found"})
840
+ return
841
+ _send_sse_headers(handler)
810
842
 
811
843
  def _resolve():
812
844
  return cq.session_source_paths(conn, session_id) if conn else []
813
845
 
814
846
  def _ingest(changed):
815
- return sync_claude_conversations(conn, only_paths=set(changed))
847
+ if account_key is None:
848
+ return sync_claude_conversations(conn, only_paths=set(changed))
849
+ before = conn.execute(
850
+ "SELECT COUNT(*),MAX(id) FROM conversation_messages "
851
+ "WHERE session_id=?",
852
+ (session_id,),
853
+ ).fetchone()
854
+ writer = sys.modules["_cctally_dashboard"].open_conversations_db()
855
+ try:
856
+ stats = sync_claude_conversations(writer, only_paths=set(changed))
857
+ finally:
858
+ writer.close()
859
+ after = conn.execute(
860
+ "SELECT COUNT(*),MAX(id) FROM conversation_messages "
861
+ "WHERE session_id=?",
862
+ (session_id,),
863
+ ).fetchone()
864
+ stats.targeted_visible = after != before
865
+ return stats
816
866
 
817
867
  try:
818
868
  _run_conversation_events_stream(
@@ -895,7 +945,9 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
895
945
  return _discovery
896
946
 
897
947
 
898
- def _qualified_conversation_events(handler, key: str) -> None:
948
+ def _qualified_conversation_events(
949
+ handler, key: str, account_key: str | None = None,
950
+ ) -> None:
899
951
  """Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
900
952
  normalization authority (Codex) → existence — answered as plain JSON per
901
953
  §2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
@@ -924,6 +976,8 @@ def _qualified_conversation_events(handler, key: str) -> None:
924
976
  except Exception as exc: # noqa: BLE001
925
977
  handler.log_error("api/conversation/events stream failed: %r", exc)
926
978
  return
979
+ if account_key is not None:
980
+ scope_conversations_db_to_account(conn, account_key)
927
981
 
928
982
  try:
929
983
  preflight = disp.neutral_events_preflight(conn, key)
@@ -959,10 +1013,43 @@ def _qualified_conversation_events(handler, key: str) -> None:
959
1013
  return cq_codex.codex_conversation_source_paths(conn, key)
960
1014
 
961
1015
  def _ingest(changed):
962
- return sync_codex_conversations(conn, only_paths=set(changed))
1016
+ if account_key is None:
1017
+ return sync_codex_conversations(conn, only_paths=set(changed))
1018
+ before = conn.execute(
1019
+ "SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
1020
+ "WHERE conversation_key=?",
1021
+ (key,),
1022
+ ).fetchone()
1023
+ # The accounting cache owns the durable physical account-range
1024
+ # decision. Advance it first so transcript ingest never treats the
1025
+ # previous range as open-ended across an account switch.
1026
+ accounting = open_cache_db()
1027
+ try:
1028
+ accounting_stats = sync_codex_cache(
1029
+ accounting, only_paths=set(changed)
1030
+ )
1031
+ finally:
1032
+ accounting.close()
1033
+ if not accounting_stats.targeted_clean:
1034
+ return accounting_stats
1035
+ writer = sys.modules["_cctally_dashboard"].open_conversations_db()
1036
+ try:
1037
+ stats = sync_codex_conversations(writer, only_paths=set(changed))
1038
+ finally:
1039
+ writer.close()
1040
+ after = conn.execute(
1041
+ "SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
1042
+ "WHERE conversation_key=?",
1043
+ (key,),
1044
+ ).fetchone()
1045
+ stats.targeted_visible = after != before
1046
+ return stats
963
1047
 
964
1048
  cached = lambda paths: _codex_cached_file_sigs(conn, paths)
965
- discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
1049
+ discovery = (
1050
+ None if account_key is not None
1051
+ else _make_codex_discovery_step(handler, conn, key, cq_codex)
1052
+ )
966
1053
  else: # claude — reuse the Claude mechanics, speak qualified frames.
967
1054
  cq = handler._conversation_query()
968
1055
 
@@ -970,7 +1057,25 @@ def _qualified_conversation_events(handler, key: str) -> None:
970
1057
  return cq.session_source_paths(conn, native)
971
1058
 
972
1059
  def _ingest(changed):
973
- return sync_claude_conversations(conn, only_paths=set(changed))
1060
+ if account_key is None:
1061
+ return sync_claude_conversations(conn, only_paths=set(changed))
1062
+ before = conn.execute(
1063
+ "SELECT COUNT(*),MAX(id) FROM conversation_messages "
1064
+ "WHERE session_id=?",
1065
+ (native,),
1066
+ ).fetchone()
1067
+ writer = sys.modules["_cctally_dashboard"].open_conversations_db()
1068
+ try:
1069
+ stats = sync_claude_conversations(writer, only_paths=set(changed))
1070
+ finally:
1071
+ writer.close()
1072
+ after = conn.execute(
1073
+ "SELECT COUNT(*),MAX(id) FROM conversation_messages "
1074
+ "WHERE session_id=?",
1075
+ (native,),
1076
+ ).fetchone()
1077
+ stats.targeted_visible = after != before
1078
+ return stats
974
1079
 
975
1080
  cached = lambda paths: _cached_file_sigs(conn, paths)
976
1081
  discovery = None
@@ -999,14 +1104,23 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
999
1104
  if not handler._require_transcripts_allowed():
1000
1105
  return
1001
1106
  import urllib.parse as _u
1107
+ account_vals = _u.parse_qs(
1108
+ handler.path.partition("?")[2], keep_blank_values=True
1109
+ ).get("account")
1110
+ if account_vals is not None and (
1111
+ len(account_vals) != 1 or not account_vals[0]
1112
+ ):
1113
+ handler._respond_json(400, {"error": "invalid account"})
1114
+ return
1115
+ account_key = account_vals[0] if account_vals else None
1002
1116
  key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
1003
1117
  if not key:
1004
1118
  handler.send_error(404, "conversation not found")
1005
1119
  return
1006
1120
  if key.startswith("v1."):
1007
- _qualified_conversation_events(handler, key)
1121
+ _qualified_conversation_events(handler, key, account_key)
1008
1122
  else:
1009
- _bare_conversation_events(handler, key)
1123
+ _bare_conversation_events(handler, key, account_key)
1010
1124
 
1011
1125
 
1012
1126
  def _handle_get_conversation_search_impl(handler) -> None:
@@ -1407,10 +1521,42 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
1407
1521
  return
1408
1522
  if session_id.startswith("v1."):
1409
1523
  disp = _conversation_dispatch()
1524
+ cref = disp.resolve_conversation_ref(session_id)
1525
+ paging = {}
1526
+ if cref is not None and cref.source == "codex":
1527
+ raw_limit = _qs_str(q, "limit", None)
1528
+ if raw_limit is None:
1529
+ limit = 100
1530
+ else:
1531
+ try:
1532
+ limit = int(raw_limit)
1533
+ except (TypeError, ValueError):
1534
+ handler._respond_json(400, {"error": "invalid find limit"})
1535
+ return
1536
+ if not 1 <= limit <= 200:
1537
+ handler._respond_json(400, {"error": "invalid find limit"})
1538
+ return
1539
+ cursor = _qs_str(q, "cursor", None)
1540
+ around = _qs_str(q, "around", None)
1541
+ direction = _qs_str(q, "direction", "next")
1542
+ if direction not in ("next", "previous"):
1543
+ handler._respond_json(400, {"error": "invalid find direction"})
1544
+ return
1545
+ if cursor is not None and around is not None:
1546
+ handler._respond_json(
1547
+ 400, {"error": "find cursor and around are mutually exclusive"})
1548
+ return
1549
+ paging = {
1550
+ "limit": limit,
1551
+ "cursor": cursor,
1552
+ "direction": direction,
1553
+ "around": around,
1554
+ }
1410
1555
  _serve_qualified_entity(
1411
1556
  handler,
1412
1557
  lambda conn: disp.neutral_find(
1413
- conn, session_id, query, kind=kind, regex=regex, case=case),
1558
+ conn, session_id, query, kind=kind, regex=regex, case=case,
1559
+ **paging),
1414
1560
  "/api/conversation/find")
1415
1561
  return
1416
1562
  ok, body = handler._run_conversation_query(
@@ -41,6 +41,7 @@ import bisect
41
41
  import datetime as dt
42
42
  import importlib.util as _ilu
43
43
  import os
44
+ import sqlite3
44
45
  import sys
45
46
  from collections.abc import Mapping
46
47
  from zoneinfo import ZoneInfo
@@ -329,7 +330,40 @@ def _select_current_block_for_envelope(
329
330
  # ``FROM`` clause so the table name lives in the registry, not inlined here.
330
331
 
331
332
 
332
- def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
333
+ def _alert_account_resolver(conn: sqlite3.Connection):
334
+ """Build one snapshot-scoped #345 resolver without per-row DB reads."""
335
+ import _cctally_account
336
+
337
+ decorated = {
338
+ provider: _cctally_account.provider_is_decorated(conn, provider)
339
+ for provider in ("claude", "codex")
340
+ }
341
+ labels = {
342
+ provider: _cctally_account.display_label_map(conn, provider)
343
+ for provider, enabled in decorated.items()
344
+ if enabled
345
+ }
346
+ for provider_labels in labels.values():
347
+ provider_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
348
+
349
+ def fields(provider: str, account_key: object) -> dict[str, str]:
350
+ if not decorated.get(provider, False):
351
+ return {}
352
+ key = str(account_key or "*")
353
+ label = labels[provider].get(key)
354
+ if label is None:
355
+ label = _cctally_account.account_label(conn, key)
356
+ return {
357
+ "accountKey": key,
358
+ "accountLabel": label,
359
+ }
360
+
361
+ return fields
362
+
363
+
364
+ def _envelope_rows_weekly(
365
+ conn, descriptor, limit, severity_for, account_fields,
366
+ ) -> list[dict]:
333
367
  # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
334
368
  # across pre-credit (0) and post-credit (event.id) cohorts, both
335
369
  # of which can be alerted. The envelope id must include the
@@ -340,7 +374,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
340
374
  rows = conn.execute(
341
375
  f"""
342
376
  SELECT week_start_date, percent_threshold, captured_at_utc,
343
- alerted_at, cumulative_cost_usd, reset_event_id
377
+ alerted_at, cumulative_cost_usd, reset_event_id, account_key
344
378
  FROM {descriptor.milestone_table}
345
379
  WHERE alerted_at IS NOT NULL
346
380
  ORDER BY alerted_at DESC
@@ -360,6 +394,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
360
394
  "severity": severity_for(threshold),
361
395
  "crossed_at": r["captured_at_utc"],
362
396
  "alerted_at": r["alerted_at"],
397
+ **account_fields("claude", r["account_key"]),
363
398
  "context": {
364
399
  "week_start_date": r["week_start_date"],
365
400
  "cumulative_cost_usd": cumulative,
@@ -377,7 +412,9 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
377
412
  return out
378
413
 
379
414
 
380
- def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
415
+ def _envelope_rows_five_hour(
416
+ conn, descriptor, limit, severity_for, account_fields,
417
+ ) -> list[dict]:
381
418
  # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
382
419
  # include ``reset_event_id`` so post-credit (seg=event.id) crossings
383
420
  # of the same (window_key, threshold) don't collide with pre-credit
@@ -387,10 +424,12 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
387
424
  rows = conn.execute(
388
425
  f"""
389
426
  SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
390
- m.alerted_at, m.block_cost_usd, m.reset_event_id,
427
+ m.alerted_at, m.block_cost_usd, m.reset_event_id, m.account_key,
391
428
  b.block_start_at
392
429
  FROM {descriptor.milestone_table} m
393
- LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
430
+ LEFT JOIN five_hour_blocks b
431
+ ON b.five_hour_window_key = m.five_hour_window_key
432
+ AND b.account_key = m.account_key
394
433
  WHERE m.alerted_at IS NOT NULL
395
434
  ORDER BY m.alerted_at DESC
396
435
  LIMIT ?
@@ -410,6 +449,7 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
410
449
  "severity": severity_for(threshold),
411
450
  "crossed_at": r["captured_at_utc"],
412
451
  "alerted_at": r["alerted_at"],
452
+ **account_fields("claude", r["account_key"]),
413
453
  "context": {
414
454
  "five_hour_window_key": int(r["five_hour_window_key"]),
415
455
  "block_start_at": r["block_start_at"] or "",
@@ -420,7 +460,9 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
420
460
  return out
421
461
 
422
462
 
423
- def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[dict]:
463
+ def _envelope_rows_budget_family(
464
+ conn, descriptor, limit, severity_for, account_fields,
465
+ ) -> list[dict]:
424
466
  # Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
425
467
  # ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
426
468
  # (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
@@ -461,7 +503,7 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
461
503
  SELECT period_start_at,
462
504
  COALESCE(period, ?) AS period,
463
505
  threshold, crossed_at_utc, alerted_at,
464
- budget_usd, spent_usd, consumption_pct
506
+ budget_usd, spent_usd, consumption_pct, account_key
465
507
  FROM {descriptor.milestone_table}
466
508
  WHERE vendor = ? AND alerted_at IS NOT NULL
467
509
  ORDER BY alerted_at DESC
@@ -498,12 +540,15 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
498
540
  "severity": severity_for(threshold),
499
541
  "crossed_at": r["crossed_at_utc"],
500
542
  "alerted_at": r["alerted_at"],
543
+ **account_fields(str(vendor), r["account_key"]),
501
544
  "context": ctx,
502
545
  })
503
546
  return out
504
547
 
505
548
 
506
- def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
549
+ def _envelope_rows_projected(
550
+ conn, descriptor, limit, severity_for, account_fields,
551
+ ) -> list[dict]:
507
552
  # Fourth axis (issue #121): projected-pace threshold crossings. Like
508
553
  # budget, projected alerts re-anchor ``week_start_at`` on a mid-week
509
554
  # reset, so there is NO ``reset_event_id`` segment — the new window gets
@@ -524,7 +569,7 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
524
569
  SELECT week_start_at,
525
570
  COALESCE(period, 'subscription-week') AS period,
526
571
  metric, threshold, projected_value,
527
- denominator, crossed_at_utc, alerted_at
572
+ denominator, crossed_at_utc, alerted_at, account_key
528
573
  FROM {descriptor.milestone_table}
529
574
  WHERE alerted_at IS NOT NULL
530
575
  ORDER BY alerted_at DESC
@@ -547,6 +592,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
547
592
  "severity": severity_for(threshold),
548
593
  "crossed_at": r["crossed_at_utc"],
549
594
  "alerted_at": r["alerted_at"],
595
+ **account_fields(
596
+ "codex" if metric == "codex_budget_usd" else "claude",
597
+ r["account_key"],
598
+ ),
550
599
  "context": {
551
600
  "week_start_at": r["week_start_at"],
552
601
  "metric": metric,
@@ -557,7 +606,9 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
557
606
  return out
558
607
 
559
608
 
560
- def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
609
+ def _envelope_rows_project_budget(
610
+ conn, descriptor, limit, severity_for, account_fields,
611
+ ) -> list[dict]:
561
612
  # Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
562
613
  # crossings. Like the global budget axis, project-budget alerts re-anchor
563
614
  # ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
@@ -575,7 +626,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
575
626
  rows = conn.execute(
576
627
  f"""
577
628
  SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
578
- consumption_pct, crossed_at_utc, alerted_at
629
+ consumption_pct, crossed_at_utc, alerted_at, account_key
579
630
  FROM {descriptor.milestone_table}
580
631
  WHERE alerted_at IS NOT NULL
581
632
  ORDER BY alerted_at DESC
@@ -605,6 +656,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
605
656
  "severity": severity_for(threshold),
606
657
  "crossed_at": r["crossed_at_utc"],
607
658
  "alerted_at": r["alerted_at"],
659
+ **account_fields("claude", r["account_key"]),
608
660
  "context": {
609
661
  "week_start_at": r["week_start_at"],
610
662
  "project": label_by_key.get(project_key, project_key),
@@ -795,12 +847,15 @@ def _build_alerts_envelope_array(
795
847
  c = sys.modules["cctally"]
796
848
  registry = c.AXIS_REGISTRY
797
849
  severity_for = c.severity_for
850
+ account_fields = _alert_account_resolver(conn)
798
851
  out: list[dict] = []
799
852
  for descriptor in registry:
800
853
  mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
801
854
  if mapper is None: # pragma: no cover - registry/mapper drift guard
802
855
  continue
803
- out.extend(mapper(conn, descriptor, limit, severity_for))
856
+ out.extend(mapper(
857
+ conn, descriptor, limit, severity_for, account_fields,
858
+ ))
804
859
 
805
860
  # Python's list.sort is stable. When two alerts share the same
806
861
  # `alerted_at` ISO string (rare; multiple axes firing within the same
@@ -1363,9 +1418,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1363
1418
  _cfg_for_alerts.get("dashboard"), dict) else {}
1364
1419
  _cfm = _dash_cfg.get("cache_failure_markers", True)
1365
1420
  _lt = _dash_cfg.get("live_tail", True)
1421
+ _lan_auth = _dash_cfg.get("lan_auth", True)
1366
1422
  dashboard_prefs = {
1367
1423
  "cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
1368
1424
  "live_tail": _lt if isinstance(_lt, bool) else True,
1425
+ "lan_auth": _lan_auth if isinstance(_lan_auth, bool) else True,
1369
1426
  }
1370
1427
 
1371
1428
  # Mirror update-state.json + update-suppress.json into the envelope
@@ -2422,9 +2422,29 @@ def _alerts_wire(
2422
2422
  so it stays visible under focus and the client labels it as vendor-wide.
2423
2423
  """
2424
2424
  rows: list[dict[str, object]] = []
2425
+ if decorated:
2426
+ import _cctally_account
2427
+
2428
+ account_labels = _cctally_account.display_label_map(stats_conn, "codex")
2429
+ account_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
2430
+ else:
2431
+ account_labels = {}
2425
2432
 
2426
2433
  def _account(value: object) -> dict[str, object]:
2427
- return {"account_key": str(value or _CODEX_VENDOR_WIDE_ACCOUNT)} if decorated else {}
2434
+ if not decorated:
2435
+ return {}
2436
+
2437
+ key = str(value or _CODEX_VENDOR_WIDE_ACCOUNT)
2438
+ label = account_labels.get(key)
2439
+ if label is None:
2440
+ label = _cctally_account.account_label(stats_conn, key)
2441
+ return {
2442
+ # Retained as the internal account-scope selector used by the
2443
+ # source-state builder; the camel fields are the public #345 wire.
2444
+ "account_key": key,
2445
+ "accountKey": key,
2446
+ "accountLabel": label,
2447
+ }
2428
2448
 
2429
2449
  try:
2430
2450
  for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
@@ -2880,6 +2900,12 @@ def _codex_accounts_wire(
2880
2900
  }
2881
2901
  if is_unattributed:
2882
2902
  card["unattributed"] = True
2903
+ elif cyc is not None and cyc.evidence_stale:
2904
+ # #360 / #416 closeout: freshness belongs to the account whose
2905
+ # resolved cycle supplied the card. The aggregate hero marker
2906
+ # cannot speak for a fresh sibling, and staleness is disclosure
2907
+ # only — the retained percentage, reset and spend remain useful.
2908
+ card["cycleFreshness"] = "stale"
2883
2909
  accounts_wire.append(card)
2884
2910
  if cyc is not None and not is_unattributed:
2885
2911
  hero_cycles_wire.append({